From 9c7023dd6d083990b506920f2316ee79a0c39c22 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:55:00 -0400 Subject: [PATCH 1/4] Restore chat thinking stream rendering --- .../services/chat/agentChatService.test.ts | 171 ++++++++ .../main/services/chat/agentChatService.ts | 102 ++++- .../src/main/services/ipc/registerIpc.ts | 8 + .../src/main/services/lanes/laneService.ts | 35 ++ .../src/main/services/sync/syncHostService.ts | 148 ++++++- .../services/sync/syncPairingStore.test.ts | 16 + .../main/services/sync/syncPairingStore.ts | 3 +- .../main/services/sync/syncService.test.ts | 24 ++ .../src/main/services/sync/syncService.ts | 36 +- apps/desktop/src/preload/global.d.ts | 1 + apps/desktop/src/preload/preload.ts | 2 + .../chat/AgentChatMessageList.test.tsx | 119 +++++- .../components/chat/AgentChatMessageList.tsx | 155 ++++---- .../components/chat/AgentChatPane.tsx | 3 +- .../components/chat/ChatWorkLogBlock.tsx | 27 +- .../renderer/components/lanes/LanesPage.tsx | 22 +- .../lanes/ManageLaneDialog.test.tsx | 96 +++++ .../components/lanes/ManageLaneDialog.tsx | 20 +- .../missions/MissionThreadMessageList.test.ts | 32 ++ .../missions/MissionThreadMessageList.tsx | 6 +- .../settings/SyncDevicesSection.tsx | 126 ++++++ apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/types/sync.ts | 19 + apps/ios/ADE.xcodeproj/project.pbxproj | 4 - apps/ios/ADE/Services/KeychainService.swift | 31 +- apps/ios/ADE/Services/SyncService.swift | 324 +++++++++++++-- .../Views/Components/ADEDesignSystem.swift | 62 ++- apps/ios/ADE/Views/Cto/CtoChatScreen.swift | 369 ------------------ apps/ios/ADE/Views/Cto/CtoRootScreen.swift | 34 +- .../Views/Cto/CtoSessionDestinationView.swift | 41 +- .../ios/ADE/Views/Cto/CtoSettingsScreen.swift | 192 ++++----- apps/ios/ADE/Views/Cto/CtoTabShell.swift | 2 - apps/ios/ADE/Views/Cto/CtoTeamScreen.swift | 287 ++++++++------ .../ADE/Views/Cto/CtoWorkerDetailScreen.swift | 2 +- .../ADE/Views/Cto/CtoWorkflowsScreen.swift | 5 +- .../Files/FilesDirectoryContentsView.swift | 2 +- .../Views/Files/FilesDirectoryScreen.swift | 2 +- .../ios/ADE/Views/Files/FilesRootScreen.swift | 11 +- .../ios/ADE/Views/Lanes/LaneManageSheet.swift | 6 +- apps/ios/ADE/Views/LanesTabView.swift | 25 +- .../ADE/Views/PRs/CreatePrWizardView.swift | 2 +- apps/ios/ADE/Views/PRs/PrDetailScreen.swift | 2 +- apps/ios/ADE/Views/PRs/PrRebaseScreen.swift | 2 +- apps/ios/ADE/Views/PRs/PrStackSheet.swift | 2 +- apps/ios/ADE/Views/PRs/PrsRootScreen.swift | 12 +- .../Settings/SettingsConnectionHeader.swift | 14 +- .../Settings/SettingsPairingSection.swift | 50 ++- .../Work/WorkChatSessionView+Timeline.swift | 127 +++--- .../ADE/Views/Work/WorkChatSessionView.swift | 63 +-- apps/ios/ADE/Views/Work/WorkRootScreen.swift | 13 +- apps/ios/ADETests/ADETests.swift | 10 +- 51 files changed, 1883 insertions(+), 985 deletions(-) create mode 100644 apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx delete mode 100644 apps/ios/ADE/Views/Cto/CtoChatScreen.swift diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 9757f4af8..07fa5b8a5 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -3617,6 +3617,69 @@ describe("createAgentChatService", () => { ]); }); + it("keeps Codex reasoning deltas tied to the active turn and thinking activity", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Think through the options.", + }, { awaitDispatch: true }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/reasoning/summaryTextDelta", + params: { + itemId: "reasoning-1", + delta: "Checking the relevant paths.", + }, + }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "reasoning" + && event.event.turnId === "turn-1" + && event.event.itemId === "reasoning-1", + ); + + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "activity", + activity: "thinking", + turnId: "turn-1", + }), + }), + expect.objectContaining({ + event: expect.objectContaining({ + type: "reasoning", + text: "Checking the relevant paths.", + itemId: "reasoning-1", + turnId: "turn-1", + }), + }), + ])); + }); + it("ignores unsolicited Codex turn notifications when no turn is active", async () => { const events: Array<{ type: string; turnId?: string; text?: string }> = []; const { service } = createService({ @@ -6581,6 +6644,109 @@ describe("createAgentChatService", () => { await sendPromise; }); + it("does not duplicate Claude thinking when the final assistant message repeats streamed content", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-thinking", + slash_commands: [], + }; + return; + } + + yield { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "" }, + }, + }; + yield { + type: "stream_event", + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "thinking_delta", + thinking: "Checking both imports before editing.", + }, + }, + }; + yield { + type: "assistant", + message: { + content: [{ type: "thinking", thinking: "Checking both imports before editing." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-thinking", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Resolve the PR comments.", + }); + + const reasoningEvents = events + .map((event) => event.event) + .filter((event): event is Extract => event.type === "reasoning"); + expect(reasoningEvents.map((event) => event.text)).toEqual(["Checking both imports before editing."]); + expect(events.some((event) => event.event.type === "activity" && event.event.activity === "thinking")).toBe(true); + const sessionOpts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { + executableArgs?: string[]; + settings?: Record; + } | undefined; + expect(sessionOpts?.settings).toEqual(expect.objectContaining({ + showThinkingSummaries: true, + alwaysThinkingEnabled: true, + })); + expect(sessionOpts?.executableArgs).toEqual(expect.arrayContaining([ + "--include-partial-messages", + "--thinking", + "adaptive", + "--thinking-display", + "summarized", + ])); + const settingsArgIndex = sessionOpts?.executableArgs?.indexOf("--settings") ?? -1; + expect(settingsArgIndex).toBeGreaterThanOrEqual(0); + const settingsJson = sessionOpts?.executableArgs?.[settingsArgIndex + 1]; + expect(JSON.parse(String(settingsJson))).toEqual(expect.objectContaining({ + showThinkingSummaries: true, + alwaysThinkingEnabled: true, + })); + }); + it("emits completed Claude tool_result rows when tool_use_summary arrives", async () => { const events: AgentChatEventEnvelope[] = []; const setPermissionMode = vi.fn().mockResolvedValue(undefined); @@ -7297,6 +7463,11 @@ describe("createAgentChatService", () => { (event) => event.event.type === "status" && event.event.turnStatus === "started", ), ).toBe(true); + expect( + events.some( + (event) => event.event.type === "activity" && event.event.activity === "thinking", + ), + ).toBe(true); for (let i = 0; i < 20 && !resolveNewSession; i += 1) { await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 378c8998f..6703b1fea 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -938,6 +938,11 @@ const CLAUDE_EFFORT_TO_TOKENS: Record = { high: 16384, }; +const CLAUDE_THINKING_SETTINGS = { + showThinkingSummaries: true, + alwaysThinkingEnabled: true, +}; + const KNOWN_CLAUDE_EFFORTS = new Set(CLAUDE_REASONING_EFFORTS.map((e) => e.effort)); const CODEX_FALLBACK_MODELS: AgentChatModelInfo[] = listModelDescriptorsForProvider("codex").map((descriptor) => ({ @@ -1072,6 +1077,27 @@ function validateReasoningEffort(provider: "codex" | "claude", effort: string | return known.has(aliased) ? aliased : fallback; } +function buildClaudeV2ExecutableArgs(args: { + supportsReasoning: boolean; + effort?: string | null; +}): string[] { + const executableArgs = [ + "--include-partial-messages", + "--settings", + JSON.stringify(CLAUDE_THINKING_SETTINGS), + ]; + + if (args.supportsReasoning) { + executableArgs.push("--thinking", "adaptive", "--thinking-display", "summarized"); + const effort = args.effort; + if (effort === "low" || effort === "medium" || effort === "high" || effort === "max") { + executableArgs.push("--effort", effort); + } + } + + return executableArgs; +} + function describeClaudeModel(value: string): string | null { const lower = value.trim().toLowerCase(); if (lower.includes("opus")) return "Highest capability for complex strategy and review."; @@ -6298,6 +6324,8 @@ export function createAgentChatService(args: { let firstStreamEventLogged = false; const emittedClaudeToolIds = new Set(); const emittedSyntheticItemIds = new Set(); + const streamedClaudeTextContentIndexes = new Set(); + const streamedClaudeThinkingContentIndexes = new Set(); const openClaudeToolUses = new Map(); const toolInputJsonByContentIndex = new Map(); const toolUseMetaByContentIndex = new Map(); @@ -6719,27 +6747,31 @@ export function createAgentChatService(args: { if (betaMessage?.content && Array.isArray(betaMessage.content)) { for (const [blockIndex, block] of betaMessage.content.entries()) { if (block.type === "text") { - assistantText += block.text ?? ""; - emitChatEvent(managed, { - type: "text", - text: block.text ?? "", - turnId, - }); + if (!streamedClaudeTextContentIndexes.has(blockIndex)) { + assistantText += block.text ?? ""; + emitChatEvent(managed, { + type: "text", + text: block.text ?? "", + turnId, + }); + } } else if (block.type === "thinking") { const thinkingText = block.thinking ?? block.text ?? ""; const reasoningItemId = buildClaudeContentItemId("thinking", blockIndex); - emitChatEvent(managed, { - type: "activity", - activity: "thinking", - detail: REASONING_ACTIVITY_DETAIL, - turnId, - }); - emitChatEvent(managed, { - type: "reasoning", - text: thinkingText, - ...(reasoningItemId ? { itemId: reasoningItemId } : {}), - turnId, - }); + if (thinkingText.trim().length > 0 && !streamedClaudeThinkingContentIndexes.has(blockIndex)) { + emitChatEvent(managed, { + type: "activity", + activity: "thinking", + detail: REASONING_ACTIVITY_DETAIL, + turnId, + }); + emitChatEvent(managed, { + type: "reasoning", + text: thinkingText, + ...(reasoningItemId ? { itemId: reasoningItemId } : {}), + turnId, + }); + } } else if (block.type === "tool_use") { const toolName = String(block.name ?? "tool"); const itemId = buildClaudeContentItemId( @@ -6798,12 +6830,18 @@ export function createAgentChatService(args: { if (delta?.type === "text_delta") { const text = delta.text ?? ""; if (text.length) { + if (typeof contentIndex === "number") { + streamedClaudeTextContentIndexes.add(contentIndex); + } assistantText += text; emitChatEvent(managed, { type: "text", text, turnId }); } } else if (delta?.type === "thinking_delta") { const text = delta.thinking ?? delta.text ?? ""; if (text.length) { + if (typeof contentIndex === "number") { + streamedClaudeThinkingContentIndexes.add(contentIndex); + } const reasoningItemId = buildClaudeContentItemId("thinking", contentIndex); emitChatEvent(managed, { type: "activity", @@ -6850,6 +6888,9 @@ export function createAgentChatService(args: { // Some SDK versions include initial thinking text on block start const startText = block.thinking ?? block.text ?? ""; if (startText.length) { + if (typeof contentIndex === "number") { + streamedClaudeThinkingContentIndexes.add(contentIndex); + } emitChatEvent(managed, { type: "reasoning", text: startText, @@ -8740,10 +8781,19 @@ export function createAgentChatService(args: { if (method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") { const delta = String((params.delta as string | undefined) ?? ""); if (!delta.length) return; + const turnId = typeof params.turnId === "string" + ? params.turnId + : turnIdFromParams ?? runtime.activeTurnId ?? undefined; + emitChatEvent(managed, { + type: "activity", + activity: "thinking", + detail: REASONING_ACTIVITY_DETAIL, + turnId, + }); emitChatEvent(managed, { type: "reasoning", text: delta, - turnId: typeof params.turnId === "string" ? params.turnId : undefined, + turnId, itemId: typeof params.itemId === "string" ? params.itemId : undefined, summaryIndex: typeof params.summaryIndex === "number" ? params.summaryIndex : undefined }); @@ -9368,6 +9418,7 @@ export function createAgentChatService(args: { includePartialMessages: true, agentProgressSummaries: true, promptSuggestions: true, + settings: CLAUDE_THINKING_SETTINGS, maxBudgetUsd: chatConfig.sessionBudgetUsd ?? undefined, model: resolveClaudeCliModel(managed.session.model), pathToClaudeCodeExecutable: claudeExecutable.path, @@ -9433,6 +9484,10 @@ export function createAgentChatService(args: { } const claudeDescriptor = resolveSessionModelDescriptor(managed.session); const claudeSupportsReasoning = claudeDescriptor?.capabilities.reasoning ?? true; + opts.executableArgs = buildClaudeV2ExecutableArgs({ + supportsReasoning: claudeSupportsReasoning, + effort: managed.session.reasoningEffort, + }); if (claudeSupportsReasoning) { const effort = managed.session.reasoningEffort; if (effort === "low" || effort === "medium" || effort === "high" || effort === "max") { @@ -9440,13 +9495,13 @@ export function createAgentChatService(args: { } const tokens = effort ? CLAUDE_EFFORT_TO_TOKENS[effort] : undefined; if (tokens) { - opts.thinking = { type: "enabled", budgetTokens: tokens }; + opts.thinking = { type: "enabled", budgetTokens: tokens, display: "summarized" } as any; } else { // Use adaptive thinking when no specific budget applies (e.g. "max", // "xhigh", or no effort set). The SDK defaults to adaptive for models // that support it, but being explicit ensures thinking is always active // for reasoning-capable models. - opts.thinking = { type: "adaptive" }; + opts.thinking = { type: "adaptive", display: "summarized" } as any; } } const model = opts.model ?? resolveClaudeCliModel(managed.session.model) ?? DEFAULT_CLAUDE_MODEL; @@ -11712,6 +11767,11 @@ export function createAgentChatService(args: { }); emitChatEvent(prepared.managed, { type: "status", turnStatus: "started", turnId }); captureTurnBeforeSha(prepared.managed); + emitChatEvent(prepared.managed, { + type: "activity", + ...initialTurnActivity(prepared.managed.session), + turnId, + }); setSessionActive(prepared.managed); persistChatState(prepared.managed); // NOTE: onDispatched is NOT called here. It will be called inside diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index ff402c7e7..162b84d16 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -2285,6 +2285,14 @@ export function registerIpc({ return await ctx.syncService.getStatus(); }); + ipcMain.handle(IPC.syncRefreshDiscovery, async (): Promise => { + const ctx = getCtx(); + if (!ctx.syncService) { + throw new Error("Sync service is not available."); + } + return await ctx.syncService.refreshDiscovery(); + }); + ipcMain.handle(IPC.syncListDevices, async (): Promise => { const ctx = getCtx(); if (!ctx.syncService) { diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index aba140393..335e9dfc5 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -2437,6 +2437,12 @@ export function createLaneService({ remoteName = "origin", force = false }: DeleteLaneArgs): Promise { + const deleteStartedAt = Date.now(); + const logSlowDeleteStep = (step: string, stepStartedAt: number): void => { + const durationMs = Date.now() - stepStartedAt; + if (durationMs < 500) return; + logger.info("lane.delete.step", { laneId, step, durationMs }); + }; const row = getLaneRow(laneId); if (!row) throw new Error(`Lane not found: ${laneId}`); if (row.lane_type === "primary") { @@ -2449,7 +2455,9 @@ export function createLaneService({ } if (row.lane_type === "worktree" && row.worktree_path && fs.existsSync(row.worktree_path)) { + let stepStartedAt = Date.now(); const dirtyRes = await runGit(["status", "--porcelain=v1"], { cwd: row.worktree_path, timeoutMs: 8_000 }); + logSlowDeleteStep("git_status", stepStartedAt); const dirty = dirtyRes.exitCode === 0 && dirtyRes.stdout.trim().length > 0; if (dirty && !force) { throw new Error("Lane has uncommitted changes. Enable force delete after confirming warnings."); @@ -2458,41 +2466,56 @@ export function createLaneService({ const removeArgs = ["worktree", "remove"]; if (force) removeArgs.push("--force"); removeArgs.push(row.worktree_path); + stepStartedAt = Date.now(); await runGitOrThrow(removeArgs, { cwd: projectRoot, timeoutMs: 60_000 }); + logSlowDeleteStep("git_worktree_remove", stepStartedAt); } if (deleteBranch && row.branch_ref) { + let stepStartedAt = Date.now(); const refCheck = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${row.branch_ref}`], { cwd: projectRoot, timeoutMs: 8_000 }); + logSlowDeleteStep("git_branch_ref_check", stepStartedAt); if (refCheck.exitCode === 0) { + stepStartedAt = Date.now(); await runGitOrThrow(["branch", "-D", row.branch_ref], { cwd: projectRoot, timeoutMs: 30_000 }); + logSlowDeleteStep("git_branch_delete", stepStartedAt); } } if (deleteRemoteBranch && row.branch_ref) { const remote = remoteName.trim() || "origin"; + let stepStartedAt = Date.now(); const remoteCheck = await runGit(["remote", "get-url", remote], { cwd: projectRoot, timeoutMs: 8_000 }); + logSlowDeleteStep("git_remote_check", stepStartedAt); if (remoteCheck.exitCode !== 0) { throw new Error(`Remote '${remote}' is not configured for this repository`); } + stepStartedAt = Date.now(); const remoteRefCheck = await runGit(["ls-remote", "--heads", remote, row.branch_ref], { cwd: projectRoot, timeoutMs: 12_000 }); + logSlowDeleteStep("git_remote_ref_check", stepStartedAt); if (remoteRefCheck.exitCode === 0 && remoteRefCheck.stdout.trim().length > 0) { + stepStartedAt = Date.now(); await runGitOrThrow(["push", remote, "--delete", row.branch_ref], { cwd: projectRoot, timeoutMs: 45_000 }); + logSlowDeleteStep("git_remote_branch_delete", stepStartedAt); } } const lanePackDir = path.join(resolveAdeLayout(projectRoot).packsDir, "lanes", laneId); try { + const stepStartedAt = Date.now(); fs.rmSync(lanePackDir, { recursive: true, force: true }); + logSlowDeleteStep("lane_pack_dir_remove", stepStartedAt); } catch { // ignore pack folder cleanup failures } + const dbCleanupStartedAt = Date.now(); db.run("update lanes set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]); db.run("delete from pr_group_members where lane_id = ?", [laneId]); // Explicitly delete child rows that rely on FK cascade — CRR conversion can @@ -2509,7 +2532,19 @@ export function createLaneService({ db.run("delete from process_runs where lane_id = ?", [laneId]); db.run("delete from test_runs where lane_id = ?", [laneId]); db.run("delete from lanes where id = ? and project_id = ?", [laneId, projectId]); + logSlowDeleteStep("database_cleanup", dbCleanupStartedAt); invalidateLaneListCache(); + const durationMs = Date.now() - deleteStartedAt; + if (durationMs >= 1_000) { + logger.info("lane.delete.completed", { + laneId, + laneType: row.lane_type, + deleteBranch, + deleteRemoteBranch, + force, + durationMs + }); + } }, getLaneWorktreePath(laneId: string): string { diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index ddafdb1a9..38d6b6f16 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -1,7 +1,9 @@ import fs from "node:fs"; +import { execFile } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { randomBytes } from "node:crypto"; import { Bonjour, type Service as BonjourService } from "bonjour-service"; import { WebSocketServer, WebSocket, type RawData } from "ws"; @@ -35,6 +37,7 @@ import type { SyncPeerConnectionState, SyncPeerMetadata, SyncRemoteCommandDescriptor, + SyncTailnetDiscoveryStatus, SyncTerminalSnapshotPayload, } from "../../../shared/types"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; @@ -86,12 +89,16 @@ import { DEFAULT_NOTIFICATION_PREFERENCES, normalizeNotificationPreferences } fr import type { SyncPinStore } from "./syncPinStore"; import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, DEFAULT_SYNC_HOST_PORT, encodeSyncEnvelope, mapPlatform, parseSyncEnvelope, wsDataToText } from "./syncProtocol"; import { createSyncRemoteCommandService } from "./syncRemoteCommandService"; +const execFileAsync = promisify(execFile); const DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS = 30_000; const DEFAULT_SYNC_POLL_INTERVAL_MS = 400; const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000; const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000; const LANE_PRESENCE_TTL_MS = 60_000; const SYNC_MDNS_SERVICE_TYPE = "ade-sync"; +export const SYNC_TAILNET_DISCOVERY_SERVICE_NAME = "svc:ade-sync"; +export const SYNC_TAILNET_DISCOVERY_SERVICE_PORT = DEFAULT_SYNC_HOST_PORT; +const TAILSCALE_CLI_MACOS_PATH = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; const MOBILE_MUTATING_FILE_ACTIONS = new Set([ "writeText", "createFile", @@ -318,6 +325,25 @@ function parsePairingRequestPayload(payload: unknown): SyncPairingRequestPayload }; } +function resolveTailscaleCli(): string { + const configured = process.env.ADE_TAILSCALE_CLI?.trim(); + if (configured) return configured; + if (process.platform === "darwin" && fs.existsSync(TAILSCALE_CLI_MACOS_PATH)) { + return TAILSCALE_CLI_MACOS_PATH; + } + return "tailscale"; +} + +function shouldAttemptTailnetServiceAdvertise(): boolean { + if (process.env.ADE_TAILSCALE_SERVE === "0") return false; + if (process.env.NODE_ENV === "test" || process.env.VITEST) return false; + return process.platform === "darwin" || process.platform === "linux" || process.platform === "win32"; +} + +function looksLikePendingTailnetApproval(text: string): boolean { + return /\b(pending|approval|approve|review)\b/i.test(text); +} + export function createSyncHostService(args: SyncHostServiceArgs) { const layout = resolveAdeLayout(args.projectRoot); const bootstrapTokenPath = args.bootstrapTokenPath ?? path.join(layout.secretsDir, "sync-bootstrap-token"); @@ -663,6 +689,18 @@ export function createSyncHostService(args: SyncHostServiceArgs) { let bonjourAnnouncement: BonjourService | null = null; let bonjourPort: number | null = null; let bonjourSignature: string | null = null; + let tailnetServeSignature: string | null = null; + let tailnetDiscoveryStatus: SyncTailnetDiscoveryStatus = { + state: shouldAttemptTailnetServiceAdvertise() ? "disabled" : "unavailable", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: null, + error: shouldAttemptTailnetServiceAdvertise() + ? "Tailnet discovery has not been published yet." + : "Tailscale Serve discovery is not available in this desktop process.", + stderr: null, + }; let lastBroadcastAt: string | null = null; const startedAtMs = Date.now(); @@ -833,6 +871,107 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); }; + const updateTailnetDiscoveryStatus = ( + next: SyncTailnetDiscoveryStatus, + ): void => { + tailnetDiscoveryStatus = next; + setTimeout(() => { + if (!disposed) args.onStateChanged?.(); + }, 0); + }; + + const publishTailnetDiscovery = ( + port: number, + options?: { force?: boolean }, + ): void => { + if (disposed) return; + if (!shouldAttemptTailnetServiceAdvertise()) { + updateTailnetDiscoveryStatus({ + state: "unavailable", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: "Tailscale Serve discovery is not available in this desktop process.", + stderr: null, + }); + return; + } + const cli = resolveTailscaleCli(); + const signature = `${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}:${SYNC_TAILNET_DISCOVERY_SERVICE_PORT}->${port}`; + if (tailnetServeSignature === signature && !options?.force) return; + tailnetServeSignature = signature; + const target = `tcp://127.0.0.1:${port}`; + updateTailnetDiscoveryStatus({ + state: "publishing", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + updatedAt: nowIso(), + error: null, + stderr: null, + }); + const cliArgs = [ + "serve", + "--yes", + `--service=${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}`, + `--tcp=${SYNC_TAILNET_DISCOVERY_SERVICE_PORT}`, + target, + ]; + void execFileAsync(cli, cliArgs, { timeout: 10_000 }) + .then(({ stdout, stderr }) => { + const stdoutText = stdout.trim(); + const stderrText = stderr.trim(); + const outputText = [stdoutText, stderrText].filter(Boolean).join("\n"); + updateTailnetDiscoveryStatus({ + state: looksLikePendingTailnetApproval(outputText) ? "pending_approval" : "published", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + updatedAt: nowIso(), + error: null, + stderr: stderrText || null, + }); + args.logger.info("sync_host.tailnet_discovery_published", { + service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + stdout: stdoutText || null, + stderr: stderrText || null, + }); + }) + .catch((error: unknown) => { + tailnetServeSignature = null; + const errorMessage = error instanceof Error ? error.message : String(error); + const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? null; + const stderr = typeof (error as { stderr?: unknown })?.stderr === "string" + ? String((error as { stderr?: string }).stderr).trim() + : null; + const errorText = [errorMessage, stderr].filter(Boolean).join("\n"); + updateTailnetDiscoveryStatus({ + state: code === "ENOENT" + ? "unavailable" + : looksLikePendingTailnetApproval(errorText) + ? "pending_approval" + : "failed", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + updatedAt: nowIso(), + error: code === "ENOENT" ? "Tailscale CLI was not found." : errorMessage, + stderr, + }); + args.logger.warn("sync_host.tailnet_discovery_failed", { + service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + error: errorMessage, + code, + stderr, + }); + }); + }; + function send(ws: WebSocket, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): void { ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes })); } @@ -1697,6 +1836,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const address = server.address(); const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; publishLanDiscovery(port); + publishTailnetDiscovery(port); return port; } await new Promise((resolve, reject) => { @@ -1729,6 +1869,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const address = server.address(); const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; publishLanDiscovery(port); + publishTailnetDiscovery(port); return port; }, @@ -1745,10 +1886,11 @@ export function createSyncHostService(args: SyncHostServiceArgs) { setLocalActiveLanePresence(laneIds); }, - refreshLanDiscovery(): void { + refreshLanDiscovery(options?: { forceTailnet?: boolean }): void { const address = server.address(); if (typeof address === "object" && address) { publishLanDiscovery(address.port); + publishTailnetDiscovery(address.port, { force: options?.forceTailnet }); } }, @@ -1781,6 +1923,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { .filter((peer): peer is SyncPeerConnectionState => peer != null); }, + getTailnetDiscoveryStatus(): SyncTailnetDiscoveryStatus { + return { ...tailnetDiscoveryStatus }; + }, + getLanePresenceSnapshot(): Array<{ laneId: string; devicesOpen: DeviceMarker[] }> { return getLanePresenceSnapshot(); }, diff --git a/apps/desktop/src/main/services/sync/syncPairingStore.test.ts b/apps/desktop/src/main/services/sync/syncPairingStore.test.ts index cd1ccc55d..f038a38e7 100644 --- a/apps/desktop/src/main/services/sync/syncPairingStore.test.ts +++ b/apps/desktop/src/main/services/sync/syncPairingStore.test.ts @@ -90,6 +90,22 @@ describe("syncPairingStore", () => { const second = store.pairPeer(samplePeer, "424242"); expect(first.secret).not.toBe(second.secret); }); + + it("updates an existing device pairing without resetting createdAt", () => { + const { store, pinStore, pairingFile } = makeHarness("ade-pairing-existing-"); + pinStore.setPin("424242"); + + store.pairPeer(samplePeer, "424242"); + const before = JSON.parse(fs.readFileSync(pairingFile, "utf8")); + const createdAt = before[samplePeer.deviceId].createdAt; + + store.pairPeer({ ...samplePeer, deviceName: "Arul's iPhone" }, "424242"); + + const after = JSON.parse(fs.readFileSync(pairingFile, "utf8")); + expect(Object.keys(after)).toEqual([samplePeer.deviceId]); + expect(after[samplePeer.deviceId].createdAt).toBe(createdAt); + expect(after[samplePeer.deviceId].peerName).toBe("Arul's iPhone"); + }); }); describe("authenticate", () => { diff --git a/apps/desktop/src/main/services/sync/syncPairingStore.ts b/apps/desktop/src/main/services/sync/syncPairingStore.ts index e48ed30e6..f398ee351 100644 --- a/apps/desktop/src/main/services/sync/syncPairingStore.ts +++ b/apps/desktop/src/main/services/sync/syncPairingStore.ts @@ -53,9 +53,10 @@ export function createSyncPairingStore(args: SyncPairingStoreArgs) { } const secret = randomBytes(24).toString("hex"); const records = readRecords(); + const existing = records[peer.deviceId] ?? null; records[peer.deviceId] = { secretHash: hashSecret(secret), - createdAt: nowIso(), + createdAt: existing?.createdAt ?? nowIso(), lastUsedAt: null, peerName: peer.deviceName, peerPlatform: peer.platform, diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index 1103234d8..bdd07cca9 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -21,6 +21,17 @@ const { createSyncHostServiceMock } = vi.hoisted(() => ({ getPeerStates() { return []; }, + getTailnetDiscoveryStatus() { + return { + state: "disabled", + serviceName: "svc:ade-sync", + servicePort: 8787, + target: null, + updatedAt: null, + error: null, + stderr: null, + }; + }, getBrainStatusSnapshot() { return {}; }, @@ -34,6 +45,8 @@ const { createSyncHostServiceMock } = vi.hoisted(() => ({ // Tests only exercise role/transfer/pairing logic, not the sync transport. vi.mock("./syncHostService", () => ({ createSyncHostService: createSyncHostServiceMock, + SYNC_TAILNET_DISCOVERY_SERVICE_NAME: "svc:ade-sync", + SYNC_TAILNET_DISCOVERY_SERVICE_PORT: 8787, })); function createLogger() { @@ -512,6 +525,17 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { getPeerStates() { return []; }, + getTailnetDiscoveryStatus() { + return { + state: "disabled", + serviceName: "svc:ade-sync", + servicePort: 8787, + target: null, + updatedAt: null, + error: null, + stderr: null, + }; + }, getBrainStatusSnapshot() { return {}; }, diff --git a/apps/desktop/src/main/services/sync/syncService.ts b/apps/desktop/src/main/services/sync/syncService.ts index f44edae87..c5692d213 100644 --- a/apps/desktop/src/main/services/sync/syncService.ts +++ b/apps/desktop/src/main/services/sync/syncService.ts @@ -8,6 +8,7 @@ import type { SyncPairingConnectInfo, SyncPairingQrPayload, SyncRoleSnapshot, + SyncTailnetDiscoveryStatus, SyncTransferBlocker, SyncTransferReadiness, } from "../../../shared/types"; @@ -46,7 +47,12 @@ import type { NotificationEventBus } from "../notifications/notificationEventBus import type { AdeDb } from "../state/kvDb"; import { nowIso, safeJsonParse, sleep, writeTextAtomic } from "../shared/utils"; import { createDeviceRegistryService } from "./deviceRegistryService"; -import { createSyncHostService, type SyncHostService } from "./syncHostService"; +import { + createSyncHostService, + SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + type SyncHostService, +} from "./syncHostService"; import { createSyncPeerService } from "./syncPeerService"; import { createSyncPinStore } from "./syncPinStore"; import { DEFAULT_SYNC_HOST_PORT } from "./syncProtocol"; @@ -206,6 +212,20 @@ function isRetryableHostBindError(error: unknown): boolean { return code === "EADDRINUSE" || code === "EACCES"; } +function createInactiveTailnetDiscoveryStatus( + error: string, +): SyncTailnetDiscoveryStatus { + return { + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: null, + error, + stderr: null, + }; +} + function buildHostPortCandidates(preferredPort: number | null | undefined): number[] { const preferred = Number.isFinite(preferredPort) ? Math.max(0, Math.min(65_535, Math.floor(Number(preferredPort)))) @@ -663,6 +683,13 @@ export function createSyncService(args: SyncServiceArgs) { connectedPeers: hostService ? hostService.getPeerStates() : (syncPeerService.getLatestBrainStatus()?.connectedPeers ?? []), + tailnetDiscovery: canHostPhonePairing && hostService + ? hostService.getTailnetDiscoveryStatus() + : createInactiveTailnetDiscoveryStatus( + canHostPhonePairing + ? "Tailnet discovery is waiting for the desktop sync host to start." + : "Tailnet discovery is only published by the host desktop.", + ), client, transferReadiness: await getTransferReadiness(), survivableStateText: @@ -676,6 +703,13 @@ export function createSyncService(args: SyncServiceArgs) { return await listRuntimeDevices(); }, + async refreshDiscovery(): Promise { + hostService?.refreshLanDiscovery?.({ forceTailnet: true }); + const snapshot = await this.getStatus(); + args.onStatusChanged?.(snapshot); + return snapshot; + }, + async updateLocalDevice(argsIn: { name?: string; deviceType?: "desktop" | "phone" | "vps" | "unknown"; diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 2d4b28c3e..27b451170 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -649,6 +649,7 @@ declare global { }; sync: { getStatus: () => Promise; + refreshDiscovery: () => Promise; listDevices: () => Promise; updateLocalDevice: (args: { name?: string; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f9b9d891e..b3cc5ef41 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -704,6 +704,8 @@ contextBridge.exposeInMainWorld("ade", { sync: { getStatus: async (): Promise => ipcRenderer.invoke(IPC.syncGetStatus), + refreshDiscovery: async (): Promise => + ipcRenderer.invoke(IPC.syncRefreshDiscovery), listDevices: async (): Promise => ipcRenderer.invoke(IPC.syncListDevices), updateLocalDevice: async (args: { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index fe16c53d4..2a873e58f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -443,6 +443,45 @@ describe("AgentChatMessageList transcript rendering", () => { expect(transcriptOnly.container.textContent).not.toContain("Running command: npm test"); }); + it("keeps thinking activity visible after a duplicate started status", () => { + const rendered = renderMessageList( + [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "status", + turnStatus: "started", + turnId: "turn-1", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "activity", + activity: "thinking", + detail: "Thinking through the answer", + turnId: "turn-1", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:02.000Z", + event: { + type: "status", + turnStatus: "started", + turnId: "turn-1", + }, + }, + ], + { showStreamingIndicator: true }, + ); + + expect(rendered.container.textContent).toContain("Thinking: Thinking through the answer"); + expect(rendered.container.innerHTML).toContain("bg-violet-400"); + }); + it("keeps the live assistant bubble stable until the turn finishes", () => { const live = renderMessageList( [ @@ -460,7 +499,7 @@ describe("AgentChatMessageList transcript rendering", () => { { showStreamingIndicator: true }, ); - expect(live.container.innerHTML).toContain("min-h-[5.5rem]"); + expect(live.container.innerHTML).toContain("ade-glow-pulse"); cleanup(); @@ -490,7 +529,83 @@ describe("AgentChatMessageList transcript rendering", () => { { showStreamingIndicator: false }, ); - expect(settled.container.innerHTML).not.toContain("min-h-[5.5rem]"); + expect(settled.container.innerHTML).not.toContain("ade-glow-pulse"); + }); + + it("shows streamed live reasoning text instead of only a thinking placeholder", () => { + const rendered = renderMessageList( + [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "status", + turnStatus: "started", + turnId: "turn-live", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "reasoning", + text: "Checking both imports before editing.", + itemId: "reasoning-live", + turnId: "turn-live", + }, + }, + ], + { showStreamingIndicator: true }, + ); + + expect(rendered.container.textContent).toContain("Checking both imports before editing."); + expect(rendered.container.textContent).not.toContain("Thinking..."); + }); + + it("does not show a fake one-second duration for un-timed completed reasoning", () => { + const rendered = renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "reasoning", + text: "Checked the import graph.", + itemId: "reasoning-complete", + turnId: "turn-complete", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "done", + turnId: "turn-complete", + status: "completed", + }, + }, + ]); + + expect(rendered.container.textContent).toContain("Thought"); + expect(rendered.container.textContent).not.toContain("1s"); + }); + + it("keeps work-log cards bounded to content width", () => { + const rendered = renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "tool_call", + tool: "functions.exec_command", + args: { cmd: "pwd" }, + itemId: "tool-1", + turnId: "turn-1", + }, + }, + ]); + + expect(rendered.container.textContent).toContain("Run pwd"); + expect(rendered.container.innerHTML).toContain("max-w-[min(100%,70ch)]"); }); it("renders a bottom turn summary card with task, file, and background-agent totals", () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 78bff78a3..af555b7cf 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -779,15 +779,38 @@ function ThinkingDots({ toneClass = "bg-emerald-300/70" }: { toneClass?: string } -function ActivityIndicator({ activity, detail }: { activity: string; detail?: string; animate?: boolean }) { +function ActivityIndicator({ activity, detail, animate = true }: { activity: string; detail?: string; animate?: boolean }) { const label = ACTIVITY_LABELS[activity] ?? activity; const displayText = detail ? `${label}: ${replaceInternalToolNames(detail)}` : `${label}...`; + const thinking = activity === "thinking"; + const activeTextClass = thinking ? "text-violet-200/78" : "text-emerald-200/80"; + const activeDotClass = thinking ? "bg-violet-400/70" : "bg-emerald-400/80"; + const activePingClass = thinking ? "bg-violet-400/12" : "bg-emerald-400/10"; return ( -
+
- - + {animate ? ( + thinking ? ( + + + + + + + + ) : ( + <> + + + + ) + ) : ( + + )} {displayText}
@@ -992,49 +1015,6 @@ function ModelGlyph({ return ; } -type AssistantPresentation = { - label: string; - glyph: React.ReactNode; -}; - -const KNOWN_PROVIDER_LABELS = new Set(["Claude", "Codex", "Cursor"]); -const GENERIC_ASSISTANT_LABELS = new Set(["Agent", "Assistant", ...KNOWN_PROVIDER_LABELS]); - -function inferProviderLabel(meta: { family: string | null; cliCommand: string | null }): string | null { - if (meta.family === "anthropic" || meta.cliCommand === "claude") return "Claude"; - if (meta.cliCommand === "codex") return "Codex"; - if (meta.family === "cursor" || meta.cliCommand === "cursor") return "Cursor"; - return null; -} - -function providerGlyph(provider: string | null): React.ReactNode { - switch (provider) { - case "Claude": return ; - case "Codex": return ; - case "Cursor": return ; - default: return ; - } -} - -function resolveAssistantPresentation({ - assistantLabel, - turnModel, -}: { - assistantLabel?: string; - turnModel?: { label: string; modelId?: string; model?: string } | null; -}): AssistantPresentation { - const customLabel = assistantLabel?.trim() ?? ""; - const modelMeta = turnModel ? resolveModelMeta(turnModel.modelId, turnModel.model) : { family: null, cliCommand: null }; - const resolvedProviderLabel = inferProviderLabel(modelMeta) - ?? (KNOWN_PROVIDER_LABELS.has(customLabel) ? customLabel : null); - const hardOverrideLabel = - customLabel.length > 0 && !GENERIC_ASSISTANT_LABELS.has(customLabel) - ? customLabel - : null; - const label = hardOverrideLabel ?? resolvedProviderLabel ?? "Assistant"; - return { label, glyph: providerGlyph(resolvedProviderLabel) }; -} - function commandTimelineVerb(status: Extract["status"]): string { if (status === "failed") return "Command failed"; if (status === "running") return "Running"; @@ -1397,6 +1377,7 @@ function renderEvent( surfaceProfile?: ChatSurfaceProfile; assistantLabel?: string; turnActive?: boolean; + sessionEnded?: boolean; onOpenWorkspacePath?: (path: string) => void; respondingApprovalIds?: Set; pendingApprovalIds?: Set; @@ -1461,10 +1442,6 @@ function renderEvent( /* ── Agent text ── */ if (event.type === "text") { - const assistant = resolveAssistantPresentation({ - assistantLabel: options?.assistantLabel, - turnModel: options?.turnModel, - }); return ( @@ -1486,18 +1463,8 @@ function renderEvent(
)} -
- - {assistant.glyph} - - {options?.turnModel?.label ? ( - - {options.turnModel.label} - - ) : null} -
- -
+
+
@@ -1963,11 +1930,13 @@ function renderEvent( const isLive = Boolean(options?.turnActive); const reasoningPreview = summarizeInlineText(reasoningText, 108); - // Compute duration if we have timestamps - const startTs = (event as any).startTimestamp ?? envelope.timestamp; - const endTs = envelope.timestamp; - const durationSec = Math.max(1, Math.round((new Date(endTs).getTime() - new Date(startTs).getTime()) / 1000)); - const durationLabel = isLive ? null : `${durationSec}s`; + const startTimestamp = typeof (event as any).startTimestamp === "string" ? (event as any).startTimestamp : null; + const durationMs = startTimestamp + ? new Date(envelope.timestamp).getTime() - new Date(startTimestamp).getTime() + : null; + const durationLabel = !isLive && durationMs != null && Number.isFinite(durationMs) && durationMs > 0 + ? `${Math.max(1, Math.round(durationMs / 1000))}s` + : null; return ( {isLive ? ( - + - Thinking... + + {reasoningPreview.length ? reasoningPreview : "Thinking..."} + ) : ( <> @@ -2284,7 +2255,7 @@ function renderEvent( /* ── Activity ── */ if (event.type === "activity") { - return ; + return ; } /* ── Status ── */ @@ -2743,7 +2714,8 @@ function deriveLatestActivity(events: AgentChatEventEnvelope[]): { activity: str if (evt.type === "activity") { return { activity: evt.activity, detail: evt.detail }; } - if (evt.type === "done" || evt.type === "status") return null; + if (evt.type === "done") return null; + if (evt.type === "status" && evt.turnStatus !== "started") return null; } return null; } @@ -2783,6 +2755,8 @@ type EventRowProps = { surfaceProfile?: ChatSurfaceProfile; assistantLabel?: string; turnActive?: boolean; + sessionEnded?: boolean; + isLatestWorkLog?: boolean; onOpenWorkspacePath?: (path: string) => void; onNavigateSuggestion?: (suggestion: OperatorNavigationSuggestion) => void; respondingApprovalIds?: Set; @@ -2801,6 +2775,8 @@ const EventRow = React.memo(function EventRow({ surfaceProfile = "standard", assistantLabel, turnActive, + sessionEnded, + isLatestWorkLog, onOpenWorkspacePath, onNavigateSuggestion, respondingApprovalIds, @@ -2808,6 +2784,7 @@ const EventRow = React.memo(function EventRow({ resolvedInputStates, sessionId, }: EventRowProps) { + const workLogAnimate = Boolean(turnActive) && !sessionEnded && Boolean(isLatestWorkLog); return (
{showTurnDivider ? ( @@ -2827,11 +2804,14 @@ const EventRow = React.memo(function EventRow({ ) : null} {envelope.event.type === "work_log_group" ? ( - +
+ +
) : renderEvent(envelope as RenderEnvelope, { onApproval, @@ -2840,6 +2820,7 @@ const EventRow = React.memo(function EventRow({ surfaceProfile, assistantLabel, turnActive, + sessionEnded, onOpenWorkspacePath, respondingApprovalIds, pendingApprovalIds, @@ -2997,6 +2978,7 @@ export function AgentChatMessageList({ respondingApprovalIds, pendingApprovalIds, sessionId, + sessionEnded = false, }: { events: AgentChatEventEnvelope[]; showStreamingIndicator?: boolean; @@ -3009,6 +2991,7 @@ export function AgentChatMessageList({ respondingApprovalIds?: Set; pendingApprovalIds?: Set; sessionId?: string | null; + sessionEnded?: boolean; }) { const scrollRef = useRef(null); const location = useLocation(); @@ -3265,6 +3248,13 @@ export function AgentChatMessageList({ } }, [shouldVirtualize]); + const latestWorkLogIndex = useMemo(() => { + for (let i = groupedRows.length - 1; i >= 0; i -= 1) { + if (groupedRows[i]?.event.type === "work_log_group") return i; + } + return -1; + }, [groupedRows]); + /** Renders a single row with turn-divider logic. Used by both paths. */ const renderRow = useCallback((envelope: TranscriptGroupedEnvelope, index: number, virtualized: boolean) => { const currentTurn = getGroupedTurnId(envelope); @@ -3276,6 +3266,7 @@ export function AgentChatMessageList({ const turnModel = currentTurn ? (turnModelState.map.get(currentTurn) ?? null) : turnModelState.lastModel; + const isLatestWorkLog = index === latestWorkLogIndex; if (virtualized) { return ( @@ -3292,6 +3283,8 @@ export function AgentChatMessageList({ surfaceProfile={surfaceProfile} assistantLabel={assistantLabel} turnActive={Boolean(currentTurn && activeTurnId && currentTurn === activeTurnId)} + sessionEnded={sessionEnded} + isLatestWorkLog={isLatestWorkLog} onOpenWorkspacePath={openWorkspacePath} onNavigateSuggestion={handleNavigateSuggestion} respondingApprovalIds={respondingApprovalIds} @@ -3314,6 +3307,8 @@ export function AgentChatMessageList({ surfaceProfile={surfaceProfile} assistantLabel={assistantLabel} turnActive={Boolean(currentTurn && activeTurnId && currentTurn === activeTurnId)} + sessionEnded={sessionEnded} + isLatestWorkLog={isLatestWorkLog} onOpenWorkspacePath={openWorkspacePath} onNavigateSuggestion={handleNavigateSuggestion} respondingApprovalIds={respondingApprovalIds} @@ -3322,7 +3317,7 @@ export function AgentChatMessageList({ sessionId={sessionId} /> ); - }, [activeTurnId, assistantLabel, surfaceMode, surfaceProfile, groupedRows, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, sessionId]); + }, [activeTurnId, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, sessionId, sessionEnded]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { @@ -3340,7 +3335,7 @@ export function AgentChatMessageList({ const streamingIndicator = showStreamingIndicator ? ( void; + animate?: boolean; }) { const hasNavigationSuggestions = useMemo( () => entries.some((e) => readNavigationSuggestions(e.result).length > 0), @@ -475,10 +478,11 @@ export function ChatWorkLogBlock({ const groupStatus: ChatStatusVisualState = useMemo(() => { if (entries.some((e) => e.status === "failed")) return "failed"; + if (!animate) return "completed"; if (entries.some((e) => e.status === "running")) return "working"; if (entries.some((e) => e.status === "interrupted")) return "waiting"; return "completed"; - }, [entries]); + }, [entries, animate]); const groupStatusLabel = useMemo(() => { if (groupStatus === "failed") return "failed"; @@ -506,7 +510,7 @@ export function ChatWorkLogBlock({ return (
@@ -561,7 +565,8 @@ export function ChatWorkLogBlock({ const isEntryExpanded = expandedEntries[entry.id] ?? (hasSuggestions || entry.status === "failed"); const heading = replaceInternalToolNames(workEntryHeading(entry)); const preview = workEntryPreview(entry); - const statusLabel = workStatusLabel(entry.status); + const statusLabel = workStatusLabel(entry.status, animate); + const entryStatusState = workStatusState(entry.status, animate); return (
@@ -576,7 +581,7 @@ export function ChatWorkLogBlock({ )} - + {entryIsMemory ? ( @@ -595,7 +600,7 @@ export function ChatWorkLogBlock({ ) : null} {statusLabel} diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 61fac4931..f534b09b9 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -220,6 +220,7 @@ export function LanesPage() { const [deleteForce, setDeleteForce] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(""); const [laneActionBusy, setLaneActionBusy] = useState(false); + const [laneActionStatus, setLaneActionStatus] = useState(null); const [laneActionError, setLaneActionError] = useState(null); const [managedLaneIds, setManagedLaneIds] = useState([]); const [conflictChipsByLane, setConflictChipsByLane] = useState>({}); @@ -780,8 +781,9 @@ export function LanesPage() { return primaryBranches.filter((branch) => branch.isRemote && (!q || branch.name.toLowerCase().includes(q))); }, [primaryBranches, branchSearchQuery]); - const runLaneAction = async (fn: () => Promise) => { + const runLaneAction = async (fn: () => Promise, status: string) => { setLaneActionBusy(true); + setLaneActionStatus(status); setLaneActionError(null); try { await fn(); @@ -791,6 +793,7 @@ export function LanesPage() { setLaneActionError(err instanceof Error ? err.message : String(err)); } finally { setLaneActionBusy(false); + setLaneActionStatus(null); } }; @@ -868,7 +871,7 @@ export function LanesPage() { for (const lane of actionable) { await window.ade.lanes.archive({ laneId: lane.id }); } - }); + }, actionable.length > 1 ? `Archiving ${actionable.length} lanes...` : "Archiving lane..."); }; const deleteManagedLanes = async () => { @@ -876,6 +879,18 @@ export function LanesPage() { const actionable = targets.filter((l) => l.laneType !== "primary"); if (actionable.length === 0) return; if (deleteConfirmText.trim().toLowerCase() !== deletePhrase.toLowerCase()) return; + const deleteStatus = + deleteMode === "remote_branch" + ? actionable.length > 1 + ? `Deleting ${actionable.length} lane worktrees, local branches, and remote branches...` + : "Deleting lane worktree, local branch, and remote branch..." + : deleteMode === "local_branch" + ? actionable.length > 1 + ? `Deleting ${actionable.length} lane worktrees and local branches...` + : "Deleting lane worktree and local branch..." + : actionable.length > 1 + ? `Deleting ${actionable.length} lane worktrees...` + : "Deleting lane worktree..."; await runLaneAction(async () => { const errors: string[] = []; for (const lane of actionable) { @@ -905,7 +920,7 @@ export function LanesPage() { for (const id of deletedIds) { clearLaneInspectorTab(id); } - }); + }, deleteStatus); }; const openBatchManage = useCallback((laneIds: string[]) => { @@ -2225,6 +2240,7 @@ export function LanesPage() { setDeleteConfirmText={setDeleteConfirmText} deletePhrase={deletePhrase} laneActionBusy={laneActionBusy} + laneActionStatus={laneActionStatus} laneActionError={laneActionError} onAdoptAttached={() => { if (!managedLane || managedLane.laneType !== "attached") return; diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx new file mode 100644 index 000000000..49eb05b5a --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx @@ -0,0 +1,96 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ManageLaneDialog } from "./ManageLaneDialog"; +import type { LaneSummary } from "../../../shared/types"; + +vi.mock("border-beam", () => ({ + BorderBeam: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +function makeLane(overrides: Partial = {}): LaneSummary { + return { + id: "lane-1", + name: "feature lane", + laneType: "worktree", + baseRef: "main", + branchRef: "ade/feature-lane", + worktreePath: "/repo/.ade/worktrees/feature-lane", + attachedRootPath: null, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { + dirty: false, + ahead: 0, + behind: 0, + remoteBehind: -1, + rebaseInProgress: false, + }, + color: null, + icon: null, + tags: [], + createdAt: "2026-04-21T00:00:00.000Z", + ...overrides, + }; +} + +const baseProps = { + open: true, + onOpenChange: vi.fn(), + managedLane: makeLane(), + managedLanes: [], + deleteMode: "local_branch" as const, + setDeleteMode: vi.fn(), + deleteRemoteName: "origin", + setDeleteRemoteName: vi.fn(), + deleteForce: false, + setDeleteForce: vi.fn(), + deleteConfirmText: "delete feature lane", + setDeleteConfirmText: vi.fn(), + deletePhrase: "delete feature lane", + laneActionError: null, + onAdoptAttached: vi.fn(), + onArchive: vi.fn(), + onDelete: vi.fn(), +}; + +describe("ManageLaneDialog", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("shows a concrete deleting status and disables delete controls while busy", () => { + render( + , + ); + + expect(screen.getByRole("status").textContent).toContain("Deleting lane worktree and local branch..."); + expect((screen.getByRole("button", { name: /Deleting/i }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole("button", { name: /\+ local branch/i }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByDisplayValue("delete feature lane") as HTMLInputElement).disabled).toBe(true); + expect((screen.getByRole("checkbox", { name: /Force delete/i }) as HTMLInputElement).disabled).toBe(true); + }); + + it("keeps the delete button actionable after confirmation when idle", () => { + render( + , + ); + + expect(screen.queryByRole("status")).toBeNull(); + expect((screen.getByRole("button", { name: /Delete lane/i }) as HTMLButtonElement).disabled).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx index 8b2a5896b..de2d2a03f 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx @@ -1,4 +1,4 @@ -import { ArrowSquareOut, GitBranch, WarningCircle, Archive, Trash } from "@phosphor-icons/react"; +import { ArrowSquareOut, GitBranch, WarningCircle, Archive, Trash, CircleNotch } from "@phosphor-icons/react"; import { Button } from "../ui/Button"; import type { LaneSummary } from "../../../shared/types"; import { LaneDialogShell } from "./LaneDialogShell"; @@ -19,6 +19,7 @@ export function ManageLaneDialog({ setDeleteConfirmText, deletePhrase, laneActionBusy, + laneActionStatus, laneActionError, onAdoptAttached, onArchive, @@ -38,6 +39,7 @@ export function ManageLaneDialog({ setDeleteConfirmText: (v: string) => void; deletePhrase: string; laneActionBusy: boolean; + laneActionStatus: string | null; laneActionError: string | null; onAdoptAttached: () => void; onArchive: () => void; @@ -179,6 +181,7 @@ export function ManageLaneDialog({
+ {laneActionBusy && ( +
+ + {laneActionStatus ?? "Working..."} +
+ )} + {/* Error */} {laneActionError && (
@@ -237,9 +249,9 @@ export function ManageLaneDialog({ disabled={laneActionBusy || !confirmMatch} onClick={onDelete} > - + {laneActionBusy ? : } {laneActionBusy - ? "Working..." + ? "Deleting..." : isBatch ? `Delete ${lanes.length} lanes` : "Delete lane"} diff --git a/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.test.ts b/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.test.ts index 26dfac87e..a22e584c6 100644 --- a/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.test.ts +++ b/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.test.ts @@ -87,6 +87,38 @@ describe("MissionThreadMessageList transcript merge", () => { expect(merged[0]?.timestamp).toBe("2026-03-10T12:00:02.000Z"); }); + it("keeps distinct reasoning blocks separated by item id", () => { + const merged = mergeMissionThreadEvents([], [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { + type: "reasoning", + turnId: "turn-1", + itemId: "reasoning-1", + text: "First thought.", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:01.000Z", + event: { + type: "reasoning", + turnId: "turn-1", + itemId: "reasoning-2", + text: "Second thought.", + }, + }, + ]); + + const reasoning = merged.filter((envelope) => envelope.event.type === "reasoning"); + expect(reasoning).toHaveLength(2); + expect(reasoning.map((envelope) => envelope.event.type === "reasoning" ? envelope.event.text : "")).toEqual([ + "First thought.", + "Second thought.", + ]); + }); + it("dedupes identical tool events even when transcript timestamps differ", () => { const fallbackEvent: AgentChatEventEnvelope = { sessionId: "session-1", diff --git a/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.tsx b/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.tsx index 98759b695..bb7174f42 100644 --- a/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.tsx +++ b/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.tsx @@ -188,8 +188,12 @@ export function buildMissionThreadEventMergeKey(envelope: AgentChatEventEnvelope : null; switch (event.type) { - case "text": case "reasoning": + if (itemId) return [...baseParts, "item", itemId].join("::"); + if (turnId) return [...baseParts, "turn", turnId].join("::"); + if (messageId) return [...baseParts, "message", messageId].join("::"); + return [...baseParts, normalizeInlineText(event.text)].join("::"); + case "text": if (turnId) return [...baseParts, "turn", turnId].join("::"); if (itemId) return [...baseParts, "item", itemId].join("::"); if (messageId) return [...baseParts, "message", messageId].join("::"); diff --git a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx index 0d56de999..b86ab30e5 100644 --- a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx @@ -5,6 +5,7 @@ import type { SyncDeviceRecord, SyncDeviceRuntimeState, SyncRoleSnapshot, + SyncTailnetDiscoveryStatus, } from "../../../shared/types"; import { COLORS, @@ -208,6 +209,12 @@ export function SyncDevicesSection() { setNotice(device.connectionState === "connected" ? "Device revoked." : "Device removed."); }), [runAction]); + const handleRetryDiscovery = useCallback(() => runAction(async () => { + const nextStatus = await window.ade.sync.refreshDiscovery(); + setStatus(nextStatus); + setNotice("Tailnet discovery retry started."); + }), [runAction]); + if (loading) { return
Loading sync status...
; } @@ -241,6 +248,13 @@ export function SyncDevicesSection() { )} + + {notice ?
{notice}
: null} {error ?
{error}
: null} @@ -322,6 +336,118 @@ function StatusBar({ connected, peerCount }: { connected: boolean; peerCount: nu ); } +function displayTailnetHost(status: SyncTailnetDiscoveryStatus): string { + return `${status.serviceName.replace(/^svc:/, "")}:${status.servicePort}`; +} + +function tailnetStatusCopy(status: SyncTailnetDiscoveryStatus, isLocalHost: boolean): { + label: string; + color: string; + title: string; + detail: string; + canRetry: boolean; +} { + const host = displayTailnetHost(status); + switch (status.state) { + case "published": + return { + label: "Published", + color: COLORS.success, + title: `Published as ${host}`, + detail: "Phones on this tailnet can find this host automatically.", + canRetry: true, + }; + case "pending_approval": + return { + label: "Pending approval", + color: COLORS.warning, + title: `Waiting on ${host}`, + detail: status.stderr || status.error || "Tailscale accepted the service, but tailnet policy may need admin approval.", + canRetry: true, + }; + case "publishing": + return { + label: "Publishing", + color: COLORS.accent, + title: `Publishing ${host}`, + detail: status.target ? `Forwarding to ${status.target}.` : "Publishing tailnet discovery.", + canRetry: false, + }; + case "unavailable": + return { + label: "Tailscale not available", + color: COLORS.warning, + title: `Cannot publish ${host}`, + detail: status.stderr || status.error || "Install or open Tailscale on this desktop, then retry.", + canRetry: true, + }; + case "failed": + return { + label: "Failed", + color: COLORS.danger, + title: `Could not publish ${host}`, + detail: status.stderr || status.error || "Tailscale Serve returned an error.", + canRetry: true, + }; + default: + return { + label: "Not active", + color: COLORS.textMuted, + title: isLocalHost ? `Not published as ${host}` : "Only the host desktop publishes tailnet discovery", + detail: status.error || "Start phone sync hosting to publish tailnet discovery.", + canRetry: isLocalHost, + }; + } +} + +function TailnetDiscoveryPanel({ + status, + busy, + isLocalHost, + onRetry, +}: { + status: SyncTailnetDiscoveryStatus; + busy: boolean; + isLocalHost: boolean; + onRetry: () => void; +}) { + const copy = tailnetStatusCopy(status, isLocalHost); + const disabled = busy || !copy.canRetry; + return ( +
+
+
+
+ + Tailnet discovery + + {copy.label} +
+
{copy.title}
+
+ +
+
+ {copy.detail} +
+ {status.updatedAt ? ( +
Updated {formatTimestamp(status.updatedAt)}
+ ) : null} +
+ ); +} + function PairPhoneCard({ qrPayloadText, pin, diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index d37cb1f05..2dd0de682 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -293,6 +293,7 @@ export const IPC = { aiVerifyApiKey: "ade.ai.verifyApiKey", aiUpdateConfig: "ade.ai.updateConfig", syncGetStatus: "ade.sync.getStatus", + syncRefreshDiscovery: "ade.sync.refreshDiscovery", syncListDevices: "ade.sync.listDevices", syncUpdateLocalDevice: "ade.sync.updateLocalDevice", syncConnectToBrain: "ade.sync.connectToBrain", diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 122e376dd..7371e34ef 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -149,6 +149,24 @@ export type SyncDeviceRuntimeState = SyncDeviceRecord & { syncLag: number | null; }; +export type SyncTailnetDiscoveryState = + | "disabled" + | "publishing" + | "published" + | "pending_approval" + | "unavailable" + | "failed"; + +export type SyncTailnetDiscoveryStatus = { + state: SyncTailnetDiscoveryState; + serviceName: string; + servicePort: number; + target: string | null; + updatedAt: string | null; + error: string | null; + stderr: string | null; +}; + export type SyncRoleSnapshot = { mode: SyncMode; role: SyncRole; @@ -161,6 +179,7 @@ export type SyncRoleSnapshot = { pairingPinConfigured: boolean; pairingConnectInfo: SyncPairingConnectInfo | null; connectedPeers: SyncPeerConnectionState[]; + tailnetDiscovery: SyncTailnetDiscoveryStatus; client: SyncClientStatus; transferReadiness: SyncTransferReadiness; survivableStateText: string; diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 867996f58..3a405fe57 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -86,7 +86,6 @@ H10000000000000000000001 /* CtoRootScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000010 /* CtoRootScreen.swift */; }; H10000000000000000000002 /* CtoSessionDestinationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000011 /* CtoSessionDestinationView.swift */; }; H10000000000000000000003 /* CtoBriefEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000012 /* CtoBriefEditor.swift */; }; - H10000000000000000000004 /* CtoChatScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000013 /* CtoChatScreen.swift */; }; H10000000000000000000005 /* CtoIdentityEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000014 /* CtoIdentityEditor.swift */; }; H10000000000000000000006 /* CtoSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000015 /* CtoSettingsScreen.swift */; }; H10000000000000000000007 /* CtoSharedHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000016 /* CtoSharedHelpers.swift */; }; @@ -285,7 +284,6 @@ H10000000000000000000010 /* CtoRootScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoRootScreen.swift; path = ADE/Views/Cto/CtoRootScreen.swift; sourceTree = ""; }; H10000000000000000000011 /* CtoSessionDestinationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoSessionDestinationView.swift; path = ADE/Views/Cto/CtoSessionDestinationView.swift; sourceTree = ""; }; H10000000000000000000012 /* CtoBriefEditor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoBriefEditor.swift; path = ADE/Views/Cto/CtoBriefEditor.swift; sourceTree = ""; }; - H10000000000000000000013 /* CtoChatScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoChatScreen.swift; path = ADE/Views/Cto/CtoChatScreen.swift; sourceTree = ""; }; H10000000000000000000014 /* CtoIdentityEditor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoIdentityEditor.swift; path = ADE/Views/Cto/CtoIdentityEditor.swift; sourceTree = ""; }; H10000000000000000000015 /* CtoSettingsScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoSettingsScreen.swift; path = ADE/Views/Cto/CtoSettingsScreen.swift; sourceTree = ""; }; H10000000000000000000016 /* CtoSharedHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoSharedHelpers.swift; path = ADE/Views/Cto/CtoSharedHelpers.swift; sourceTree = ""; }; @@ -548,7 +546,6 @@ isa = PBXGroup; children = ( H10000000000000000000012 /* CtoBriefEditor.swift */, - H10000000000000000000013 /* CtoChatScreen.swift */, H10000000000000000000014 /* CtoIdentityEditor.swift */, H10000000000000000000010 /* CtoRootScreen.swift */, H10000000000000000000011 /* CtoSessionDestinationView.swift */, @@ -1095,7 +1092,6 @@ H10000000000000000000001 /* CtoRootScreen.swift in Sources */, H10000000000000000000002 /* CtoSessionDestinationView.swift in Sources */, H10000000000000000000003 /* CtoBriefEditor.swift in Sources */, - H10000000000000000000004 /* CtoChatScreen.swift in Sources */, H10000000000000000000005 /* CtoIdentityEditor.swift in Sources */, H10000000000000000000006 /* CtoSettingsScreen.swift in Sources */, H10000000000000000000007 /* CtoSharedHelpers.swift in Sources */, diff --git a/apps/ios/ADE/Services/KeychainService.swift b/apps/ios/ADE/Services/KeychainService.swift index e59375981..cd04ebe09 100644 --- a/apps/ios/ADE/Services/KeychainService.swift +++ b/apps/ios/ADE/Services/KeychainService.swift @@ -3,10 +3,11 @@ import Security final class KeychainService { private let service = "com.ade.ios.sync" - private let account = "connection-token" + private let tokenAccount = "connection-token" + private let deviceIdAccount = "device-id" - func saveToken(_ token: String) { - let data = Data(token.utf8) + private func saveString(_ value: String, account: String) { + let data = Data(value.utf8) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -23,7 +24,7 @@ final class KeychainService { SecItemAdd(addQuery as CFDictionary, nil) } - func loadToken() -> String? { + private func loadString(account: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -37,7 +38,7 @@ final class KeychainService { return String(data: data, encoding: .utf8) } - func clearToken() { + private func clearString(account: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -45,4 +46,24 @@ final class KeychainService { ] SecItemDelete(query as CFDictionary) } + + func saveToken(_ token: String) { + saveString(token, account: tokenAccount) + } + + func loadToken() -> String? { + loadString(account: tokenAccount) + } + + func clearToken() { + clearString(account: tokenAccount) + } + + func saveDeviceId(_ deviceId: String) { + saveString(deviceId, account: deviceIdAccount) + } + + func loadDeviceId() -> String? { + loadString(account: deviceIdAccount) + } } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index edb53d490..3eebab979 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import Network import SwiftUI import UIKit import WidgetKit @@ -24,6 +25,15 @@ enum RemoteConnectionState: String { case connected case syncing case error + + /// True when the host is not reachable — either we never connected + /// (or gave up) or the last socket turned over into an error state. + /// UI uses this to suppress per-screen "failed to load" banners whose + /// underlying cause is simply "not connected"; the top-right gear dot + /// (ADEConnectionDot) is the single source of truth for this state. + var isHostUnreachable: Bool { + self == .disconnected || self == .error + } } func unwrapSyncCommandResponse(_ raw: Any) throws -> Any { @@ -170,6 +180,22 @@ enum SyncSocketTiming { static let lanePresenceHeartbeatNanoseconds: UInt64 = 30_000_000_000 } +enum SyncTailnetDiscoveryTiming { + static let probeIntervalNanoseconds: UInt64 = 45_000_000_000 + static let probeTimeoutNanoseconds: UInt64 = 2_000_000_000 +} + +enum SyncTailnetDiscovery { + static let hostCandidates = [ + "ade-sync", + "ade-desktop", + ] + static let portCandidates = [ + 8787, + 8788, + ] +} + func syncIsTailscaleIPv4Address(_ host: String) -> Bool { let normalized = host .trimmingCharacters(in: .whitespacesAndNewlines) @@ -183,10 +209,6 @@ func syncIsTailscaleIPv4Address(_ host: String) -> Bool { return first == 100 && (64...127).contains(second) } -func syncCanAttemptInsecureWebSocket(to host: String) -> Bool { - !syncIsTailscaleIPv4Address(host) -} - struct SyncReconnectState { private(set) var attempts = 0 @@ -212,6 +234,14 @@ struct SyncReconnectState { } } +private struct SyncNetworkPathSnapshot: Equatable, Sendable { + let isSatisfied: Bool + let usesWiFi: Bool + let usesCellular: Bool + let usesWiredEthernet: Bool + let isExpensive: Bool +} + enum SyncUserFacingError { static func message(for error: Error) -> String { let nsError = error as NSError @@ -404,6 +434,7 @@ final class SyncService: ObservableObject { private let legacyDraftKey = "ade.sync.connectionDraft" private let profileKey = "ade.sync.hostProfile" + private let legacyDeviceIdKey = "ade.sync.deviceId" private let autoReconnectPausedKey = "ade.sync.autoReconnectPausedByUser" private let pendingOperationsKey = "ade.sync.pendingOperations" private let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" @@ -411,6 +442,9 @@ final class SyncService: ObservableObject { private let database: DatabaseService private let socketSessionDelegate: SyncSocketSessionDelegate private let socketSession: URLSession + private let pathMonitor = NWPathMonitor() + private let pathMonitorQueue = DispatchQueue(label: "com.ade.sync.network-path") + private let tailnetDiscovery = SyncTailnetProbe() private var socket: URLSessionWebSocketTask? private struct PendingRequest { let completion: (Result) -> Void @@ -426,6 +460,7 @@ final class SyncService: ObservableObject { private var relayTask: Task? private var hydrationTask: Task? private var reconnectTask: Task? + private var networkPathReconnectTask: Task? private var lanePresenceHeartbeatTask: Task? private var openLaneReferenceCounts: [String: Int] = [:] private var terminalBufferRevisionTask: Task? @@ -448,6 +483,10 @@ final class SyncService: ObservableObject { private var autoReconnectAwaitingLiveDiscovery = false /// Prevents overlapping `reconnectIfPossible` runs from stacking TCP/WebSocket attempts. private var reconnectConnectInFlight = false + private var bonjourDiscoveredHosts: [DiscoveredSyncHost] = [] + private var tailnetDiscoveredHosts: [DiscoveredSyncHost] = [] + private var lastNetworkPathSnapshot: SyncNetworkPathSnapshot? + private var preferTailnetReconnectUntil: Date? private(set) var deviceId: String private var remoteCommandDescriptors: [SyncRemoteCommandDescriptor] = [] private var supportsChatStreaming = false @@ -525,11 +564,15 @@ final class SyncService: ObservableObject { self.socketSession = socketSession self.database = database self.autoReconnectPausedByUser = UserDefaults.standard.bool(forKey: autoReconnectPausedKey) - if let existing = UserDefaults.standard.string(forKey: "ade.sync.deviceId") { + if let existing = keychain.loadDeviceId() { + deviceId = existing + } else if let existing = UserDefaults.standard.string(forKey: legacyDeviceIdKey) { deviceId = existing + keychain.saveDeviceId(existing) } else { let fresh = UUID().uuidString.lowercased() - UserDefaults.standard.set(fresh, forKey: "ade.sync.deviceId") + UserDefaults.standard.set(fresh, forKey: legacyDeviceIdKey) + keychain.saveDeviceId(fresh) deviceId = fresh } pendingOperationCount = loadPendingOperations().count @@ -545,10 +588,31 @@ final class SyncService: ObservableObject { discoveryBrowser.onHostsChanged = { [weak self] hosts in Task { @MainActor in - self?.applyDiscoveredHosts(hosts) + self?.bonjourDiscoveredHosts = hosts + self?.publishMergedDiscoveredHosts() } } discoveryBrowser.start() + tailnetDiscovery.onHostsChanged = { [weak self] hosts in + Task { @MainActor in + self?.tailnetDiscoveredHosts = hosts + self?.publishMergedDiscoveredHosts() + } + } + tailnetDiscovery.start() + pathMonitor.pathUpdateHandler = { [weak self] path in + let snapshot = SyncNetworkPathSnapshot( + isSatisfied: path.status == .satisfied, + usesWiFi: path.usesInterfaceType(.wifi), + usesCellular: path.usesInterfaceType(.cellular), + usesWiredEthernet: path.usesInterfaceType(.wiredEthernet), + isExpensive: path.isExpensive + ) + Task { @MainActor in + self?.handleNetworkPathChange(snapshot) + } + } + pathMonitor.start(queue: pathMonitorQueue) databaseObserver = NotificationCenter.default.addObserver( forName: .adeDatabaseDidChange, @@ -578,12 +642,15 @@ final class SyncService: ObservableObject { relayTask?.cancel() hydrationTask?.cancel() reconnectTask?.cancel() + networkPathReconnectTask?.cancel() lanePresenceHeartbeatTask?.cancel() terminalBufferRevisionTask?.cancel() chatEventRevisionTask?.cancel() snapshotDebouncerTask?.cancel() activeSessionsObservationTask?.cancel() discoveryBrowser.stop() + tailnetDiscovery.stop() + pathMonitor.cancel() socketSession.invalidateAndCancel() if let databaseObserver { NotificationCenter.default.removeObserver(databaseObserver) @@ -651,6 +718,46 @@ final class SyncService: ObservableObject { return migrated } + private func publishMergedDiscoveredHosts() { + applyDiscoveredHosts(bonjourDiscoveredHosts + tailnetDiscoveredHosts) + } + + var canReconnectToSavedHost: Bool { + activeHostProfile != nil && keychain.loadToken() != nil + } + + var savedReconnectHost: DiscoveredSyncHost? { + guard canReconnectToSavedHost, + let profile = activeHostProfile ?? loadProfile() else { + return nil + } + let tailscaleAddress = + profile.tailscaleAddress + ?? profile.savedAddressCandidates.first(where: syncIsTailscaleIPv4Address) + ?? profile.lastSuccessfulAddress.flatMap { syncIsTailscaleIPv4Address($0) ? $0 : nil } + let lanAddresses = profile.discoveredLanAddresses.filter { !syncIsTailscaleIPv4Address($0) } + let savedLanAddresses = profile.savedAddressCandidates.filter { !syncIsTailscaleIPv4Address($0) } + let addresses = deduplicatedAddresses( + lanAddresses + + savedLanAddresses + + (profile.lastSuccessfulAddress.flatMap { syncIsTailscaleIPv4Address($0) ? nil : $0 }.map { [$0] } ?? []) + ) + guard tailscaleAddress != nil || !addresses.isEmpty else { return nil } + let identity = profile.hostIdentity?.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = profile.hostName?.trimmingCharacters(in: .whitespacesAndNewlines) + let routeId = tailscaleAddress ?? addresses.first ?? "saved" + return DiscoveredSyncHost( + id: "saved-\(identity?.isEmpty == false ? identity! : routeId)", + serviceName: "Saved ADE host", + hostName: displayName?.isEmpty == false ? displayName! : routeId, + hostIdentity: identity?.isEmpty == false ? identity : nil, + port: profile.port, + addresses: addresses, + tailscaleAddress: tailscaleAddress, + lastResolvedAt: profile.updatedAt + ) + } + func reconnectIfPossible(userInitiated: Bool = false) async { do { try ensureDatabaseReady() @@ -675,7 +782,7 @@ final class SyncService: ObservableObject { guard let profile = loadProfile(), let token = keychain.loadToken() else { return } if !userInitiated && automaticReconnectAddresses(for: profile).isEmpty { if !autoReconnectAwaitingLiveDiscovery { - syncConnectLog.info("reconnect skipped: waiting for live discovery") + syncConnectLog.info("reconnect skipped: waiting for a saved or live route") } autoReconnectAwaitingLiveDiscovery = true return @@ -702,13 +809,54 @@ final class SyncService: ObservableObject { guard isCurrentConnectAttempt(connectAttemptGeneration) else { return } handleReconnectFailure( error, - shouldScheduleRetry: false, + shouldScheduleRetry: !userInitiated, phase: userInitiated ? .failed : .disconnected, connectionState: userInitiated ? .error : .disconnected ) } } + private func handleNetworkPathChange(_ snapshot: SyncNetworkPathSnapshot) { + let previous = lastNetworkPathSnapshot + lastNetworkPathSnapshot = snapshot + guard previous != nil else { return } + guard snapshot.isSatisfied else { return } + guard canReconnectToSavedHost, + allowAutoReconnect, + !autoReconnectPausedByUser, + let profile = activeHostProfile ?? loadProfile() else { + return + } + + let connectedOverTailnet = currentAddress.map(syncIsTailscaleIPv4Address) ?? false + let shouldRoamToTailnet = + !connectedOverTailnet + && profileHasTailnetRoute(profile) + && (snapshot.usesCellular || (!snapshot.usesWiFi && !snapshot.usesWiredEthernet)) + + if shouldRoamToTailnet { + preferTailnetForUpcomingReconnect() + scheduleNetworkPathReconnect(forceSocketReset: true) + return + } + + if !canSendLiveRequests() { + scheduleNetworkPathReconnect(forceSocketReset: false) + } + } + + private func scheduleNetworkPathReconnect(forceSocketReset: Bool) { + networkPathReconnectTask?.cancel() + networkPathReconnectTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 250_000_000) + guard let self, !Task.isCancelled else { return } + if forceSocketReset { + self.teardownSocket(reason: "Network route changed.") + } + await self.reconnectIfPossible() + } + } + func handleForegroundTransition() async { guard !reconnectConnectInFlight else { return } if canSendLiveRequests() { @@ -2546,13 +2694,7 @@ final class SyncService: ObservableObject { } private func connectableAddresses(from addresses: [String]) -> [String] { - addresses.filter { address in - let canAttempt = syncCanAttemptInsecureWebSocket(to: address) - if !canAttempt { - syncConnectLog.debug("skip host=\(address, privacy: .public) reason=ats_insecure_tailscale") - } - return canAttempt - } + addresses } private func noConnectableAddressError() -> NSError { @@ -2560,7 +2702,7 @@ final class SyncService: ObservableObject { domain: "ADE", code: 24, userInfo: [ - NSLocalizedDescriptionKey: "iOS blocks insecure ADE sync over Tailscale IPs. Connect on the same local network or pair with a LAN address.", + NSLocalizedDescriptionKey: "No ADE host address is available. Scan the pairing QR again or enter the host address manually.", ] ) } @@ -2669,7 +2811,25 @@ final class SyncService: ObservableObject { return false } + private func profileHasTailnetRoute(_ profile: HostConnectionProfile) -> Bool { + if profile.tailscaleAddress.map(syncIsTailscaleIPv4Address) == true { return true } + if profile.lastSuccessfulAddress.map(syncIsTailscaleIPv4Address) == true { return true } + return profile.savedAddressCandidates.contains(where: syncIsTailscaleIPv4Address) + } + + private func preferTailnetForUpcomingReconnect() { + preferTailnetReconnectUntil = Date().addingTimeInterval(20) + } + + private func shouldPreferTailnetReconnect() -> Bool { + guard let until = preferTailnetReconnectUntil else { return false } + if until > Date() { return true } + preferTailnetReconnectUntil = nil + return false + } + private func prioritizedAddresses(for profile: HostConnectionProfile) -> [String] { + let preferTailnet = shouldPreferTailnetReconnect() let matchingDiscovery = discoveredHosts.filter { host in matchesDiscoveredHost(host, profile: profile) } @@ -2686,20 +2846,46 @@ final class SyncService: ObservableObject { // own timeout) before we finally try the correct current IP. Only fall // back to cached saved candidates if no live discovery is available. let prioritizedLive = liveLastSuccessful - + liveLan - + liveTailscale - let fallbackSaved = (liveLastSuccessful.isEmpty ? (profile.lastSuccessfulAddress.map { [$0] } ?? []) : []) - + profile.savedAddressCandidates - + profile.discoveredLanAddresses - + (profile.tailscaleAddress.map { [$0] } ?? []) + + (preferTailnet ? liveTailscale : liveLan) + + (preferTailnet ? liveLan : liveTailscale) + let savedTailnet = profile.savedAddressCandidates.filter(syncIsTailscaleIPv4Address) + let savedLan = profile.savedAddressCandidates.filter { !syncIsTailscaleIPv4Address($0) } + let fallbackLastSuccessful = liveLastSuccessful.isEmpty ? (profile.lastSuccessfulAddress.map { [$0] } ?? []) : [] + let savedProfileTailnet = profile.tailscaleAddress.map { [$0] } ?? [] + let fallbackSaved: [String] + if preferTailnet { + fallbackSaved = savedProfileTailnet + + savedTailnet + + fallbackLastSuccessful + + savedLan + + profile.discoveredLanAddresses + } else { + fallbackSaved = fallbackLastSuccessful + + savedLan + + profile.discoveredLanAddresses + + savedProfileTailnet + + savedTailnet + } return deduplicatedAddresses(prioritizedLive + fallbackSaved) } private func automaticReconnectAddresses(for profile: HostConnectionProfile) -> [String] { + let preferTailnet = shouldPreferTailnetReconnect() let matchingDiscovery = discoveredHosts.filter { host in matchesDiscoveredHost(host, profile: profile) } - guard !matchingDiscovery.isEmpty else { return [] } + guard !matchingDiscovery.isEmpty else { + let savedTailnet = profile.savedAddressCandidates.filter(syncIsTailscaleIPv4Address) + let lastSuccessfulTailnet = profile.lastSuccessfulAddress.flatMap { address in + syncIsTailscaleIPv4Address(address) ? [address] : nil + } ?? [] + return deduplicatedAddresses( + (preferTailnet ? [] : lastSuccessfulTailnet) + + (profile.tailscaleAddress.map { [$0] } ?? []) + + savedTailnet + + (preferTailnet ? lastSuccessfulTailnet : []) + ) + } let liveLan = matchingDiscovery.flatMap(\.addresses) let liveTailscale = matchingDiscovery.compactMap(\.tailscaleAddress) @@ -2708,7 +2894,11 @@ final class SyncService: ObservableObject { liveSet.contains(address) ? [address] : nil } ?? [] - return deduplicatedAddresses(liveLastSuccessful + liveLan + liveTailscale) + return deduplicatedAddresses( + liveLastSuccessful + + (preferTailnet ? liveTailscale : liveLan) + + (preferTailnet ? liveLan : liveTailscale) + ) } private func connectUsingProfile( @@ -2914,9 +3104,6 @@ final class SyncService: ObservableObject { connectAttemptGeneration: UInt64, publishConnecting: Bool = true ) async throws { - guard syncCanAttemptInsecureWebSocket(to: host) else { - throw noConnectableAddressError() - } teardownSocket(closeCode: .goingAway) if publishConnecting { connectionState = .connecting @@ -4198,6 +4385,87 @@ extension SyncService { } } +private final class SyncTailnetProbe { + var onHostsChanged: (([DiscoveredSyncHost]) -> Void)? + + private let connectionQueue = DispatchQueue(label: "com.ade.sync.tailnet-probe") + private var probeTask: Task? + + func start() { + guard probeTask == nil else { return } + probeTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refresh() + try? await Task.sleep(nanoseconds: SyncTailnetDiscoveryTiming.probeIntervalNanoseconds) + } + } + } + + func stop() { + probeTask?.cancel() + probeTask = nil + onHostsChanged?([]) + } + + private func refresh() async { + var nextHosts: [String: DiscoveredSyncHost] = [:] + for host in SyncTailnetDiscovery.hostCandidates { + for port in SyncTailnetDiscovery.portCandidates { + guard !Task.isCancelled else { return } + let canConnect = await probe(host: host, port: port) + guard canConnect else { continue } + let key = "\(host):\(port)" + nextHosts[key] = DiscoveredSyncHost( + id: "tailnet-\(key)", + serviceName: "ADE Tailnet \(host)", + hostName: host, + hostIdentity: nil, + port: port, + addresses: [host], + tailscaleAddress: nil, + lastResolvedAt: ISO8601DateFormatter().string(from: Date()) + ) + break + } + } + onHostsChanged?(Array(nextHosts.values)) + } + + private func probe(host: String, port: Int) async -> Bool { + guard let endpointPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false } + return await withCheckedContinuation { continuation in + let connection = NWConnection( + host: NWEndpoint.Host(host), + port: endpointPort, + using: .tcp + ) + var completed = false + let complete: (Bool) -> Void = { result in + guard !completed else { return } + completed = true + connection.cancel() + continuation.resume(returning: result) + } + connection.stateUpdateHandler = { state in + switch state { + case .ready: + complete(true) + case .failed, .cancelled: + complete(false) + default: + break + } + } + connection.start(queue: connectionQueue) + connectionQueue.asyncAfter( + deadline: .now() + .nanoseconds(Int(SyncTailnetDiscoveryTiming.probeTimeoutNanoseconds)) + ) { + complete(false) + } + } + } +} + private final class SyncBonjourBrowser: NSObject, NetServiceBrowserDelegate, NetServiceDelegate { var onHostsChanged: (([DiscoveredSyncHost]) -> Void)? diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 34eaaf4fd..22fd6d30d 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -424,6 +424,7 @@ struct ADEStatusPill: View { struct ADEConnectionDot: View { @EnvironmentObject private var syncService: SyncService + @Environment(\.accessibilityReduceMotion) private var reduceMotion private var tint: Color { switch syncService.connectionState { @@ -493,22 +494,57 @@ struct ADEConnectionDot: View { } } + /// Subtle pill label shown only when the host is not connected. This is + /// the single source of truth for "why is the app empty?" — per-screen + /// "X failed to load" banners whose cause is disconnection are suppressed + /// in favor of this one glance-able affordance in the toolbar. + private var attachedLabel: String? { + switch syncService.connectionState { + case .connected, .syncing: + return nil + case .connecting: + return "Connecting" + case .error: + return "Offline" + case .disconnected: + return "Offline" + } + } + var body: some View { - ZStack { - Circle() - .fill(tint.opacity(0.14)) - .frame(width: 30, height: 30) - .overlay( - Circle() - .stroke(tint.opacity(0.55), lineWidth: 1) - ) - .shadow(color: tint.opacity(showsConnectedGlow ? 0.24 : 0.16), radius: showsConnectedGlow ? 2 : 1) + HStack(spacing: 6) { + ZStack { + Circle() + .fill(tint.opacity(0.14)) + .frame(width: 30, height: 30) + .overlay( + Circle() + .stroke(tint.opacity(0.55), lineWidth: 1) + ) + .shadow(color: tint.opacity(showsConnectedGlow ? 0.24 : 0.16), radius: showsConnectedGlow ? 2 : 1) + + Image(systemName: "gearshape.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + } - Image(systemName: "gearshape.fill") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(tint) + if let attachedLabel { + Text(attachedLabel) + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .foregroundStyle(tint) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(tint.opacity(0.12), in: Capsule()) + .overlay( + Capsule() + .stroke(tint.opacity(0.35), lineWidth: 0.5) + ) + .transition(.opacity.combined(with: .scale(scale: 0.9))) + .accessibilityHidden(true) + } } - .frame(minWidth: 44, minHeight: 44) + .animation(ADEMotion.emphasis(reduceMotion: reduceMotion), value: attachedLabel) + .frame(minHeight: 44) .contentShape(Rectangle()) .onTapGesture { syncService.settingsPresented = true diff --git a/apps/ios/ADE/Views/Cto/CtoChatScreen.swift b/apps/ios/ADE/Views/Cto/CtoChatScreen.swift deleted file mode 100644 index 59a21e90b..000000000 --- a/apps/ios/ADE/Views/Cto/CtoChatScreen.swift +++ /dev/null @@ -1,369 +0,0 @@ -import SwiftUI - -/// CTO Chat tab — compact agent launcher row. Actual CTO/worker chats are -/// pushed as detail destinations so they get the same clean chrome as Work -/// chat sessions. -struct CtoChatScreen: View { - @EnvironmentObject private var syncService: SyncService - @Binding var path: NavigationPath - - @State private var selectedKind: CtoSessionDestinationView.Kind = .cto - @State private var agents: [AgentIdentity] = [] - @State private var fallbackWorkers: [CtoWorkerEntry] = [] - @State private var loadState: LoadState = .idle - @State private var errorMessage: String? - - private enum LoadState: Equatable { - case idle - case loading - case ready - case failed - } - - var body: some View { - VStack(spacing: 0) { - if let errorMessage, shouldShowAgentsLoadError { - ADENoticeCard( - title: "Unable to load CTO agents", - message: errorMessage, - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: "Retry", - action: { Task { await loadAgentsIfNeeded(force: true) } } - ) - .padding(.horizontal, 12) - .padding(.top, 8) - } - - pillsRow - Divider() - .opacity(0.08) - .padding(.top, 2) - - agentListBody - } - .task { await loadAgentsIfNeeded() } - .task(id: ctoAgentsLiveReloadKey) { - guard ctoAgentsLiveReloadKey != nil else { return } - await loadAgentsIfNeeded(force: loadState == .failed) - } - } - - // MARK: - Agent list - - @ViewBuilder - private var agentListBody: some View { - ScrollView { - VStack(spacing: 8) { - // CTO row - chatListRow( - name: "CTO", - subtitle: "Persistent technical lead", - statusDot: ADEColor.ctoAccent, - seed: nil, - isActive: isCtoSelected - ) { openChat(.cto) } - - Divider().opacity(0.07).padding(.horizontal, 16) - - if loadState == .loading && displayAgents.isEmpty { - ForEach(0..<3, id: \.self) { _ in - ADECardSkeleton(rows: 1).padding(.horizontal, 16) - } - } else if displayAgents.isEmpty && loadState == .ready { - Text("No workers hired yet.") - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - .padding(.top, 12) - } else { - ForEach(Array(displayAgents.enumerated()), id: \.element.id) { idx, entry in - chatListRow( - name: entry.name, - subtitle: entry.statusText, - statusDot: ctoStatusTint(entry.statusRaw), - seed: entry.seed, - isActive: isSelected(agentId: entry.agentId) - ) { - openChat(.worker(agentId: entry.agentId, displayName: entry.name)) - } - if idx < displayAgents.count - 1 { - Divider().opacity(0.07).padding(.horizontal, 16) - } - } - } - - Color.clear.frame(height: 24) - } - .padding(.top, 8) - } - } - - private func chatListRow( - name: String, - subtitle: String, - statusDot: Color, - seed: String?, - isActive: Bool, - onTap: @escaping () -> Void - ) -> some View { - Button(action: onTap) { - HStack(spacing: 12) { - ZStack { - let tint = ctoAvatarTint(name: name, seed: seed) - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(isActive ? tint.opacity(0.25) : ADEColor.recessedBackground.opacity(0.55)) - RoundedRectangle(cornerRadius: 11, style: .continuous) - .stroke(isActive ? tint.opacity(0.4) : ADEColor.glassBorder, lineWidth: 0.5) - Text(ctoAvatarInitial(for: name)) - .font(.system(size: 14, weight: .heavy)) - .foregroundStyle(isActive ? ctoAvatarTint(name: name, seed: seed) : ADEColor.textSecondary) - } - .frame(width: 38, height: 38) - - VStack(alignment: .leading, spacing: 2) { - Text(name) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - HStack(spacing: 4) { - Circle() - .fill(statusDot) - .frame(width: 5, height: 5) - .shadow(color: statusDot.opacity(0.6), radius: 2) - Text(subtitle) - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - } - } - - Spacer(minLength: 8) - - Image(systemName: "chevron.right") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .background(isActive ? ADEColor.ctoAccent.opacity(0.05) : Color.clear) - .accessibilityLabel("\(name), \(subtitle)") - .accessibilityHint("Opens chat with \(name).") - .accessibilityAddTraits(isActive ? [.isSelected] : []) - } - - // MARK: - Pills - - private var pillsRow: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - AgentPill( - name: "CTO", - statusText: "persistent", - statusDot: ADEColor.ctoAccent, - isActive: isCtoSelected, - seed: nil - ) { - openChat(.cto) - } - - if loadState == .loading && agents.isEmpty && fallbackWorkers.isEmpty { - ForEach(0..<3, id: \.self) { _ in - pillSkeleton - } - } else { - ForEach(Array(displayAgents.enumerated()), id: \.element.id) { _, entry in - AgentPill( - name: entry.name, - statusText: entry.statusText, - statusDot: ctoStatusTint(entry.statusRaw), - isActive: isSelected(agentId: entry.agentId), - seed: entry.seed - ) { - openChat(.worker(agentId: entry.agentId, displayName: entry.name)) - } - } - } - } - .padding(.horizontal, 16) - .padding(.vertical, 6) - } - .background(Color.clear) - } - - private var pillSkeleton: some View { - RoundedRectangle(cornerRadius: 999, style: .continuous) - .fill(ADEColor.recessedBackground.opacity(0.5)) - .frame(width: 112, height: 28) - .redacted(reason: .placeholder) - } - - private var isCtoSelected: Bool { - if case .cto = selectedKind { return true } - return false - } - - private var hasDisplayAgents: Bool { - !displayAgents.isEmpty - } - - private var shouldShowAgentsLoadError: Bool { - errorMessage != nil && !hasDisplayAgents - } - - private var ctoAgentsLiveReloadKey: String? { - switch syncService.connectionState { - case .connected, .syncing: - return "live-\(syncService.localStateRevision)" - case .connecting, .disconnected, .error: - return nil - } - } - - private func isSelected(agentId: String) -> Bool { - if case .worker(let id, _) = selectedKind { return id == agentId } - return false - } - - private func openChat(_ kind: CtoSessionDestinationView.Kind) { - selectedKind = kind - path.append(route(for: kind)) - } - - private func route(for kind: CtoSessionDestinationView.Kind) -> CtoSessionRoute { - switch kind { - case .cto: - return .cto - case .worker(let agentId, let displayName): - return .worker(agentId: agentId, displayName: displayName) - } - } - - // MARK: - Data - - /// Unified view-model that folds AgentIdentity (preferred) or CtoWorkerEntry - /// (fallback) into one shape so the pill row doesn't care which source hit. - private struct DisplayAgent: Identifiable { - let id: String - let agentId: String - let name: String - let statusRaw: String - let statusText: String - let seed: String? - } - - private var displayAgents: [DisplayAgent] { - if !agents.isEmpty { - return agents.map { agent in - DisplayAgent( - id: agent.id, - agentId: agent.id, - name: agent.name, - statusRaw: agent.status, - statusText: agent.status, - seed: agent.id - ) - } - } - return fallbackWorkers.map { worker in - DisplayAgent( - id: worker.agentId, - agentId: worker.agentId, - name: worker.name, - statusRaw: worker.status, - statusText: worker.status, - seed: worker.avatarSeed - ) - } - } - - @MainActor - private func loadAgentsIfNeeded(force: Bool = false) async { - if loadState == .loading || (!force && loadState == .ready) { return } - loadState = .loading - errorMessage = nil - do { - let fetched = try await syncService.fetchCtoAgents() - agents = fetched - if fetched.isEmpty { - // Fall back to the legacy roster if the new listAgents endpoint is empty. - if let roster = try? await syncService.fetchCtoRoster() { - fallbackWorkers = roster.workers - } - } - loadState = .ready - } catch { - errorMessage = error.localizedDescription - loadState = .failed - // Best-effort fallback so the pill row isn't completely empty. - if let roster = try? await syncService.fetchCtoRoster() { - fallbackWorkers = roster.workers - } - } - } -} - -// MARK: - AgentPill - -private struct AgentPill: View { - let name: String - let statusText: String - let statusDot: Color - let isActive: Bool - let seed: String? - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - HStack(spacing: 6) { - initialBadge - Text(name) - .font(.system(size: 11.5, weight: .semibold)) - .foregroundStyle(isActive ? ADEColor.textPrimary : ADEColor.textSecondary) - .lineLimit(1) - Circle() - .fill(statusDot) - .frame(width: 6, height: 6) - .shadow(color: statusDot.opacity(0.7), radius: 2) - Text("· \(statusText)") - .font(.system(size: 9.5, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - } - .padding(.leading, 5) - .padding(.trailing, 9) - .padding(.vertical, 5) - .background( - Capsule(style: .continuous) - .fill(isActive ? ADEColor.ctoAccent.opacity(0.14) : ADEColor.recessedBackground.opacity(0.55)) - ) - .overlay( - Capsule(style: .continuous) - .stroke( - isActive ? ADEColor.ctoAccent.opacity(0.35) : ADEColor.glassBorder, - lineWidth: 0.5 - ) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .accessibilityLabel("\(name), \(statusText)") - .accessibilityHint("Opens this chat.") - .accessibilityAddTraits(isActive ? [.isSelected] : []) - } - - private var initialBadge: some View { - let tint = ctoAvatarTint(name: name, seed: seed) - return Text(ctoAvatarInitial(for: name)) - .font(.system(size: 10, weight: .heavy)) - .foregroundStyle(isActive ? Color.black : ADEColor.textPrimary) - .frame(width: 20, height: 20) - .background( - Circle().fill(isActive ? tint : ADEColor.recessedBackground.opacity(0.8)) - ) - } -} diff --git a/apps/ios/ADE/Views/Cto/CtoRootScreen.swift b/apps/ios/ADE/Views/Cto/CtoRootScreen.swift index 5c40fc97a..7ba12ba28 100644 --- a/apps/ios/ADE/Views/Cto/CtoRootScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoRootScreen.swift @@ -2,35 +2,21 @@ import SwiftUI import UIKit /// Top-level CTO tab screen. Hosts a persistent NavigationStack and segmented -/// picker (Team / Workflows / Settings). The stack drives -/// drill-down into per-worker chat (CtoSessionDestinationView) and per-worker -/// detail (CtoWorkerDetailScreen). +/// picker (Team / Workflows / Settings). The stack drives drill-down into +/// per-worker chat (CtoSessionDestinationView) and per-worker detail +/// (CtoWorkerDetailScreen). struct CtoRootScreen: View { @EnvironmentObject private var syncService: SyncService var isTabActive = true - @State private var selectedTab: CtoTab = .chat + @State private var selectedTab: CtoTab = .team @State private var path = NavigationPath() @State private var snapshot: CtoSnapshot? - @State private var snapshotError: String? @State private var isLoadingSnapshot = false var body: some View { NavigationStack(path: $path) { VStack(spacing: 0) { - if let snapshotError { - ADENoticeCard( - title: "CTO failed to load", - message: snapshotError, - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: "Retry", - action: { Task { await loadSnapshot() } } - ) - .padding(.horizontal, 12) - .padding(.top, 8) - } - CtoTabShell(active: $selectedTab) tabBody @@ -71,9 +57,6 @@ struct CtoRootScreen: View { @ViewBuilder private var tabBody: some View { switch selectedTab { - case .chat: - CtoChatScreen(path: $path) - .environmentObject(syncService) case .team: CtoTeamScreen(path: $path) .environmentObject(syncService) @@ -91,12 +74,9 @@ struct CtoRootScreen: View { if isLoadingSnapshot { return } isLoadingSnapshot = true defer { isLoadingSnapshot = false } - do { - snapshot = try await syncService.fetchCtoState() - snapshotError = nil - } catch { - snapshotError = error.localizedDescription - } + // Non-connection errors here are silently ignored — the global connection + // banner (top-right gear) owns user-facing failure messaging. + snapshot = (try? await syncService.fetchCtoState()) ?? snapshot } private var ctoLiveReloadKey: String? { diff --git a/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift b/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift index 941f24a2a..444dcb189 100644 --- a/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift @@ -76,22 +76,37 @@ struct CtoSessionDestinationView: View { .ctoSessionNavigationChrome(mode: navigationChrome, title: navigationTitle) } + @ViewBuilder private func failureView(message: String) -> some View { - VStack(spacing: 16) { - ADENoticeCard( - title: "Could not open CTO chat", - message: message, - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: "Retry", - action: { - Task { @MainActor in startEnsureSession(force: true) } - } + // When the host isn't reachable, don't flash a big red "Could not open + // CTO chat" card — the top-right gear dot already signals offline. Show + // a subtle empty state instead so the user knows to reconnect. + if syncService.connectionState.isHostUnreachable { + ADEEmptyStateView( + symbol: "wifi.slash", + title: "Connect your Mac to open this chat", + message: "Tap the settings gear in the top right to reconnect to your desktop host." ) + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .ctoSessionNavigationChrome(mode: navigationChrome, title: navigationTitle) + } else { + VStack(spacing: 16) { + ADENoticeCard( + title: "Could not open CTO chat", + message: message, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Retry", + action: { + Task { @MainActor in startEnsureSession(force: true) } + } + ) + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .ctoSessionNavigationChrome(mode: navigationChrome, title: navigationTitle) } - .padding(16) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .ctoSessionNavigationChrome(mode: navigationChrome, title: navigationTitle) } private var navigationTitle: String { diff --git a/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift b/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift index 68613367b..d4e62b8f1 100644 --- a/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift @@ -16,52 +16,42 @@ struct CtoSettingsScreen: View { @State private var desktopOnlyTitle: String = "" var body: some View { - List { - if let errorMessage { - ADENoticeCard( - title: "Settings failed to load", - message: errorMessage, - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: "Retry", - action: { Task { await reload() } } - ) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 6, trailing: 0)) - } + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if let errorMessage, !syncService.connectionState.isHostUnreachable { + ADENoticeCard( + title: "Settings failed to load", + message: errorMessage, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Retry", + action: { Task { await reload() } } + ) + } - if isLoading && snapshot == nil { - Section { + if isLoading && snapshot == nil { VStack(spacing: 12) { ADECardSkeleton(rows: 3) ADECardSkeleton(rows: 4) ADECardSkeleton(rows: 3) } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) } - } - if let snapshot { - identitySection(snapshot) - coreMemorySection(snapshot) - } + if let snapshot { + identitySection(snapshot) + coreMemorySection(snapshot) + } - heartbeatSection - budgetSection - integrationsSection - advancedSection + heartbeatSection + budgetSection + integrationsSection + advancedSection - Section { - Color.clear.frame(height: 40) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets()) + Color.clear.frame(height: 32) } + .padding(.horizontal, 16) + .padding(.top, 8) } - .listStyle(.plain) .scrollContentBackground(.hidden) .adeScreenBackground() .refreshable { await reload() } @@ -89,19 +79,12 @@ struct CtoSettingsScreen: View { @ViewBuilder private func identitySection(_ snapshot: CtoSnapshot) -> some View { - Section { + VStack(alignment: .leading, spacing: 6) { SectionHeader(title: "Identity") - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 6, trailing: 0)) - IdentityCard( identity: snapshot.identity, onEdit: { showingIdentityEditor = true } ) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) } } @@ -109,12 +92,8 @@ struct CtoSettingsScreen: View { @ViewBuilder private func coreMemorySection(_ snapshot: CtoSnapshot) -> some View { - Section { + VStack(alignment: .leading, spacing: 6) { SectionHeader(title: "Core memory") - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 12, leading: 0, bottom: 6, trailing: 0)) - VStack(spacing: 0) { MemoryRow( label: "Project summary", @@ -137,9 +116,6 @@ struct CtoSettingsScreen: View { ) { showingBriefEditor = true } } .adeListCard(padding: 0) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) } } @@ -150,12 +126,8 @@ struct CtoSettingsScreen: View { // MARK: - Heartbeat (read-only placeholders) private var heartbeatSection: some View { - Section { + VStack(alignment: .leading, spacing: 6) { SectionHeader(title: "Heartbeat") - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 12, leading: 0, bottom: 6, trailing: 0)) - VStack(spacing: 0) { RowItem(label: "Mode", value: "Combined", disabled: true) Sep() @@ -164,21 +136,14 @@ struct CtoSettingsScreen: View { RowItem(label: "Event triggers", value: "—", disabled: true) } .adeListCard(padding: 0) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) } } // MARK: - Budget private var budgetSection: some View { - Section { + VStack(alignment: .leading, spacing: 6) { SectionHeader(title: "Budget") - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 12, leading: 0, bottom: 6, trailing: 0)) - VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline, spacing: 6) { @@ -205,25 +170,7 @@ struct CtoSettingsScreen: View { Text("\(pct)% this month") .font(.system(size: 10, design: .monospaced)) .foregroundStyle(ADEColor.textMuted) - GeometryReader { proxy in - let width = proxy.size.width * CGFloat(pct) / 100.0 - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill(ADEColor.recessedBackground.opacity(0.5)) - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill( - LinearGradient( - colors: pct > 80 - ? [ADEColor.warning, ADEColor.warning.opacity(0.7)] - : [ADEColor.accentDeep, ADEColor.ctoAccent], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(width: max(0, width)) - } - } - .frame(height: 5) + CtoBudgetBar(percent: pct) } Divider().opacity(0.08) @@ -239,9 +186,6 @@ struct CtoSettingsScreen: View { } } .adeListCard() - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) } } @@ -268,20 +212,14 @@ struct CtoSettingsScreen: View { // MARK: - Integrations private var integrationsSection: some View { - Section { + VStack(alignment: .leading, spacing: 6) { SectionHeader(title: "Integrations") - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 12, leading: 0, bottom: 6, trailing: 0)) - if let syncNotice { Text(syncNotice) .font(.system(size: 11.5, design: .monospaced)) .foregroundStyle(ADEColor.success) .frame(maxWidth: .infinity, alignment: .leading) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 4, trailing: 16)) + .padding(.horizontal, 16) } VStack(spacing: 0) { @@ -338,9 +276,6 @@ struct CtoSettingsScreen: View { IntegrationRow(name: "External MCP", subtitle: "off", connected: false) } .adeListCard(padding: 0) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) } } @@ -364,12 +299,8 @@ struct CtoSettingsScreen: View { // MARK: - Advanced private var advancedSection: some View { - Section { + VStack(alignment: .leading, spacing: 6) { SectionHeader(title: "Advanced") - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 12, leading: 0, bottom: 6, trailing: 0)) - VStack(spacing: 0) { RowItem(label: "Re-run onboarding", value: "") { desktopOnlyTitle = "Re-run onboarding" @@ -387,9 +318,6 @@ struct CtoSettingsScreen: View { } } .adeListCard(padding: 0) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) } } @@ -413,30 +341,48 @@ struct CtoSettingsScreen: View { errorMessage = nil defer { isLoading = false } - async let stateResult = Result { try await syncService.fetchCtoState() } - async let budgetResult = Result { try await syncService.fetchCtoBudget() } - async let linearResult = Result { try await syncService.fetchLinearConnectionStatus() } - - let (state, budgetValue, linear) = await (stateResult, budgetResult, linearResult) - - if case .success(let value) = state { self.snapshot = value } - if case .success(let value) = budgetValue { self.budget = value } - if case .success(let value) = linear { self.linearStatus = value } + do { + self.snapshot = try await syncService.fetchCtoState() + } catch { + if self.snapshot == nil { + self.errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } - // Only surface an error if the primary read (state) failed — budget and - // linear are supplemental and degrade gracefully. - if case .failure(let err) = state, self.snapshot == nil { - self.errorMessage = (err as? LocalizedError)?.errorDescription ?? String(describing: err) + if let value = try? await syncService.fetchCtoBudget() { + self.budget = value + } + if let value = try? await syncService.fetchLinearConnectionStatus() { + self.linearStatus = value } } } -// MARK: - Result-bridging helper for concurrent loads +// MARK: - Budget bar (GeometryReader-free to avoid iOS 26 glass-layer pool churn) + +private struct CtoBudgetBar: View { + let percent: Int -private extension Result where Failure == Error { - init(_ body: () async throws -> Success) async { - do { self = .success(try await body()) } - catch { self = .failure(error) } + var body: some View { + let clamped = max(0, min(100, percent)) + let fill = CGFloat(clamped) / 100.0 + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(ADEColor.recessedBackground.opacity(0.5)) + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill( + LinearGradient( + colors: clamped > 80 + ? [ADEColor.warning, ADEColor.warning.opacity(0.7)] + : [ADEColor.accentDeep, ADEColor.ctoAccent], + startPoint: .leading, + endPoint: .trailing + ) + ) + .scaleEffect(x: fill, y: 1, anchor: .leading) + } + .frame(height: 5) + .frame(maxWidth: .infinity) } } @@ -459,7 +405,7 @@ private struct SectionHeader: View { .foregroundStyle(ADEColor.textMuted) } } - .padding(.horizontal, 16) + .padding(.top, 6) } } diff --git a/apps/ios/ADE/Views/Cto/CtoTabShell.swift b/apps/ios/ADE/Views/Cto/CtoTabShell.swift index 4e96a59f2..5735a3860 100644 --- a/apps/ios/ADE/Views/Cto/CtoTabShell.swift +++ b/apps/ios/ADE/Views/Cto/CtoTabShell.swift @@ -1,7 +1,6 @@ import SwiftUI enum CtoTab: String, CaseIterable, Identifiable { - case chat case team case workflows case settings @@ -10,7 +9,6 @@ enum CtoTab: String, CaseIterable, Identifiable { var label: String { switch self { - case .chat: return "Chat" case .team: return "Team" case .workflows: return "Workflows" case .settings: return "Settings" diff --git a/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift b/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift index 2ac79eabb..2ec80d3a1 100644 --- a/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift @@ -1,7 +1,8 @@ import SwiftUI -/// CTO Team tab — budget card + 2-column grid of WorkerCards. -/// Mirrors screen-team.jsx. +/// CTO Team tab — hero CTO card pinned at the top, followed by budget card and +/// the full roster of hired workers. Primary tap opens chat; a secondary +/// "Details" chip drills into the worker dashboard. struct CtoTeamScreen: View { @EnvironmentObject private var syncService: SyncService @Binding var path: NavigationPath @@ -9,48 +10,33 @@ struct CtoTeamScreen: View { @State private var agents: [AgentIdentity] = [] @State private var budget: AgentBudgetSnapshot? @State private var isLoading = false - @State private var errorMessage: String? @State private var showHireSheet = false @State private var pendingWakeup: Set = [] @State private var wakeupNotice: String? - private let columns: [GridItem] = [ - GridItem(.flexible(), spacing: 8), - GridItem(.flexible(), spacing: 8), - ] - var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { headerRow .padding(.horizontal, 20) - if let errorMessage { - ADENoticeCard( - title: "Team failed to load", - message: errorMessage, - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: "Retry", - action: { Task { await load() } } - ) + ctoHeroCard .padding(.horizontal, 16) - } budgetCard .padding(.horizontal, 16) - ctoCard - .padding(.horizontal, 16) + rosterSectionHeader + .padding(.horizontal, 20) if isLoading && agents.isEmpty { - LazyVGrid(columns: columns, spacing: 8) { + VStack(spacing: 8) { ForEach(0..<4, id: \.self) { _ in ADECardSkeleton(rows: 2) } } - .padding(.horizontal, 14) + .padding(.horizontal, 16) } else if agents.isEmpty { ADEEmptyStateView( symbol: "person.crop.circle.badge.questionmark", @@ -59,19 +45,23 @@ struct CtoTeamScreen: View { ) .padding(.horizontal, 16) } else { - LazyVGrid(columns: columns, spacing: 8) { + VStack(spacing: 8) { ForEach(agents) { agent in - WorkerCard( + WorkerRowCard( agent: agent, spentOverride: spentOverride(for: agent), isWaking: pendingWakeup.contains(agent.id), - onTap: { path.append(CtoSessionRoute.workerDetail(agentId: agent.id, displayName: agent.name)) }, + onOpenChat: { + path.append(CtoSessionRoute.worker(agentId: agent.id, displayName: agent.name)) + }, onWake: { Task { await wakeUp(agent: agent) } }, - onEdit: { path.append(CtoSessionRoute.workerDetail(agentId: agent.id, displayName: agent.name)) } + onDetails: { + path.append(CtoSessionRoute.workerDetail(agentId: agent.id, displayName: agent.name)) + } ) } } - .padding(.horizontal, 14) + .padding(.horizontal, 16) } if let wakeupNotice { @@ -137,44 +127,63 @@ struct CtoTeamScreen: View { return "\(count) \(workerWord) · \(spent) this month" } - private var ctoCard: some View { + // MARK: - Hero CTO card + + private var ctoHeroCard: some View { Button { path.append(CtoSessionRoute.cto) } label: { - HStack(alignment: .center, spacing: 12) { + HStack(alignment: .center, spacing: 14) { ZStack { - Circle() - .fill(ADEColor.ctoAccent.opacity(0.18)) + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill( + LinearGradient( + colors: [ADEColor.ctoAccent.opacity(0.32), ADEColor.ctoAccent.opacity(0.14)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(ADEColor.ctoAccent.opacity(0.4), lineWidth: 0.8) Text("C") - .font(.subheadline.weight(.bold)) + .font(.system(size: 22, weight: .heavy)) .foregroundStyle(ADEColor.ctoAccent) } - .frame(width: 38, height: 38) + .frame(width: 52, height: 52) .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Text("CTO") - .font(.subheadline.weight(.bold)) + .font(.system(size: 16, weight: .bold)) .foregroundStyle(ADEColor.textPrimary) ADEStatusPill(text: "Persistent", tint: ADEColor.ctoAccent) } - Text("Technical lead chat") - .font(.caption) + Text("Technical lead · tap to chat") + .font(.system(size: 12)) .foregroundStyle(ADEColor.textSecondary) } Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) + Image(systemName: "message.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.ctoAccent) + .padding(8) + .background( + Circle().fill(ADEColor.ctoAccent.opacity(0.14)) + ) + .overlay( + Circle().stroke(ADEColor.ctoAccent.opacity(0.32), lineWidth: 0.5) + ) + .accessibilityHidden(true) } + .padding(.vertical, 4) .contentShape(Rectangle()) } .buttonStyle(.plain) .adeListCard() - .accessibilityLabel("CTO, persistent technical lead chat") + .accessibilityLabel("CTO, persistent technical lead") .accessibilityHint("Opens the CTO chat.") } @@ -242,16 +251,34 @@ struct CtoTeamScreen: View { budget?.workers.first(where: { $0.agentId == agent.id })?.spentMonthlyCents } + // MARK: - Roster section header + + private var rosterSectionHeader: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("Workers") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .textCase(.uppercase) + .tracking(0.4) + Spacer(minLength: 0) + if !agents.isEmpty { + Text("\(agents.count)") + .font(.caption.monospaced()) + .foregroundStyle(ADEColor.textMuted) + } + } + } + // MARK: - Actions /// Loads agents and budget in parallel but tolerates partial failure — a - /// missing budget snapshot shouldn't hide the worker grid, and a listAgents - /// failure shouldn't hide the budget card. + /// missing budget snapshot shouldn't hide the worker list, and a listAgents + /// failure shouldn't hide the budget card. Connection-level errors are + /// surfaced globally by the top-right gear, not inline here. @MainActor private func load() async { if isLoading { return } isLoading = true - errorMessage = nil defer { isLoading = false } async let agentsR = CtoTeamAsyncResult { try await syncService.fetchCtoAgents() } @@ -260,8 +287,6 @@ struct CtoTeamScreen: View { if case .success(let fetched) = agentsResult { agents = fetched - } else if case .failure(let err) = agentsResult, agents.isEmpty { - errorMessage = err.localizedDescription } if case .success(let snap) = budgetResult { budget = snap @@ -282,15 +307,18 @@ struct CtoTeamScreen: View { } } -// MARK: - WorkerCard +// MARK: - WorkerRowCard -private struct WorkerCard: View { +/// Full-width worker row. Primary tap opens chat; the Details chip on the +/// right opens the worker dashboard. A context menu (long-press) mirrors both +/// actions plus Wake. +private struct WorkerRowCard: View { let agent: AgentIdentity let spentOverride: Int? let isWaking: Bool - let onTap: () -> Void + let onOpenChat: () -> Void let onWake: () -> Void - let onEdit: () -> Void + let onDetails: () -> Void private var spentCents: Int { spentOverride ?? agent.spentMonthlyCents ?? 0 } private var budgetCents: Int? { agent.budgetMonthlyCents } @@ -304,72 +332,69 @@ private struct WorkerCard: View { private var isRunning: Bool { agent.status.lowercased() == "running" } var body: some View { - VStack(alignment: .leading, spacing: 5) { - Button(action: onTap) { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 6) { - Circle() - .fill(statusDot) - .frame(width: 7, height: 7) - .shadow(color: isRunning ? statusDot.opacity(0.8) : .clear, radius: 3) - Text(agent.name) - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 0) - } + HStack(alignment: .center, spacing: 12) { + Button(action: onOpenChat) { + HStack(alignment: .center, spacing: 12) { + avatar + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Circle() + .fill(statusDot) + .frame(width: 7, height: 7) + .shadow(color: isRunning ? statusDot.opacity(0.8) : .clear, radius: 3) + Text(agent.name) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } - HStack(spacing: 5) { - Text(agent.role) - .font(.system(size: 9.5, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - Text("·") - .font(.system(size: 9.5)) - .foregroundStyle(ADEColor.textMuted.opacity(0.5)) - Text(agent.model ?? "—") - .font(.system(size: 9.5, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) + HStack(spacing: 5) { + Text(agent.role) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + Text("·") + .font(.system(size: 10.5)) + .foregroundStyle(ADEColor.textMuted.opacity(0.5)) + Text(agent.model ?? "—") + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + + HStack(spacing: 6) { + Text(ctoFormatCents(spentCents) + "/mo") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + if let pct = progressPct { + GeometryReader { proxy in + let width = proxy.size.width * CGFloat(pct) / 100.0 + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(ADEColor.recessedBackground.opacity(0.5)) + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(pct > 80 ? ADEColor.warning : ADEColor.ctoAccent) + .frame(width: max(0, width)) + } + } + .frame(height: 2) + } + } + .padding(.top, 1) } } - .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("\(agent.name), \(agent.role), status \(agent.status)") - .accessibilityHint("Opens worker detail.") + .accessibilityHint("Opens chat with \(agent.name).") - HStack(alignment: .center, spacing: 4) { - Text(ctoFormatCents(spentCents) + "/mo") - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - Spacer(minLength: 4) - MiniActionButton(label: isWaking ? "…" : "Wake", action: onWake) - .disabled(isWaking) - .accessibilityLabel("Wake \(agent.name)") - MiniActionButton(label: "Edit", action: onEdit) - .accessibilityLabel("Edit \(agent.name)") - } - .padding(.top, 1) - - if let pct = progressPct { - GeometryReader { proxy in - let width = proxy.size.width * CGFloat(pct) / 100.0 - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 1, style: .continuous) - .fill(ADEColor.recessedBackground.opacity(0.5)) - RoundedRectangle(cornerRadius: 1, style: .continuous) - .fill(pct > 80 ? ADEColor.warning : ADEColor.ctoAccent) - .frame(width: max(0, width)) - } - } - .frame(height: 2) - .padding(.top, 2) - } + actionsStack } - .padding(11) + .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) @@ -385,6 +410,49 @@ private struct WorkerCard: View { RoundedRectangle(cornerRadius: 14, style: .continuous) .stroke(ADEColor.glassBorder, lineWidth: 0.5) ) + .contextMenu { + Button { + onOpenChat() + } label: { + Label("Chat", systemImage: "message") + } + Button { + onDetails() + } label: { + Label("Details", systemImage: "info.circle") + } + Button { + onWake() + } label: { + Label(isWaking ? "Waking…" : "Wake", systemImage: "bolt") + } + .disabled(isWaking) + } + } + + private var avatar: some View { + let tint = ctoAvatarTint(name: agent.name, seed: agent.id) + return ZStack { + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(tint.opacity(0.18)) + RoundedRectangle(cornerRadius: 11, style: .continuous) + .stroke(tint.opacity(0.35), lineWidth: 0.6) + Text(ctoAvatarInitial(for: agent.name)) + .font(.system(size: 14, weight: .heavy)) + .foregroundStyle(tint) + } + .frame(width: 38, height: 38) + .accessibilityHidden(true) + } + + private var actionsStack: some View { + VStack(spacing: 4) { + MiniActionButton(label: isWaking ? "…" : "Wake", action: onWake) + .disabled(isWaking) + .accessibilityLabel("Wake \(agent.name)") + MiniActionButton(label: "Details", action: onDetails) + .accessibilityLabel("Details for \(agent.name)") + } } } @@ -397,8 +465,9 @@ private struct MiniActionButton: View { Text(label) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(ADEColor.textSecondary) - .padding(.horizontal, 7) - .padding(.vertical, 2) + .frame(minWidth: 52) + .padding(.horizontal, 8) + .padding(.vertical, 4) .background(ADEColor.recessedBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 6, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 6, style: .continuous) diff --git a/apps/ios/ADE/Views/Cto/CtoWorkerDetailScreen.swift b/apps/ios/ADE/Views/Cto/CtoWorkerDetailScreen.swift index 9d3ec6267..7e6723c33 100644 --- a/apps/ios/ADE/Views/Cto/CtoWorkerDetailScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoWorkerDetailScreen.swift @@ -28,7 +28,7 @@ struct CtoWorkerDetailScreen: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { - if let errorMessage { + if let errorMessage, !syncService.connectionState.isHostUnreachable { ADENoticeCard( title: "Worker failed to load", message: errorMessage, diff --git a/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift b/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift index 2000678ad..3a9ff9f0a 100644 --- a/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift @@ -15,7 +15,7 @@ struct CtoWorkflowsScreen: View { var body: some View { List { - if let errorMessage { + if let errorMessage, !syncService.connectionState.isHostUnreachable { ADENoticeCard( title: "Workflows failed to load", message: errorMessage, @@ -51,7 +51,8 @@ struct CtoWorkflowsScreen: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) - } else if connection == nil, errorMessage == nil, !isLoading { + } else if connection == nil, !isLoading, + errorMessage == nil || syncService.connectionState.isHostUnreachable { notConnectedCard .listRowBackground(Color.clear) .listRowSeparator(.hidden) diff --git a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift index 00b4f883c..4addc21ae 100644 --- a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift +++ b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift @@ -20,7 +20,7 @@ struct FilesDirectoryContentsView: View { var body: some View { LazyVStack(alignment: .leading, spacing: 12) { - if let errorMessage { + if let errorMessage, !syncService.connectionState.isHostUnreachable { ADENoticeCard( title: "Directory load failed", message: errorMessage, diff --git a/apps/ios/ADE/Views/Files/FilesDirectoryScreen.swift b/apps/ios/ADE/Views/Files/FilesDirectoryScreen.swift index ee6525dcc..d83c1ce77 100644 --- a/apps/ios/ADE/Views/Files/FilesDirectoryScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesDirectoryScreen.swift @@ -19,7 +19,7 @@ struct FilesDirectoryScreen: View { var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 14) { - if let refreshErrorMessage { + if let refreshErrorMessage, !syncService.connectionState.isHostUnreachable { ADENoticeCard( title: "Refresh failed", message: refreshErrorMessage, diff --git a/apps/ios/ADE/Views/Files/FilesRootScreen.swift b/apps/ios/ADE/Views/Files/FilesRootScreen.swift index b1ab5a962..7d5bb61e2 100644 --- a/apps/ios/ADE/Views/Files/FilesRootScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesRootScreen.swift @@ -61,7 +61,11 @@ struct FilesRootScreen: View { NavigationStack(path: $navigationPath) { ScrollView { LazyVStack(alignment: .leading, spacing: 14) { - if let hydrationNotice = filesStatus.inlineHydrationFailureNotice(for: .files) { + // Suppress connection-caused load failure banners; the top-right + // gear dot is the single source of truth for host reachability. + if !syncService.connectionState.isHostUnreachable, + let hydrationNotice = filesStatus.inlineHydrationFailureNotice(for: .files) + { ADENoticeCard( title: hydrationNotice.title, message: hydrationNotice.message, @@ -72,7 +76,10 @@ struct FilesRootScreen: View { ) .transition(.opacity) } - if let errorMessage, filesStatus.phase == .ready { + if let errorMessage, + filesStatus.phase == .ready, + !syncService.connectionState.isHostUnreachable + { ADENoticeCard( title: "Files view error", message: errorMessage, diff --git a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift index 4ba68c8d5..9eaff2a82 100644 --- a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift @@ -82,7 +82,11 @@ struct LaneManageSheet: View { NavigationStack { ScrollView { VStack(spacing: 14) { - if let liveActionNoticePresentation { + // Connection-caused live-action notices are suppressed — the gear + // dot in the toolbar already communicates offline state. + if !syncService.connectionState.isHostUnreachable, + let liveActionNoticePresentation + { ADENoticeCard( title: liveActionNoticePresentation.title, message: liveActionNoticePresentation.message, diff --git a/apps/ios/ADE/Views/LanesTabView.swift b/apps/ios/ADE/Views/LanesTabView.swift index d2fa54519..140725e0f 100644 --- a/apps/ios/ADE/Views/LanesTabView.swift +++ b/apps/ios/ADE/Views/LanesTabView.swift @@ -66,7 +66,11 @@ struct LanesTabView: View { NavigationStack { ScrollView { LazyVStack(spacing: 14) { - if let hydrationNotice = laneStatus.inlineHydrationFailureNotice(for: .lanes) { + // Suppress connection-caused banners; the top-right gear dot is the + // single source of truth for host reachability. + if !syncService.connectionState.isHostUnreachable, + let hydrationNotice = laneStatus.inlineHydrationFailureNotice(for: .lanes) + { ADENoticeCard( title: hydrationNotice.title, message: hydrationNotice.message, @@ -77,7 +81,10 @@ struct LanesTabView: View { ) .transition(.opacity) } - if let errorMessage, laneStatus.phase == .ready { + if let errorMessage, + laneStatus.phase == .ready, + !syncService.connectionState.isHostUnreachable + { ADENoticeCard( title: "Lane view error", message: errorMessage, @@ -88,7 +95,10 @@ struct LanesTabView: View { ) .transition(.opacity) } - if let primaryBranchError, laneStatus.phase == .ready { + if let primaryBranchError, + laneStatus.phase == .ready, + !syncService.connectionState.isHostUnreachable + { ADENoticeCard( title: "Primary branch error", message: primaryBranchError, @@ -99,7 +109,14 @@ struct LanesTabView: View { ) .transition(.opacity) } - if let liveActionNoticePresentation { + // The live-action notice is entirely connection-oriented ("Pair to + // run lane actions" / "Reconnect before creating lanes"), which + // duplicates the gear-dot signal. Only surface it when something + // other than raw disconnection is the cause (e.g. still connecting + // but sync not ready yet) so the user still gets hinted. + if !syncService.connectionState.isHostUnreachable, + let liveActionNoticePresentation + { ADENoticeCard( title: liveActionNoticePresentation.title, message: liveActionNoticePresentation.message, diff --git a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift index ace5f0147..4ab61f11b 100644 --- a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift +++ b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift @@ -183,7 +183,7 @@ struct CreatePrWizardView: View { VStack(spacing: 0) { dragHandle heroHeader - if let errorMessage { + if let errorMessage, !syncService.connectionState.isHostUnreachable { ADENoticeCard( title: "Create PR failed", message: errorMessage, diff --git a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift index b4eec498c..f02186a32 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift @@ -194,7 +194,7 @@ struct PrDetailView: View { .prListRow() } - if let errorMessage { + if let errorMessage, !syncService.connectionState.isHostUnreachable { ADENoticeCard( title: "PR detail failed", message: errorMessage, diff --git a/apps/ios/ADE/Views/PRs/PrRebaseScreen.swift b/apps/ios/ADE/Views/PRs/PrRebaseScreen.swift index 569500a7d..708ebccf5 100644 --- a/apps/ios/ADE/Views/PRs/PrRebaseScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrRebaseScreen.swift @@ -85,7 +85,7 @@ struct PrRebaseScreen: View { conflictCard .padding(.horizontal, 16) - if let errorMessage { + if let errorMessage, !syncService.connectionState.isHostUnreachable { ADENoticeCard( title: "Rebase failed", message: errorMessage, diff --git a/apps/ios/ADE/Views/PRs/PrStackSheet.swift b/apps/ios/ADE/Views/PRs/PrStackSheet.swift index b4579b828..b703a7a89 100644 --- a/apps/ios/ADE/Views/PRs/PrStackSheet.swift +++ b/apps/ios/ADE/Views/PRs/PrStackSheet.swift @@ -29,7 +29,7 @@ struct PrStackSheet: View { .padding(.vertical, 32) } .frame(maxWidth: .infinity) - } else if let errorMessage { + } else if let errorMessage, !syncService.connectionState.isHostUnreachable { ScrollView { ADENoticeCard( title: "Stack failed to load", diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index f8eb56e0b..56cdf577d 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -261,7 +261,12 @@ struct PRsTabView: View { .prListRow() } } else { - if let hydrationNotice = prsStatus.inlineHydrationFailureNotice(for: .prs) { + // Suppress hydration and view-error banners when the host is + // unreachable — the red gear dot is the single source of truth + // for connection state. + if !syncService.connectionState.isHostUnreachable, + let hydrationNotice = prsStatus.inlineHydrationFailureNotice(for: .prs) + { ADENoticeCard( title: hydrationNotice.title, message: hydrationNotice.message, @@ -272,7 +277,10 @@ struct PRsTabView: View { ) .prListRow() } - if let errorMessage, prsStatus.phase == .ready { + if let errorMessage, + prsStatus.phase == .ready, + !syncService.connectionState.isHostUnreachable + { ADENoticeCard( title: "PR view error", message: errorMessage, diff --git a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift index 439248d4b..6bdaf2f8d 100644 --- a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift +++ b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift @@ -134,11 +134,14 @@ struct SettingsConnectionHeader: View { case .connected, .syncing: return "Live · ready to sync" case .connecting: - return "Establishing a secure channel" + return "Connecting to saved host" case .error: return "Unable to reach your Mac" case .disconnected: - if syncService.activeHostProfile?.hostIdentity != nil { + if syncService.savedReconnectHost?.tailscaleAddress != nil { + return "Saved host · Tailscale route ready" + } + if syncService.canReconnectToSavedHost { return "Saved host · not connected" } return "No paired host" @@ -191,10 +194,11 @@ private struct SettingsConnectedHostDetails: View { guard let address = syncService.currentAddress ?? syncService.activeHostProfile?.lastSuccessfulAddress else { return nil } + let prefix = syncIsTailscaleIPv4Address(address) ? "Tailscale " : "" if let port = syncService.activeHostProfile?.port { - return "\(address) · :\(port)" + return "\(prefix)\(address) · :\(port)" } - return address + return "\(prefix)\(address)" } } @@ -226,7 +230,7 @@ private struct SettingsConnectionQuickAction: View { .glassEffect() case .error, .disconnected: - if syncService.activeHostProfile?.hostIdentity != nil { + if syncService.canReconnectToSavedHost { ADEGlassActionButton( title: "Reconnect", symbol: "arrow.clockwise", diff --git a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift index 036526354..e8e7ad149 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift @@ -45,6 +45,9 @@ struct SettingsPairingSection: View { private var discoverSubtitle: String? { let count = syncService.discoveredHosts.count + if count == 0, syncService.savedReconnectHost?.tailscaleAddress != nil { + return "Saved Tailscale route" + } if count == 0 { return "Looking nearby" } @@ -218,7 +221,16 @@ struct DiscoverHostsSheet: View { NavigationStack { ScrollView { LazyVStack(spacing: 10) { - if syncService.discoveredHosts.isEmpty { + let savedHost = syncService.savedReconnectHost + let liveHosts = syncService.discoveredHosts.filter { host in + guard let savedHost else { return true } + if let hostIdentity = host.hostIdentity, let savedIdentity = savedHost.hostIdentity { + return hostIdentity != savedIdentity + } + return host.id != savedHost.id + } + + if savedHost == nil && liveHosts.isEmpty { VStack(spacing: 14) { ADESkeletonView(height: 56, cornerRadius: 14) ADESkeletonView(height: 56, cornerRadius: 14) @@ -229,11 +241,21 @@ struct DiscoverHostsSheet: View { } .padding(.top, 24) } else { - ForEach(syncService.discoveredHosts) { host in + if let savedHost { + Button { + dismiss() + Task { await syncService.reconnectIfPossible(userInitiated: true) } + } label: { + DiscoveredHostRow(host: savedHost, detailPrefix: "Saved Tailscale", accessoryText: "Reconnect") + } + .buttonStyle(ADEScaleButtonStyle()) + } + + ForEach(liveHosts) { host in Button { onPick(host) } label: { - DiscoveredHostRow(host: host) + DiscoveredHostRow(host: host, detailPrefix: host.tailscaleAddress == nil && !host.id.hasPrefix("tailnet-") ? nil : "Tailscale") } .buttonStyle(ADEScaleButtonStyle()) } @@ -257,6 +279,8 @@ struct DiscoverHostsSheet: View { private struct DiscoveredHostRow: View { let host: DiscoveredSyncHost + var detailPrefix: String? + var accessoryText: String? var body: some View { HStack(spacing: 14) { @@ -273,7 +297,7 @@ private struct DiscoveredHostRow: View { Text(host.hostName) .font(.body.weight(.medium)) .foregroundStyle(ADEColor.textPrimary) - Text(host.addresses.first ?? "No route") + Text(routeText) .font(.caption.monospaced()) .foregroundStyle(ADEColor.textSecondary) .lineLimit(1) @@ -282,9 +306,15 @@ private struct DiscoveredHostRow: View { Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) + if let accessoryText { + Text(accessoryText) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.purpleAccent) + } else { + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + } } .padding(.horizontal, 16) .padding(.vertical, 14) @@ -299,6 +329,12 @@ private struct DiscoveredHostRow: View { .stroke(ADEColor.border.opacity(0.18), lineWidth: 0.75) ) } + + private var routeText: String { + let route = host.tailscaleAddress ?? host.addresses.first ?? "No route" + guard let detailPrefix else { return route } + return "\(detailPrefix): \(route)" + } } // MARK: - Scan QR sheet diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index ace2df138..a738789ae 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -29,90 +29,61 @@ extension WorkChatSessionView { case .turnSeparator(let separator): WorkTurnSeparatorView(separator: separator) case .pendingQuestion(let question): - if isLive { - WorkStructuredQuestionCard( - question: question, - busy: actionInFlight, - onSelectOption: { option, freeform in - await runSessionAction { - await onRespondToQuestion( - question.id, - question.questionId, - .string(option.value), - freeform - ) - } - }, - onSubmitAll: { answers, freeform in - await runSessionAction { - await onSubmitQuestionAnswers(question.id, answers, freeform) - } - }, - onDecline: { - await runSessionAction { - await onDeclineQuestion(question.id) - } + // When offline, still render the card in a disabled (busy) state so the + // transcript keeps its full context; the top-right gear icon already + // communicates that the host is unreachable, so an extra "Reconnect to + // respond" banner here would be redundant noise. + WorkStructuredQuestionCard( + question: question, + busy: actionInFlight || !isLive, + onSelectOption: { option, freeform in + await runSessionAction { + await onRespondToQuestion( + question.id, + question.questionId, + .string(option.value), + freeform + ) } - ) - } else { - ADENoticeCard( - title: "Host needs your answer", - message: "Reconnect to respond to this question. The host keeps the session paused until input arrives.", - icon: "questionmark.circle", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - } + }, + onSubmitAll: { answers, freeform in + await runSessionAction { + await onSubmitQuestionAnswers(question.id, answers, freeform) + } + }, + onDecline: { + await runSessionAction { + await onDeclineQuestion(question.id) + } + } + ) case .pendingPermission(let permission): - if isLive { - WorkPermissionCard( - permission: permission, - busy: actionInFlight, - onDecision: { decision in - await runSessionAction { - await onRespondToPermission(permission.id, decision) - } + WorkPermissionCard( + permission: permission, + busy: actionInFlight || !isLive, + onDecision: { decision in + await runSessionAction { + await onRespondToPermission(permission.id, decision) } - ) - } else { - ADENoticeCard( - title: "Permission request waiting", - message: "Reconnect to allow or decline this tool's permission gate.", - icon: "lock.shield", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - } + } + ) case .pendingPlanApproval(let plan): - if isLive { - WorkPlanReviewCard( - plan: plan, - busy: actionInFlight, - onDecision: { decision, feedback in - await runSessionAction { - // Approve: send "accept" decision directly. - // Reject: send "decline"; if the user typed feedback, also - // queue it as a follow-up steer message so the agent sees the - // revision notes in the next turn. - await onApproveRequest(plan.id, decision) - if decision == .decline, let feedback, !feedback.isEmpty { - _ = await onSend(feedback) - } + WorkPlanReviewCard( + plan: plan, + busy: actionInFlight || !isLive, + onDecision: { decision, feedback in + await runSessionAction { + // Approve: send "accept" decision directly. + // Reject: send "decline"; if the user typed feedback, also + // queue it as a follow-up steer message so the agent sees the + // revision notes in the next turn. + await onApproveRequest(plan.id, decision) + if decision == .decline, let feedback, !feedback.isEmpty { + _ = await onSend(feedback) } } - ) - } else { - ADENoticeCard( - title: "Plan approval waiting", - message: "Reconnect to approve or reject the agent's plan.", - icon: "list.bullet.clipboard", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - } + } + ) } } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 0b08186e9..8a580ed45 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -152,56 +152,15 @@ struct WorkChatSessionView: View { @ViewBuilder var sessionOverviewSection: some View { - // Pending-input cards now render inline at their chronological position - // in the timeline via `timelineEntryView`. The overview section only - // surfaces offline banners and chat-level errors. - if !isLive { - ForEach(pendingInputs) { item in - switch item { - case .approval: - ADENoticeCard( - title: "Approval waiting on host", - message: "Reconnect to approve or deny this tool request. Cached transcript data may be slightly behind the desktop.", - icon: "lock.shield", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - case .question: - ADENoticeCard( - title: "Host needs your answer", - message: "Reconnect to respond to this question. The host keeps the session paused until input arrives.", - icon: "questionmark.circle", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - case .permission: - ADENoticeCard( - title: "Permission request waiting", - message: "Reconnect to allow or decline this tool's permission gate.", - icon: "lock.shield", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - case .planApproval: - ADENoticeCard( - title: "Plan approval waiting", - message: "Reconnect to approve or reject the agent's plan.", - icon: "list.bullet.clipboard", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - } - } - } - - // When live, approval_request cards (tool approval gates) still render - // at the top — they are not suppressed from the pendingInputs set, only - // structured questions, permission gates, and plan approvals get their - // inline treatment in the timeline. + // When live, approval_request cards (tool approval gates) render at the + // top — structured questions, permission gates, and plan approvals get + // their inline treatment in the timeline instead. + // + // When offline, we no longer stack "Reconnect to respond" banners here. + // The top-right ADEConnectionDot already signals "Offline" and the + // pending cards themselves stay visible in the timeline in a read-only + // state, so duplicating the reconnect nag at the top added noise + // without new information. if isLive { ForEach(pendingInputs) { item in if case .approval(let approval) = item { @@ -218,7 +177,9 @@ struct WorkChatSessionView: View { } } - if let errorMessage { + // Surface mid-session errors only when the host is actually reachable. + // Connection-caused failures are communicated via the top-right gear. + if let errorMessage, isLive { ADENoticeCard( title: "Chat error", message: errorMessage, diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 80446760d..064a3ed44 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -231,7 +231,13 @@ struct WorkRootScreen: View { .listRowSeparator(.hidden) } } else { - if let hydrationNotice = workStatus.inlineHydrationFailureNotice(for: .work) { + // Per-screen hydration banners are suppressed when the host is + // unreachable — the red gear dot (ADEConnectionDot) is the single + // source of truth for connection state. Genuine mid-sync failures + // while connected still show below via `errorMessage`. + if !syncService.connectionState.isHostUnreachable, + let hydrationNotice = workStatus.inlineHydrationFailureNotice(for: .work) + { ADENoticeCard( title: hydrationNotice.title, message: hydrationNotice.message, @@ -266,7 +272,10 @@ struct WorkRootScreen: View { } } - if let errorMessage, workStatus.phase == .ready { + if let errorMessage, + workStatus.phase == .ready, + !syncService.connectionState.isHostUnreachable + { ADENoticeCard( title: "Work view error", message: errorMessage, diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 2384f84c0..614eef20b 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -131,11 +131,11 @@ final class ADETests: XCTestCase { XCTAssertEqual(state.nextDelayNanoseconds(), 2_000_000_000) } - func testSyncInsecureWebSocketSkipsTailscaleIpsForATS() { - XCTAssertFalse(syncCanAttemptInsecureWebSocket(to: "100.117.237.95")) - XCTAssertFalse(syncCanAttemptInsecureWebSocket(to: "[100.64.0.1]")) - XCTAssertTrue(syncCanAttemptInsecureWebSocket(to: "192.168.68.102")) - XCTAssertTrue(syncCanAttemptInsecureWebSocket(to: "127.0.0.1")) + func testSyncRecognizesTailscaleIpv4Addresses() { + XCTAssertTrue(syncIsTailscaleIPv4Address("100.117.237.95")) + XCTAssertTrue(syncIsTailscaleIPv4Address("[100.64.0.1]")) + XCTAssertFalse(syncIsTailscaleIPv4Address("192.168.68.102")) + XCTAssertFalse(syncIsTailscaleIPv4Address("127.0.0.1")) } func testSyncBonjourTimingMatchesReliabilityRequirements() { From 06fe03a34681bd85918de8972badafe551490720 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:27:34 -0400 Subject: [PATCH 2/4] Polish mobile PR workflow parity --- apps/ios/ADE/Views/PRs/PrDetailScreen.swift | 11 +- apps/ios/ADE/Views/PRs/PrMergeGateCard.swift | 11 +- apps/ios/ADE/Views/PRs/PrWorkflowCards.swift | 110 ++++++++++++++++++- 3 files changed, 125 insertions(+), 7 deletions(-) diff --git a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift index f02186a32..ba570e614 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift @@ -137,6 +137,10 @@ struct PrDetailView: View { snapshot?.detail?.requestedReviewers.count ?? 0 } + private var isCurrentPrDraft: Bool { + currentPr.state == "draft" || snapshot?.status?.state == "draft" || snapshot?.detail?.isDraft == true + } + private var mergeGateInfo: PrMergeGateInfo { prComputeMergeGate( status: snapshot?.status, @@ -144,7 +148,8 @@ struct PrDetailView: View { reviewThreadsUnresolved: unresolvedThreadCount, reviewsNeeded: reviewsNeeded, reviewsHave: reviewsHave, - capabilities: capabilities + capabilities: capabilities, + isDraft: isCurrentPrDraft ) } @@ -154,14 +159,14 @@ struct PrDetailView: View { /// Set of sub-tabs shown in the detail picker. private var visibleTabs: [PrDetailTab] { - [.overview, .checks, .activity, .files, .convergence] + [.overview, .convergence, .files, .checks, .activity] } private func tabTitle(_ tab: PrDetailTab) -> String { switch tab { case .overview: return "Overview" case .checks: return "Checks" - case .activity: return "Reviews" + case .activity: return "Activity" case .files: return "Files" case .convergence: return "Path" } diff --git a/apps/ios/ADE/Views/PRs/PrMergeGateCard.swift b/apps/ios/ADE/Views/PRs/PrMergeGateCard.swift index 51002e06c..a5a48a2d7 100644 --- a/apps/ios/ADE/Views/PRs/PrMergeGateCard.swift +++ b/apps/ios/ADE/Views/PRs/PrMergeGateCard.swift @@ -60,7 +60,8 @@ func prComputeMergeGate( reviewThreadsUnresolved: Int, reviewsNeeded: Int, reviewsHave: Int, - capabilities: PrActionCapabilities? + capabilities: PrActionCapabilities?, + isDraft: Bool = false ) -> PrMergeGateInfo { let failing = checks.filter { check in check.status == "completed" && @@ -81,6 +82,14 @@ func prComputeMergeGate( return "\(have)/\(max(need, have)) approvals" }() + if isDraft { + return PrMergeGateInfo( + tone: .red, + subline: "Draft PRs cannot be merged until marked ready for review.", + target: .overview + ) + } + if conflicts || failing > 0 || hasBlockedReason { var parts: [String] = [] if failing > 0 { diff --git a/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift b/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift index 785135354..2b1797eae 100644 --- a/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift +++ b/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift @@ -1,5 +1,37 @@ import SwiftUI +private enum WorkflowLandingConfirmation { + case activePr(prId: String) + case queueNext(groupId: String) + + var title: String { + switch self { + case .activePr: + return "Land active PR?" + case .queueNext: + return "Land queue next?" + } + } + + var actionTitle: String { + switch self { + case .activePr: + return "Land active PR" + case .queueNext: + return "Land queue next" + } + } + + var message: String { + switch self { + case .activePr: + return "This asks the host to merge the active queue pull request using the selected strategy. GitHub may merge into the target branch if checks pass." + case .queueNext: + return "This asks the host to merge the next queued pull request using the selected strategy. GitHub may merge into the target branch if checks pass." + } + } +} + // MARK: - Legacy cards (unchanged surface, lightly restyled) struct IntegrationWorkflowCard: View { @@ -63,6 +95,16 @@ struct QueueWorkflowCard: View { let onLand: (String, PrMergeMethodOption) -> Void let onRebaseLane: (String) -> Void @State private var mergeMethod: PrMergeMethodOption = .squash + @State private var landingConfirmation: WorkflowLandingConfirmation? + + private var landingConfirmationPresented: Binding { + Binding( + get: { landingConfirmation != nil }, + set: { presented in + if !presented { landingConfirmation = nil } + } + ) + } private var activeEntry: QueueLandingEntry? { if let activePrId = queueState.activePrId, @@ -109,7 +151,7 @@ struct QueueWorkflowCard: View { .adeInsetField() Button("Land active PR") { - onLand(activeEntry.prId, mergeMethod) + landingConfirmation = .activePr(prId: activeEntry.prId) } .buttonStyle(.glassProminent) .tint(ADEColor.accent) @@ -145,6 +187,32 @@ struct QueueWorkflowCard: View { } } .adeGlassCard(cornerRadius: 18) + .confirmationDialog( + landingConfirmation?.title ?? "Land workflow", + isPresented: landingConfirmationPresented, + titleVisibility: .visible + ) { + if let landingConfirmation { + Button(landingConfirmation.actionTitle, role: .destructive) { + performLandingConfirmation(landingConfirmation) + } + } + Button("Cancel", role: .cancel) { + landingConfirmation = nil + } + } message: { + Text(landingConfirmation?.message ?? "This will ask the host to merge the selected pull request.") + } + } + + private func performLandingConfirmation(_ confirmation: WorkflowLandingConfirmation) { + landingConfirmation = nil + switch confirmation { + case .activePr(let prId): + onLand(prId, mergeMethod) + case .queueNext: + break + } } } @@ -177,11 +245,21 @@ struct PrMobileWorkflowCardView: View { let onDismissRebase: (String) -> Void @State private var mergeMethod: PrMergeMethodOption = .squash + @State private var landingConfirmation: WorkflowLandingConfirmation? private var queueId: String? { card.queueId.nonEmpty ?? (card.id.hasPrefix("queue:") ? String(card.id.dropFirst("queue:".count)) : nil) } + private var landingConfirmationPresented: Binding { + Binding( + get: { landingConfirmation != nil }, + set: { presented in + if !presented { landingConfirmation = nil } + } + ) + } + var body: some View { VStack(alignment: .leading, spacing: 14) { switch card.kind { @@ -192,6 +270,22 @@ struct PrMobileWorkflowCardView: View { } } .adeGlassCard(cornerRadius: 18) + .confirmationDialog( + landingConfirmation?.title ?? "Land workflow", + isPresented: landingConfirmationPresented, + titleVisibility: .visible + ) { + if let landingConfirmation { + Button(landingConfirmation.actionTitle, role: .destructive) { + performLandingConfirmation(landingConfirmation) + } + } + Button("Cancel", role: .cancel) { + landingConfirmation = nil + } + } message: { + Text(landingConfirmation?.message ?? "This will ask the host to merge the selected pull request.") + } } // MARK: Queue @@ -256,7 +350,7 @@ struct PrMobileWorkflowCardView: View { HStack(spacing: 10) { Button("Land active PR") { - onLand(activePrId, mergeMethod) + landingConfirmation = .activePr(prId: activePrId) } .buttonStyle(.glassProminent) .tint(ADEColor.accent) @@ -299,7 +393,7 @@ struct PrMobileWorkflowCardView: View { if let groupId = card.groupId { Button { - onLandQueueNext(groupId, mergeMethod) + landingConfirmation = .queueNext(groupId: groupId) } label: { Label("Land queue next", systemImage: "arrow.forward.to.line") .frame(maxWidth: .infinity) @@ -352,6 +446,16 @@ struct PrMobileWorkflowCardView: View { onReorderQueue(groupId, ordered) } + private func performLandingConfirmation(_ confirmation: WorkflowLandingConfirmation) { + landingConfirmation = nil + switch confirmation { + case .activePr(let prId): + onLand(prId, mergeMethod) + case .queueNext(let groupId): + onLandQueueNext(groupId, mergeMethod) + } + } + // MARK: Integration @ViewBuilder From a6c44c27319f461d821c57bc22e0efb940ceeb89 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:39:40 -0400 Subject: [PATCH 3/4] Address PR 169 review feedback - Preserve interrupted work-log status across static/ended transcripts so animate=false no longer masks non-success terminal outcomes. - Scope Claude streaming dedupe keys by message identity (with a turn-local boundary fallback for id-less snapshots) so sequential assistants at the same content index are no longer dropped. - Unpublish Tailscale Serve discovery on host dispose so stale entries don't linger after shutdown. - Gate live-only chat rendering (turnActive, streaming indicator) on !sessionEnded so completed sessions don't keep shimmering. - Remove the dead reasoning startTimestamp/durationLabel path from the chat renderer. - Describe attached lane deletion as "Unlink" in LanesPage status text and split ManageLaneDialog busy state by action kind so archive flows stop borrowing the delete UI. - Disable retry on inactive tailnet discovery state in SyncDevicesSection. - Wrap laneService slow-step git timing in try/finally so failing steps are still logged. - Tighten agentChatService tests to assert post-delta boundaries for Codex reasoning and Claude streamed thinking, plus turn-scoped Cursor startup; harden syncPairingStore createdAt test with a seeded timestamp. - Restore iOS plaintext WebSocket trust boundary so pairing secrets only ride over loopback/LAN/Tailscale routes. - Enforce 44pt minimum hit target on the connected settings chip. - Surface non-connection CTO errors with retries (CtoRootScreen, CtoTeamScreen) and gate "Sync now" on host reachability; parallelise CtoSettingsScreen supplemental reads with async let. - Block draft PRs from "Attempt merge" bypass; always show rebase errors; show offline-specific empty state for PrStackSheet; guard PrWorkflowCards landing confirmations against stale prId/groupId and assert unexpected queueNext in QueueWorkflowCard. - Label saved LAN-only reconnect hosts as "Saved" rather than "Saved Tailscale". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/chat/agentChatService.test.ts | 42 ++++++++---- .../main/services/chat/agentChatService.ts | 60 +++++++++++++---- .../src/main/services/lanes/laneService.ts | 62 ++++++++++-------- .../src/main/services/sync/syncHostService.ts | 61 +++++++++++++++++ .../services/sync/syncPairingStore.test.ts | 13 +++- .../components/chat/AgentChatMessageList.tsx | 24 ++----- .../components/chat/ChatWorkLogBlock.tsx | 6 +- .../renderer/components/lanes/LanesPage.tsx | 52 +++++++++++---- .../lanes/ManageLaneDialog.test.tsx | 1 + .../components/lanes/ManageLaneDialog.tsx | 10 +-- .../settings/SyncDevicesSection.tsx | 2 +- apps/ios/ADE/Services/SyncService.swift | 65 ++++++++++++++++++- .../Views/Components/ADEDesignSystem.swift | 2 +- apps/ios/ADE/Views/Cto/CtoRootScreen.swift | 30 ++++++++- .../ios/ADE/Views/Cto/CtoSettingsScreen.swift | 10 ++- apps/ios/ADE/Views/Cto/CtoTeamScreen.swift | 21 +++++- .../ADE/Views/Cto/CtoWorkflowsScreen.swift | 2 +- apps/ios/ADE/Views/PRs/PrDetailScreen.swift | 1 + apps/ios/ADE/Views/PRs/PrRebaseScreen.swift | 2 +- apps/ios/ADE/Views/PRs/PrStackSheet.swift | 8 ++- apps/ios/ADE/Views/PRs/PrWorkflowCards.swift | 27 ++++++-- .../Settings/SettingsPairingSection.swift | 6 +- 22 files changed, 390 insertions(+), 117 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 07fa5b8a5..0881dd812 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -3644,6 +3644,7 @@ describe("createAgentChatService", () => { && event.event.turnId === "turn-1", ); + const eventsBeforeReasoningDelta = events.length; mockState.emitCodexPayload({ jsonrpc: "2.0", method: "item/reasoning/summaryTextDelta", @@ -3661,19 +3662,26 @@ describe("createAgentChatService", () => { && event.event.itemId === "reasoning-1", ); - expect(events).toEqual(expect.arrayContaining([ + const newEvents = events.slice(eventsBeforeReasoningDelta); + // The reasoning row must be produced by the post-delta boundary, not by + // any earlier turn-start bookkeeping. + expect(newEvents).toEqual(expect.arrayContaining([ expect.objectContaining({ event: expect.objectContaining({ - type: "activity", - activity: "thinking", + type: "reasoning", + text: "Checking the relevant paths.", + itemId: "reasoning-1", turnId: "turn-1", }), }), + ])); + // And somewhere in the turn — coalescing across the initial turn-start + // activity is fine — a thinking activity must be tied to the same turn. + expect(events).toEqual(expect.arrayContaining([ expect.objectContaining({ event: expect.objectContaining({ - type: "reasoning", - text: "Checking the relevant paths.", - itemId: "reasoning-1", + type: "activity", + activity: "thinking", turnId: "turn-1", }), }), @@ -6649,6 +6657,7 @@ describe("createAgentChatService", () => { const setPermissionMode = vi.fn().mockResolvedValue(undefined); const send = vi.fn().mockResolvedValue(undefined); let streamCall = 0; + let reasoningCountAfterDelta = -1; const stream = vi.fn(() => (async function* () { streamCall += 1; @@ -6681,6 +6690,8 @@ describe("createAgentChatService", () => { }, }, }; + await new Promise((resolve) => setTimeout(resolve, 0)); + reasoningCountAfterDelta = events.filter((event) => event.event.type === "reasoning").length; yield { type: "assistant", message: { @@ -6722,6 +6733,9 @@ describe("createAgentChatService", () => { .map((event) => event.event) .filter((event): event is Extract => event.type === "reasoning"); expect(reasoningEvents.map((event) => event.text)).toEqual(["Checking both imports before editing."]); + // The streamed thinking_delta must be what created the reasoning row — not the + // final assistant message (which would also produce a row if dedupe broke). + expect(reasoningCountAfterDelta).toBe(1); expect(events.some((event) => event.event.type === "activity" && event.event.activity === "thinking")).toBe(true); const sessionOpts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { executableArgs?: string[]; @@ -7458,14 +7472,18 @@ describe("createAgentChatService", () => { expect( events.filter((event) => event.event.type === "user_message"), ).toHaveLength(1); + const startedEvent = events.find( + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "status" && event.event.turnStatus === "started", + ); + expect(startedEvent).toBeTruthy(); expect( events.some( - (event) => event.event.type === "status" && event.event.turnStatus === "started", - ), - ).toBe(true); - expect( - events.some( - (event) => event.event.type === "activity" && event.event.activity === "thinking", + (event) => + event.event.type === "activity" + && event.event.activity === "thinking" + && event.event.turnId === startedEvent!.event.turnId, ), ).toBe(true); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 6703b1fea..b8868adbb 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -6324,8 +6324,27 @@ export function createAgentChatService(args: { let firstStreamEventLogged = false; const emittedClaudeToolIds = new Set(); const emittedSyntheticItemIds = new Set(); - const streamedClaudeTextContentIndexes = new Set(); - const streamedClaudeThinkingContentIndexes = new Set(); + const streamedClaudeTextContentKeys = new Set(); + const streamedClaudeThinkingContentKeys = new Set(); + let currentClaudeStreamMessageId: string | null = null; + // Track a running boundary for assistant messages whose snapshot has no id + // (and whose stream preamble didn't carry a `message_start` id either — real + // Claude streams always do, but mocks and older SDK paths don't). Each new + // id-less assistant snapshot bumps the boundary so sequential assistants in + // the same turn don't collide at the same content index. + let claudeAssistantBoundary = 0; + let claudeAssistantBoundarySealed = false; + const claudeDedupeKey = ( + messageId: string | null | undefined, + contentIndex: number | null | undefined, + ): string | null => { + if (typeof contentIndex !== "number" || !Number.isFinite(contentIndex)) return null; + const trimmed = messageId?.trim(); + const id = trimmed && trimmed.length + ? trimmed + : `${turnId}:b${claudeAssistantBoundary}`; + return `${id}:${contentIndex}`; + }; const openClaudeToolUses = new Map(); const toolInputJsonByContentIndex = new Map(); const toolUseMetaByContentIndex = new Map(); @@ -6743,22 +6762,35 @@ export function createAgentChatService(args: { if (msg.type === "assistant") { const assistantMsg = msg as any; const betaMessage = assistantMsg.message; + const assistantMessageId = typeof betaMessage?.id === "string" ? betaMessage.id : null; + // If the snapshot has no id, advance the id-less boundary once the + // prior snapshot is sealed — so two back-to-back id-less assistants + // don't alias to the same key. While the stream preamble is actively + // filling in the current boundary (via content_block_delta), we keep + // the boundary intact so the snapshot still dedupes against deltas. + if (!assistantMessageId && !currentClaudeStreamMessageId && claudeAssistantBoundarySealed) { + claudeAssistantBoundary += 1; + claudeAssistantBoundarySealed = false; + } reportedAssistantModel = normalizeReportedModelName(betaMessage?.model) ?? reportedAssistantModel; if (betaMessage?.content && Array.isArray(betaMessage.content)) { for (const [blockIndex, block] of betaMessage.content.entries()) { if (block.type === "text") { - if (!streamedClaudeTextContentIndexes.has(blockIndex)) { + const textKey = claudeDedupeKey(assistantMessageId, blockIndex); + if (!textKey || !streamedClaudeTextContentKeys.has(textKey)) { assistantText += block.text ?? ""; emitChatEvent(managed, { type: "text", text: block.text ?? "", turnId, }); + if (textKey) streamedClaudeTextContentKeys.add(textKey); } } else if (block.type === "thinking") { const thinkingText = block.thinking ?? block.text ?? ""; const reasoningItemId = buildClaudeContentItemId("thinking", blockIndex); - if (thinkingText.trim().length > 0 && !streamedClaudeThinkingContentIndexes.has(blockIndex)) { + const thinkingKey = claudeDedupeKey(assistantMessageId, blockIndex); + if (thinkingText.trim().length > 0 && (!thinkingKey || !streamedClaudeThinkingContentKeys.has(thinkingKey))) { emitChatEvent(managed, { type: "activity", activity: "thinking", @@ -6771,6 +6803,7 @@ export function createAgentChatService(args: { ...(reasoningItemId ? { itemId: reasoningItemId } : {}), turnId, }); + if (thinkingKey) streamedClaudeThinkingContentKeys.add(thinkingKey); } } else if (block.type === "tool_use") { const toolName = String(block.name ?? "tool"); @@ -6815,6 +6848,9 @@ export function createAgentChatService(args: { outputTokens: betaMessage.usage.output_tokens ?? null, }; } + // Snapshot consumed — the next id-less assistant should get a fresh + // boundary so it doesn't collide on the same turn-scoped key. + claudeAssistantBoundarySealed = true; continue; } @@ -6830,18 +6866,16 @@ export function createAgentChatService(args: { if (delta?.type === "text_delta") { const text = delta.text ?? ""; if (text.length) { - if (typeof contentIndex === "number") { - streamedClaudeTextContentIndexes.add(contentIndex); - } + const textKey = claudeDedupeKey(currentClaudeStreamMessageId, contentIndex); + if (textKey) streamedClaudeTextContentKeys.add(textKey); assistantText += text; emitChatEvent(managed, { type: "text", text, turnId }); } } else if (delta?.type === "thinking_delta") { const text = delta.thinking ?? delta.text ?? ""; if (text.length) { - if (typeof contentIndex === "number") { - streamedClaudeThinkingContentIndexes.add(contentIndex); - } + const thinkingKey = claudeDedupeKey(currentClaudeStreamMessageId, contentIndex); + if (thinkingKey) streamedClaudeThinkingContentKeys.add(thinkingKey); const reasoningItemId = buildClaudeContentItemId("thinking", contentIndex); emitChatEvent(managed, { type: "activity", @@ -6888,9 +6922,8 @@ export function createAgentChatService(args: { // Some SDK versions include initial thinking text on block start const startText = block.thinking ?? block.text ?? ""; if (startText.length) { - if (typeof contentIndex === "number") { - streamedClaudeThinkingContentIndexes.add(contentIndex); - } + const thinkingKey = claudeDedupeKey(currentClaudeStreamMessageId, contentIndex); + if (thinkingKey) streamedClaudeThinkingContentKeys.add(thinkingKey); emitChatEvent(managed, { type: "reasoning", text: startText, @@ -6965,6 +6998,7 @@ export function createAgentChatService(args: { } } } else if (event.type === "message_start") { + currentClaudeStreamMessageId = typeof event.message?.id === "string" ? event.message.id : null; const msgUsage = event.message?.usage; if (msgUsage) { usage = { diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 335e9dfc5..cdfd88e60 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -2443,6 +2443,14 @@ export function createLaneService({ if (durationMs < 500) return; logger.info("lane.delete.step", { laneId, step, durationMs }); }; + const timeSlowDeleteStep = async (step: string, work: () => Promise): Promise => { + const stepStartedAt = Date.now(); + try { + return await work(); + } finally { + logSlowDeleteStep(step, stepStartedAt); + } + }; const row = getLaneRow(laneId); if (!row) throw new Error(`Lane not found: ${laneId}`); if (row.lane_type === "primary") { @@ -2455,9 +2463,9 @@ export function createLaneService({ } if (row.lane_type === "worktree" && row.worktree_path && fs.existsSync(row.worktree_path)) { - let stepStartedAt = Date.now(); - const dirtyRes = await runGit(["status", "--porcelain=v1"], { cwd: row.worktree_path, timeoutMs: 8_000 }); - logSlowDeleteStep("git_status", stepStartedAt); + const dirtyRes = await timeSlowDeleteStep("git_status", () => + runGit(["status", "--porcelain=v1"], { cwd: row.worktree_path!, timeoutMs: 8_000 }), + ); const dirty = dirtyRes.exitCode === 0 && dirtyRes.stdout.trim().length > 0; if (dirty && !force) { throw new Error("Lane has uncommitted changes. Enable force delete after confirming warnings."); @@ -2466,43 +2474,43 @@ export function createLaneService({ const removeArgs = ["worktree", "remove"]; if (force) removeArgs.push("--force"); removeArgs.push(row.worktree_path); - stepStartedAt = Date.now(); - await runGitOrThrow(removeArgs, { cwd: projectRoot, timeoutMs: 60_000 }); - logSlowDeleteStep("git_worktree_remove", stepStartedAt); + await timeSlowDeleteStep("git_worktree_remove", () => + runGitOrThrow(removeArgs, { cwd: projectRoot, timeoutMs: 60_000 }), + ); } if (deleteBranch && row.branch_ref) { - let stepStartedAt = Date.now(); - const refCheck = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${row.branch_ref}`], { - cwd: projectRoot, - timeoutMs: 8_000 - }); - logSlowDeleteStep("git_branch_ref_check", stepStartedAt); + const refCheck = await timeSlowDeleteStep("git_branch_ref_check", () => + runGit(["show-ref", "--verify", "--quiet", `refs/heads/${row.branch_ref}`], { + cwd: projectRoot, + timeoutMs: 8_000, + }), + ); if (refCheck.exitCode === 0) { - stepStartedAt = Date.now(); - await runGitOrThrow(["branch", "-D", row.branch_ref], { cwd: projectRoot, timeoutMs: 30_000 }); - logSlowDeleteStep("git_branch_delete", stepStartedAt); + await timeSlowDeleteStep("git_branch_delete", () => + runGitOrThrow(["branch", "-D", row.branch_ref!], { cwd: projectRoot, timeoutMs: 30_000 }), + ); } } if (deleteRemoteBranch && row.branch_ref) { const remote = remoteName.trim() || "origin"; - let stepStartedAt = Date.now(); - const remoteCheck = await runGit(["remote", "get-url", remote], { cwd: projectRoot, timeoutMs: 8_000 }); - logSlowDeleteStep("git_remote_check", stepStartedAt); + const remoteCheck = await timeSlowDeleteStep("git_remote_check", () => + runGit(["remote", "get-url", remote], { cwd: projectRoot, timeoutMs: 8_000 }), + ); if (remoteCheck.exitCode !== 0) { throw new Error(`Remote '${remote}' is not configured for this repository`); } - stepStartedAt = Date.now(); - const remoteRefCheck = await runGit(["ls-remote", "--heads", remote, row.branch_ref], { - cwd: projectRoot, - timeoutMs: 12_000 - }); - logSlowDeleteStep("git_remote_ref_check", stepStartedAt); + const remoteRefCheck = await timeSlowDeleteStep("git_remote_ref_check", () => + runGit(["ls-remote", "--heads", remote, row.branch_ref!], { + cwd: projectRoot, + timeoutMs: 12_000, + }), + ); if (remoteRefCheck.exitCode === 0 && remoteRefCheck.stdout.trim().length > 0) { - stepStartedAt = Date.now(); - await runGitOrThrow(["push", remote, "--delete", row.branch_ref], { cwd: projectRoot, timeoutMs: 45_000 }); - logSlowDeleteStep("git_remote_branch_delete", stepStartedAt); + await timeSlowDeleteStep("git_remote_branch_delete", () => + runGitOrThrow(["push", remote, "--delete", row.branch_ref!], { cwd: projectRoot, timeoutMs: 45_000 }), + ); } } diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index 38d6b6f16..cd520cb05 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -972,6 +972,62 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); }; + const unpublishTailnetDiscovery = async (): Promise => { + if (!tailnetServeSignature) return; + tailnetServeSignature = null; + if (!shouldAttemptTailnetServiceAdvertise()) { + updateTailnetDiscoveryStatus({ + state: "unavailable", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: null, + stderr: null, + }); + return; + } + const cli = resolveTailscaleCli(); + try { + await execFileAsync( + cli, + ["serve", "--yes", `--service=${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}`, "off"], + { timeout: 10_000 }, + ); + updateTailnetDiscoveryStatus({ + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: null, + stderr: null, + }); + args.logger.info("sync_host.tailnet_discovery_unpublished", { + service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + }); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? null; + updateTailnetDiscoveryStatus({ + state: code === "ENOENT" ? "unavailable" : "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: code === "ENOENT" ? "Tailscale CLI was not found." : errorMessage, + stderr: null, + }); + args.logger.warn("sync_host.tailnet_discovery_unpublish_failed", { + service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + error: errorMessage, + code, + }); + } + }; + function send(ws: WebSocket, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): void { ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes })); } @@ -2003,6 +2059,11 @@ export function createSyncHostService(args: SyncHostServiceArgs) { clearInterval(pollTimer); clearInterval(heartbeatTimer); clearInterval(brainStatusTimer); + try { + await unpublishTailnetDiscovery(); + } catch { + // Never throw from dispose. + } await new Promise((resolve) => { const finish = () => resolve(); for (const peer of peers) { diff --git a/apps/desktop/src/main/services/sync/syncPairingStore.test.ts b/apps/desktop/src/main/services/sync/syncPairingStore.test.ts index f038a38e7..b4fd6e6e4 100644 --- a/apps/desktop/src/main/services/sync/syncPairingStore.test.ts +++ b/apps/desktop/src/main/services/sync/syncPairingStore.test.ts @@ -96,14 +96,21 @@ describe("syncPairingStore", () => { pinStore.setPin("424242"); store.pairPeer(samplePeer, "424242"); - const before = JSON.parse(fs.readFileSync(pairingFile, "utf8")); - const createdAt = before[samplePeer.deviceId].createdAt; + + // Seed a deterministic, distinctly-past createdAt so the test fails + // reliably if the production path overwrites it on re-pairing (the + // nowIso() clock can otherwise produce the same millisecond for both + // pairPeer calls, masking the regression). + const seededCreatedAt = "2020-01-01T00:00:00.000Z"; + const seeded = JSON.parse(fs.readFileSync(pairingFile, "utf8")); + seeded[samplePeer.deviceId].createdAt = seededCreatedAt; + fs.writeFileSync(pairingFile, JSON.stringify(seeded)); store.pairPeer({ ...samplePeer, deviceName: "Arul's iPhone" }, "424242"); const after = JSON.parse(fs.readFileSync(pairingFile, "utf8")); expect(Object.keys(after)).toEqual([samplePeer.deviceId]); - expect(after[samplePeer.deviceId].createdAt).toBe(createdAt); + expect(after[samplePeer.deviceId].createdAt).toBe(seededCreatedAt); expect(after[samplePeer.deviceId].peerName).toBe("Arul's iPhone"); }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index af555b7cf..3951be7cb 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -1930,13 +1930,6 @@ function renderEvent( const isLive = Boolean(options?.turnActive); const reasoningPreview = summarizeInlineText(reasoningText, 108); - const startTimestamp = typeof (event as any).startTimestamp === "string" ? (event as any).startTimestamp : null; - const durationMs = startTimestamp - ? new Date(envelope.timestamp).getTime() - new Date(startTimestamp).getTime() - : null; - const durationLabel = !isLive && durationMs != null && Number.isFinite(durationMs) && durationMs > 0 - ? `${Math.max(1, Math.round(durationMs / 1000))}s` - : null; return ( ) : ( - <> - Thought - {durationLabel ? ( - - {durationLabel} - - ) : null} - + Thought )}
} @@ -3268,6 +3254,8 @@ export function AgentChatMessageList({ : turnModelState.lastModel; const isLatestWorkLog = index === latestWorkLogIndex; + const rowTurnActive = Boolean(currentTurn && activeTurnId && currentTurn === activeTurnId) && !sessionEnded; + if (virtualized) { return ( { if (entries.some((e) => e.status === "failed")) return "failed"; + if (entries.some((e) => e.status === "interrupted")) return "waiting"; if (!animate) return "completed"; if (entries.some((e) => e.status === "running")) return "working"; - if (entries.some((e) => e.status === "interrupted")) return "waiting"; return "completed"; }, [entries, animate]); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index f534b09b9..dbdf41677 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -222,6 +222,7 @@ export function LanesPage() { const [laneActionBusy, setLaneActionBusy] = useState(false); const [laneActionStatus, setLaneActionStatus] = useState(null); const [laneActionError, setLaneActionError] = useState(null); + const [laneActionKind, setLaneActionKind] = useState<"delete" | "archive" | "adopt" | null>(null); const [managedLaneIds, setManagedLaneIds] = useState([]); const [conflictChipsByLane, setConflictChipsByLane] = useState>({}); const chipTimersRef = useRef>(new Map()); @@ -781,8 +782,13 @@ export function LanesPage() { return primaryBranches.filter((branch) => branch.isRemote && (!q || branch.name.toLowerCase().includes(q))); }, [primaryBranches, branchSearchQuery]); - const runLaneAction = async (fn: () => Promise, status: string) => { + const runLaneAction = async ( + fn: () => Promise, + status: string, + kind: "delete" | "archive" | "adopt" = "delete", + ) => { setLaneActionBusy(true); + setLaneActionKind(kind); setLaneActionStatus(status); setLaneActionError(null); try { @@ -794,6 +800,7 @@ export function LanesPage() { } finally { setLaneActionBusy(false); setLaneActionStatus(null); + setLaneActionKind(null); } }; @@ -871,7 +878,7 @@ export function LanesPage() { for (const lane of actionable) { await window.ade.lanes.archive({ laneId: lane.id }); } - }, actionable.length > 1 ? `Archiving ${actionable.length} lanes...` : "Archiving lane..."); + }, actionable.length > 1 ? `Archiving ${actionable.length} lanes...` : "Archiving lane...", "archive"); }; const deleteManagedLanes = async () => { @@ -879,18 +886,34 @@ export function LanesPage() { const actionable = targets.filter((l) => l.laneType !== "primary"); if (actionable.length === 0) return; if (deleteConfirmText.trim().toLowerCase() !== deletePhrase.toLowerCase()) return; - const deleteStatus = - deleteMode === "remote_branch" - ? actionable.length > 1 - ? `Deleting ${actionable.length} lane worktrees, local branches, and remote branches...` - : "Deleting lane worktree, local branch, and remote branch..." - : deleteMode === "local_branch" - ? actionable.length > 1 - ? `Deleting ${actionable.length} lane worktrees and local branches...` - : "Deleting lane worktree and local branch..." - : actionable.length > 1 - ? `Deleting ${actionable.length} lane worktrees...` - : "Deleting lane worktree..."; + const attachedCount = actionable.filter((l) => l.laneType === "attached").length; + const managedCount = actionable.length - attachedCount; + const deleteStatus = (() => { + if (managedCount === 0 && attachedCount > 0) { + return attachedCount > 1 + ? `Unlinking ${attachedCount} attached lanes...` + : "Unlinking attached lane..."; + } + const managedPhrase = + deleteMode === "remote_branch" + ? managedCount > 1 + ? `${managedCount} lane worktrees, local branches, and remote branches` + : "lane worktree, local branch, and remote branch" + : deleteMode === "local_branch" + ? managedCount > 1 + ? `${managedCount} lane worktrees and local branches` + : "lane worktree and local branch" + : managedCount > 1 + ? `${managedCount} lane worktrees` + : "lane worktree"; + if (attachedCount === 0) { + return `Deleting ${managedPhrase}...`; + } + const attachedPhrase = attachedCount > 1 + ? `${attachedCount} attached lanes` + : "attached lane"; + return `Unlinking ${attachedPhrase} and deleting ${managedPhrase}...`; + })(); await runLaneAction(async () => { const errors: string[] = []; for (const lane of actionable) { @@ -2242,6 +2265,7 @@ export function LanesPage() { laneActionBusy={laneActionBusy} laneActionStatus={laneActionStatus} laneActionError={laneActionError} + laneActionKind={laneActionKind} onAdoptAttached={() => { if (!managedLane || managedLane.laneType !== "attached") return; reopenAdoptHint(); diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx index 49eb05b5a..d599ccaeb 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx @@ -70,6 +70,7 @@ describe("ManageLaneDialog", () => { , ); diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx index de2d2a03f..17f391657 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx @@ -21,6 +21,7 @@ export function ManageLaneDialog({ laneActionBusy, laneActionStatus, laneActionError, + laneActionKind, onAdoptAttached, onArchive, onDelete @@ -41,6 +42,7 @@ export function ManageLaneDialog({ laneActionBusy: boolean; laneActionStatus: string | null; laneActionError: string | null; + laneActionKind?: "delete" | "archive" | "adopt" | null; onAdoptAttached: () => void; onArchive: () => void; onDelete: () => void; @@ -227,7 +229,7 @@ export function ManageLaneDialog({ />
- {laneActionBusy && ( + {laneActionBusy && (laneActionKind === "delete" || laneActionKind == null) && (
{laneActionStatus ?? "Working..."} @@ -235,7 +237,7 @@ export function ManageLaneDialog({ )} {/* Error */} - {laneActionError && ( + {laneActionError && (laneActionKind === "delete" || laneActionKind == null) && (
{laneActionError} @@ -249,8 +251,8 @@ export function ManageLaneDialog({ disabled={laneActionBusy || !confirmMatch} onClick={onDelete} > - {laneActionBusy ? : } - {laneActionBusy + {laneActionBusy && laneActionKind === "delete" ? : } + {laneActionBusy && laneActionKind === "delete" ? "Deleting..." : isBatch ? `Delete ${lanes.length} lanes` diff --git a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx index b86ab30e5..f55f0d2d7 100644 --- a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx @@ -395,7 +395,7 @@ function tailnetStatusCopy(status: SyncTailnetDiscoveryStatus, isLocalHost: bool color: COLORS.textMuted, title: isLocalHost ? `Not published as ${host}` : "Only the host desktop publishes tailnet discovery", detail: status.error || "Start phone sync hosting to publish tailnet discovery.", - canRetry: isLocalHost, + canRetry: false, }; } } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 3eebab979..e0d12ca26 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -2694,7 +2694,70 @@ final class SyncService: ObservableObject { } private func connectableAddresses(from addresses: [String]) -> [String] { - addresses + // The pairing secret is sent over ws:// (plaintext) immediately after + // `openSocket`, so only allow addresses we can trust on an unencrypted + // transport — loopback, RFC1918 LAN ranges, link-local, and Tailscale CGNAT. + addresses.filter { syncCanAttemptPlaintextWebSocket($0) } + } + + private func syncCanAttemptPlaintextWebSocket(_ address: String) -> Bool { + let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return false } + + // Strip a leading scheme so raw hosts like "192.168.1.10:7878" and + // "ws://192.168.1.10:7878" both flow through the same path. + var host = trimmed + if let schemeRange = host.range(of: "://") { + let scheme = host[.. NSError { diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 22fd6d30d..860d68d0d 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -544,7 +544,7 @@ struct ADEConnectionDot: View { } } .animation(ADEMotion.emphasis(reduceMotion: reduceMotion), value: attachedLabel) - .frame(minHeight: 44) + .frame(minWidth: 44, minHeight: 44, alignment: .leading) .contentShape(Rectangle()) .onTapGesture { syncService.settingsPresented = true diff --git a/apps/ios/ADE/Views/Cto/CtoRootScreen.swift b/apps/ios/ADE/Views/Cto/CtoRootScreen.swift index 7ba12ba28..e34670e4f 100644 --- a/apps/ios/ADE/Views/Cto/CtoRootScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoRootScreen.swift @@ -13,12 +13,26 @@ struct CtoRootScreen: View { @State private var path = NavigationPath() @State private var snapshot: CtoSnapshot? @State private var isLoadingSnapshot = false + @State private var snapshotLoadError: String? var body: some View { NavigationStack(path: $path) { VStack(spacing: 0) { CtoTabShell(active: $selectedTab) + if let snapshotLoadError, !syncService.connectionState.isHostUnreachable { + ADENoticeCard( + title: "Couldn't load CTO state", + message: snapshotLoadError, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.warning, + actionTitle: "Retry", + action: { Task { await loadSnapshot() } } + ) + .padding(.horizontal, 20) + .padding(.top, 8) + } + tabBody .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -74,9 +88,19 @@ struct CtoRootScreen: View { if isLoadingSnapshot { return } isLoadingSnapshot = true defer { isLoadingSnapshot = false } - // Non-connection errors here are silently ignored — the global connection - // banner (top-right gear) owns user-facing failure messaging. - snapshot = (try? await syncService.fetchCtoState()) ?? snapshot + do { + snapshot = try await syncService.fetchCtoState() + snapshotLoadError = nil + } catch { + // Connection failures are owned by the top-right gear dot. For anything + // else (command error, parse error, timeouts while connected) surface the + // message so the user has a retry/diagnostic path instead of stale state. + if syncService.connectionState.isHostUnreachable { + snapshotLoadError = nil + } else { + snapshotLoadError = (error as NSError).localizedDescription + } + } } private var ctoLiveReloadKey: String? { diff --git a/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift b/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift index d4e62b8f1..c4bd8a438 100644 --- a/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift @@ -341,18 +341,22 @@ struct CtoSettingsScreen: View { errorMessage = nil defer { isLoading = false } + async let snapshotTask = syncService.fetchCtoState() + async let budgetTask = syncService.fetchCtoBudget() + async let linearTask = syncService.fetchLinearConnectionStatus() + do { - self.snapshot = try await syncService.fetchCtoState() + self.snapshot = try await snapshotTask } catch { if self.snapshot == nil { self.errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) } } - if let value = try? await syncService.fetchCtoBudget() { + if let value = try? await budgetTask { self.budget = value } - if let value = try? await syncService.fetchLinearConnectionStatus() { + if let value = try? await linearTask { self.linearStatus = value } } diff --git a/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift b/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift index 2ec80d3a1..bade4d6f1 100644 --- a/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift @@ -10,6 +10,7 @@ struct CtoTeamScreen: View { @State private var agents: [AgentIdentity] = [] @State private var budget: AgentBudgetSnapshot? @State private var isLoading = false + @State private var loadError: String? @State private var showHireSheet = false @State private var pendingWakeup: Set = [] @@ -37,6 +38,16 @@ struct CtoTeamScreen: View { } } .padding(.horizontal, 16) + } else if agents.isEmpty && loadError != nil { + ADENoticeCard( + title: "Couldn't load workers", + message: loadError ?? "Unknown error.", + icon: "exclamationmark.triangle.fill", + tint: ADEColor.warning, + actionTitle: "Retry", + action: { Task { await load() } } + ) + .padding(.horizontal, 16) } else if agents.isEmpty { ADEEmptyStateView( symbol: "person.crop.circle.badge.questionmark", @@ -285,8 +296,16 @@ struct CtoTeamScreen: View { async let budgetR = CtoTeamAsyncResult { try await syncService.fetchCtoBudget() } let (agentsResult, budgetResult) = await (agentsR, budgetR) - if case .success(let fetched) = agentsResult { + switch agentsResult { + case .success(let fetched): agents = fetched + loadError = nil + case .failure(let error): + if syncService.connectionState.isHostUnreachable { + loadError = nil + } else { + loadError = (error as NSError).localizedDescription + } } if case .success(let snap) = budgetResult { budget = snap diff --git a/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift b/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift index 3a9ff9f0a..3b65a4d28 100644 --- a/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift @@ -104,7 +104,7 @@ struct CtoWorkflowsScreen: View { ) } .buttonStyle(.plain) - .disabled(isSyncing) + .disabled(isSyncing || syncService.connectionState.isHostUnreachable) .accessibilityLabel("Sync Linear now") .accessibilityHint("Triggers an immediate Linear workflow intake and dispatch cycle.") } diff --git a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift index ba570e614..301f9d1ff 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift @@ -74,6 +74,7 @@ struct PrDetailView: View { private var canAttemptBlockedMerge: Bool { guard canRunPrActions else { return false } + guard !isCurrentPrDraft else { return false } guard let status = snapshot?.status else { return false } let state = status.state.isEmpty ? currentPr.state : status.state return state == "open" && !status.isMergeable && !status.mergeConflicts diff --git a/apps/ios/ADE/Views/PRs/PrRebaseScreen.swift b/apps/ios/ADE/Views/PRs/PrRebaseScreen.swift index 708ebccf5..569500a7d 100644 --- a/apps/ios/ADE/Views/PRs/PrRebaseScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrRebaseScreen.swift @@ -85,7 +85,7 @@ struct PrRebaseScreen: View { conflictCard .padding(.horizontal, 16) - if let errorMessage, !syncService.connectionState.isHostUnreachable { + if let errorMessage { ADENoticeCard( title: "Rebase failed", message: errorMessage, diff --git a/apps/ios/ADE/Views/PRs/PrStackSheet.swift b/apps/ios/ADE/Views/PRs/PrStackSheet.swift index b703a7a89..9a25c0f7f 100644 --- a/apps/ios/ADE/Views/PRs/PrStackSheet.swift +++ b/apps/ios/ADE/Views/PRs/PrStackSheet.swift @@ -44,9 +44,11 @@ struct PrStackSheet: View { } else if stackRows.isEmpty { ScrollView { ADEEmptyStateView( - symbol: "list.number", - title: "No stack members", - message: "The host did not sync any PR chain members for this workflow yet." + symbol: syncService.connectionState.isHostUnreachable ? "wifi.exclamationmark" : "list.number", + title: syncService.connectionState.isHostUnreachable ? "Offline" : "No stack members", + message: syncService.connectionState.isHostUnreachable + ? "Reconnect to the desktop host to load this PR stack." + : "The host did not sync any PR chain members for this workflow yet." ) .padding(16) } diff --git a/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift b/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift index 2b1797eae..6deb1a6eb 100644 --- a/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift +++ b/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift @@ -208,10 +208,17 @@ struct QueueWorkflowCard: View { private func performLandingConfirmation(_ confirmation: WorkflowLandingConfirmation) { landingConfirmation = nil switch confirmation { - case .activePr(let prId): - onLand(prId, mergeMethod) + case .activePr(let capturedPrId): + // Guard against a stale snapshot: the active PR may have changed while + // the confirmation dialog was visible. Only dispatch if the card still + // points at the same PR the user approved. + guard let currentActivePrId = activeEntry?.prId, + currentActivePrId == capturedPrId else { + return + } + onLand(capturedPrId, mergeMethod) case .queueNext: - break + assertionFailure("QueueWorkflowCard does not support queueNext landing") } } } @@ -449,10 +456,16 @@ struct PrMobileWorkflowCardView: View { private func performLandingConfirmation(_ confirmation: WorkflowLandingConfirmation) { landingConfirmation = nil switch confirmation { - case .activePr(let prId): - onLand(prId, mergeMethod) - case .queueNext(let groupId): - onLandQueueNext(groupId, mergeMethod) + case .activePr(let capturedPrId): + // If the active PR changed while the dialog was open, don't act on a + // stale id — the user approved a different PR than the one now active. + guard let currentActivePrId = card.activePrId, currentActivePrId == capturedPrId else { + return + } + onLand(capturedPrId, mergeMethod) + case .queueNext(let capturedGroupId): + guard card.groupId == capturedGroupId else { return } + onLandQueueNext(capturedGroupId, mergeMethod) } } diff --git a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift index e8e7ad149..acd343f37 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift @@ -246,7 +246,11 @@ struct DiscoverHostsSheet: View { dismiss() Task { await syncService.reconnectIfPossible(userInitiated: true) } } label: { - DiscoveredHostRow(host: savedHost, detailPrefix: "Saved Tailscale", accessoryText: "Reconnect") + DiscoveredHostRow( + host: savedHost, + detailPrefix: savedHost.tailscaleAddress == nil ? "Saved" : "Saved Tailscale", + accessoryText: "Reconnect" + ) } .buttonStyle(ADEScaleButtonStyle()) } From d74e019d934a9024f9c422b3c18cba859df0b833 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:00:19 -0400 Subject: [PATCH 4/4] Fix Capy review feedback --- .../components/chat/ChatWorkLogBlock.tsx | 19 +++-- .../components/chat/chatStatusVisuals.tsx | 8 +- .../lanes/ManageLaneDialog.test.tsx | 14 ++++ .../components/lanes/ManageLaneDialog.tsx | 9 ++- apps/ios/ADE/Services/SyncService.swift | 21 +++++- apps/ios/ADE/Views/Cto/CtoTeamScreen.swift | 74 ++++++++++++++++--- apps/ios/ADE/Views/PRs/PrStackSheet.swift | 21 +++++- apps/ios/ADE/Views/PRs/PrWorkflowCards.swift | 23 ++++-- .../ADE/Views/Work/WorkChatSessionView.swift | 7 +- 9 files changed, 157 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx b/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx index 541d5603e..b3d068407 100644 --- a/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx @@ -134,16 +134,16 @@ function workToneIcon(entry: ChatWorkLogEntry): { icon: Icon; className: string return { icon: Warning, className: "text-fg/34" }; } -function workStatusState(status: ChatWorkLogEntry["status"], animate = true): ChatStatusVisualState { +function workStatusState(status: ChatWorkLogEntry["status"]): ChatStatusVisualState { if (status === "completed" || status === "failed") return status; if (status === "interrupted") return "waiting"; - return animate ? "working" : "completed"; + return "working"; } -function workStatusLabel(status: ChatWorkLogEntry["status"], animate = true): string { +function workStatusLabel(status: ChatWorkLogEntry["status"]): string { if (status === "completed" || status === "failed") return status; if (status === "interrupted") return "interrupted"; - return animate ? "running" : "completed"; + return "running"; } function workEntryHeading(entry: ChatWorkLogEntry): string { @@ -479,10 +479,9 @@ export function ChatWorkLogBlock({ const groupStatus: ChatStatusVisualState = useMemo(() => { if (entries.some((e) => e.status === "failed")) return "failed"; if (entries.some((e) => e.status === "interrupted")) return "waiting"; - if (!animate) return "completed"; if (entries.some((e) => e.status === "running")) return "working"; return "completed"; - }, [entries, animate]); + }, [entries]); const groupStatusLabel = useMemo(() => { if (groupStatus === "failed") return "failed"; @@ -521,7 +520,7 @@ export function ChatWorkLogBlock({ onClick={() => setExpanded((prev) => !prev)} > - + {latestIsMemory ? ( @@ -565,8 +564,8 @@ export function ChatWorkLogBlock({ const isEntryExpanded = expandedEntries[entry.id] ?? (hasSuggestions || entry.status === "failed"); const heading = replaceInternalToolNames(workEntryHeading(entry)); const preview = workEntryPreview(entry); - const statusLabel = workStatusLabel(entry.status, animate); - const entryStatusState = workStatusState(entry.status, animate); + const statusLabel = workStatusLabel(entry.status); + const entryStatusState = workStatusState(entry.status); return (
@@ -581,7 +580,7 @@ export function ChatWorkLogBlock({ )} - + {entryIsMemory ? ( diff --git a/apps/desktop/src/renderer/components/chat/chatStatusVisuals.tsx b/apps/desktop/src/renderer/components/chat/chatStatusVisuals.tsx index a6372940e..685c35d5f 100644 --- a/apps/desktop/src/renderer/components/chat/chatStatusVisuals.tsx +++ b/apps/desktop/src/renderer/components/chat/chatStatusVisuals.tsx @@ -28,10 +28,12 @@ export function ChatStatusGlyph({ status, size = 12, className, + animate = true, }: { status: ChatStatusVisualState; size?: number; className?: string; + animate?: boolean; }) { switch (status) { case "completed": @@ -51,8 +53,10 @@ export function ChatStatusGlyph({ case "working": return ( - - + {animate ? ( + + ) : null} + ); } diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx index d599ccaeb..c58a80dad 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx @@ -82,6 +82,20 @@ describe("ManageLaneDialog", () => { expect((screen.getByRole("checkbox", { name: /Force delete/i }) as HTMLInputElement).disabled).toBe(true); }); + it("shows concrete archive progress without turning the delete button into deleting state", () => { + render( + , + ); + + expect(screen.getByRole("status").textContent).toContain("Archiving lane..."); + expect(screen.queryByRole("button", { name: /Deleting/i })).toBeNull(); + }); + it("keeps the delete button actionable after confirmation when idle", () => { render(
- {laneActionBusy && (laneActionKind === "delete" || laneActionKind == null) && ( + {laneActionBusy && (laneActionKind === "delete" || laneActionKind === "archive" || laneActionKind == null) && (
- + {laneActionStatus ?? "Working..."}
)} {/* Error */} - {laneActionError && (laneActionKind === "delete" || laneActionKind == null) && ( + {laneActionError && (laneActionKind === "delete" || laneActionKind === "archive" || laneActionKind == null) && (
{laneActionError} diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index e0d12ca26..bc6a9db10 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -196,6 +196,12 @@ enum SyncTailnetDiscovery { ] } +func syncIsTailnetDiscoveryHost(_ host: String) -> Bool { + SyncTailnetDiscovery.hostCandidates.contains( + host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + ) +} + func syncIsTailscaleIPv4Address(_ host: String) -> Bool { let normalized = host .trimmingCharacters(in: .whitespacesAndNewlines) @@ -2731,6 +2737,7 @@ final class SyncService: ObservableObject { if host == "localhost" || host.hasSuffix(".localhost") { return true } if host.hasSuffix(".local") { return true } // Bonjour / mDNS if host.hasSuffix(".ts.net") { return true } // Tailscale MagicDNS + if syncIsTailnetDiscoveryHost(host) { return true } // Tailscale Serve service aliases if let v4 = IPv4Address(host) { let bytes = v4.rawValue @@ -2850,6 +2857,12 @@ final class SyncService: ObservableObject { return true } + if let tailnetService = discovered.tailscaleAddress, + syncIsTailnetDiscoveryHost(tailnetService), + profileHasTailnetRoute(profile) { + return true + } + if let hostIdentity = profile.hostIdentity?.trimmingCharacters(in: .whitespacesAndNewlines), !hostIdentity.isEmpty { let discoveredIdentity = discovered.hostIdentity?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -4478,14 +4491,16 @@ private final class SyncTailnetProbe { let canConnect = await probe(host: host, port: port) guard canConnect else { continue } let key = "\(host):\(port)" + let routeHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let isTailnetRoute = syncIsTailnetDiscoveryHost(routeHost) nextHosts[key] = DiscoveredSyncHost( id: "tailnet-\(key)", serviceName: "ADE Tailnet \(host)", - hostName: host, + hostName: routeHost, hostIdentity: nil, port: port, - addresses: [host], - tailscaleAddress: nil, + addresses: isTailnetRoute ? [] : [routeHost], + tailscaleAddress: isTailnetRoute ? routeHost : nil, lastResolvedAt: ISO8601DateFormatter().string(from: Date()) ) break diff --git a/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift b/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift index bade4d6f1..91652c1a2 100644 --- a/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift @@ -8,6 +8,7 @@ struct CtoTeamScreen: View { @Binding var path: NavigationPath @State private var agents: [AgentIdentity] = [] + @State private var fallbackWorkers: [CtoWorkerEntry] = [] @State private var budget: AgentBudgetSnapshot? @State private var isLoading = false @State private var loadError: String? @@ -31,14 +32,14 @@ struct CtoTeamScreen: View { rosterSectionHeader .padding(.horizontal, 20) - if isLoading && agents.isEmpty { + if isLoading && displayAgents.isEmpty { VStack(spacing: 8) { ForEach(0..<4, id: \.self) { _ in ADECardSkeleton(rows: 2) } } .padding(.horizontal, 16) - } else if agents.isEmpty && loadError != nil { + } else if displayAgents.isEmpty && loadError != nil { ADENoticeCard( title: "Couldn't load workers", message: loadError ?? "Unknown error.", @@ -48,7 +49,7 @@ struct CtoTeamScreen: View { action: { Task { await load() } } ) .padding(.horizontal, 16) - } else if agents.isEmpty { + } else if displayAgents.isEmpty { ADEEmptyStateView( symbol: "person.crop.circle.badge.questionmark", title: "No workers hired yet", @@ -57,7 +58,7 @@ struct CtoTeamScreen: View { .padding(.horizontal, 16) } else { VStack(spacing: 8) { - ForEach(agents) { agent in + ForEach(displayAgents) { agent in WorkerRowCard( agent: agent, spentOverride: spentOverride(for: agent), @@ -87,8 +88,12 @@ struct CtoTeamScreen: View { } .padding(.top, 8) } - .refreshable { await load() } - .task { if agents.isEmpty { await load() } } + .refreshable { await load(force: true) } + .task { if displayAgents.isEmpty { await load(force: true) } } + .task(id: ctoAgentsLiveReloadKey) { + guard ctoAgentsLiveReloadKey != nil else { return } + await load(force: true) + } .sheet(isPresented: $showHireSheet) { CtoDesktopOnlyNotice( title: "Hire worker", @@ -129,7 +134,7 @@ struct CtoTeamScreen: View { } private var headerSubtitle: String { - let count = agents.count + let count = displayAgents.count let workerWord = count == 1 ? "worker" : "workers" let spent = ctoFormatCents(budget?.companySpentMonthlyCents ?? companySpentFallbackCents) if let cap = budget?.companyCapMonthlyCents { @@ -272,8 +277,8 @@ struct CtoTeamScreen: View { .textCase(.uppercase) .tracking(0.4) Spacer(minLength: 0) - if !agents.isEmpty { - Text("\(agents.count)") + if !displayAgents.isEmpty { + Text("\(displayAgents.count)") .font(.caption.monospaced()) .foregroundStyle(ADEColor.textMuted) } @@ -287,8 +292,9 @@ struct CtoTeamScreen: View { /// failure shouldn't hide the budget card. Connection-level errors are /// surfaced globally by the top-right gear, not inline here. @MainActor - private func load() async { + private func load(force: Bool = false) async { if isLoading { return } + if !force && !displayAgents.isEmpty { return } isLoading = true defer { isLoading = false } @@ -299,9 +305,21 @@ struct CtoTeamScreen: View { switch agentsResult { case .success(let fetched): agents = fetched + if fetched.isEmpty { + if let roster = try? await syncService.fetchCtoRoster() { + fallbackWorkers = roster.workers + } else { + fallbackWorkers = [] + } + } else { + fallbackWorkers = [] + } loadError = nil case .failure(let error): - if syncService.connectionState.isHostUnreachable { + agents = [] + let rosterWorkers = (try? await syncService.fetchCtoRoster())?.workers ?? [] + fallbackWorkers = rosterWorkers + if syncService.connectionState.isHostUnreachable || !rosterWorkers.isEmpty { loadError = nil } else { loadError = (error as NSError).localizedDescription @@ -312,6 +330,40 @@ struct CtoTeamScreen: View { } } + private var displayAgents: [AgentIdentity] { + if !agents.isEmpty { return agents } + return fallbackWorkers.map { worker in + AgentIdentity( + id: worker.agentId, + name: worker.name, + slug: nil, + role: "worker", + title: nil, + reportsTo: nil, + capabilities: [], + status: worker.status, + adapterType: "legacy_roster", + adapterConfig: nil, + personality: nil, + systemPromptExtension: nil, + budgetMonthlyCents: nil, + spentMonthlyCents: nil, + lastHeartbeatAt: nil, + createdAt: nil, + updatedAt: nil + ) + } + } + + private var ctoAgentsLiveReloadKey: String? { + switch syncService.connectionState { + case .connected, .syncing: + return "live-\(syncService.localStateRevision)" + case .connecting, .disconnected, .error: + return nil + } + } + @MainActor private func wakeUp(agent: AgentIdentity) async { guard !pendingWakeup.contains(agent.id) else { return } diff --git a/apps/ios/ADE/Views/PRs/PrStackSheet.swift b/apps/ios/ADE/Views/PRs/PrStackSheet.swift index 9a25c0f7f..f8fc746ec 100644 --- a/apps/ios/ADE/Views/PRs/PrStackSheet.swift +++ b/apps/ios/ADE/Views/PRs/PrStackSheet.swift @@ -16,6 +16,7 @@ struct PrStackSheet: View { @State private var stackInfo: PrStackInfo? @State private var isLoading = true @State private var errorMessage: String? + @State private var actionErrorMessage: String? @State private var detailPath = NavigationPath() @State private var isDispatchingStackAction = false @State private var actionMessage: String? @@ -67,6 +68,18 @@ struct PrStackSheet: View { .padding(.horizontal, 16) } + if let actionErrorMessage { + ADENoticeCard( + title: "Stack action failed", + message: actionErrorMessage, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: nil, + action: nil + ) + .padding(.horizontal, 16) + } + stackHero .padding(.horizontal, 16) .padding(.top, 4) @@ -294,13 +307,15 @@ struct PrStackSheet: View { guard !isDispatchingStackAction, let laneId = rebaseTargetLaneId else { return } isDispatchingStackAction = true errorMessage = nil + actionMessage = nil + actionErrorMessage = nil Task { @MainActor in defer { isDispatchingStackAction = false } do { try await syncService.startLaneRebase(laneId: laneId) actionMessage = "Rebase started." } catch { - errorMessage = error.localizedDescription + actionErrorMessage = error.localizedDescription } } } @@ -309,13 +324,15 @@ struct PrStackSheet: View { guard !isDispatchingStackAction, let prId = landTargetPrId else { return } isDispatchingStackAction = true errorMessage = nil + actionMessage = nil + actionErrorMessage = nil Task { @MainActor in defer { isDispatchingStackAction = false } do { try await syncService.mergePullRequest(prId: prId, method: PrMergeMethodOption.squash.rawValue) actionMessage = "Landing started." } catch { - errorMessage = error.localizedDescription + actionErrorMessage = error.localizedDescription } } } diff --git a/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift b/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift index 6deb1a6eb..516bd0474 100644 --- a/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift +++ b/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift @@ -2,7 +2,7 @@ import SwiftUI private enum WorkflowLandingConfirmation { case activePr(prId: String) - case queueNext(groupId: String) + case queueNext(groupId: String, prId: String) var title: String { switch self { @@ -258,6 +258,15 @@ struct PrMobileWorkflowCardView: View { card.queueId.nonEmpty ?? (card.id.hasPrefix("queue:") ? String(card.id.dropFirst("queue:".count)) : nil) } + private var nextQueueEntry: QueueLandingEntry? { + card.entries? + .sorted(by: { $0.position < $1.position }) + .first { entry in + let state = entry.state.lowercased() + return state == "open" || state == "draft" + } + } + private var landingConfirmationPresented: Binding { Binding( get: { landingConfirmation != nil }, @@ -399,14 +408,17 @@ struct PrMobileWorkflowCardView: View { } if let groupId = card.groupId { + let nextPrId = nextQueueEntry?.prId Button { - landingConfirmation = .queueNext(groupId: groupId) + if let nextPrId { + landingConfirmation = .queueNext(groupId: groupId, prId: nextPrId) + } } label: { Label("Land queue next", systemImage: "arrow.forward.to.line") .frame(maxWidth: .infinity) } .buttonStyle(.glass) - .disabled(!isLive) + .disabled(!isLive || nextPrId == nil) } if let queueId { @@ -463,8 +475,9 @@ struct PrMobileWorkflowCardView: View { return } onLand(capturedPrId, mergeMethod) - case .queueNext(let capturedGroupId): - guard card.groupId == capturedGroupId else { return } + case .queueNext(let capturedGroupId, let capturedPrId): + guard card.groupId == capturedGroupId, + nextQueueEntry?.prId == capturedPrId else { return } onLandQueueNext(capturedGroupId, mergeMethod) } } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 8a580ed45..993edc2e8 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -4,6 +4,7 @@ import AVKit struct WorkChatSessionView: View { @Environment(\.accessibilityReduceMotion) var reduceMotion + @EnvironmentObject private var syncService: SyncService let session: TerminalSessionSummary let chatSummary: AgentChatSessionSummary? @@ -177,9 +178,9 @@ struct WorkChatSessionView: View { } } - // Surface mid-session errors only when the host is actually reachable. - // Connection-caused failures are communicated via the top-right gear. - if let errorMessage, isLive { + // Connection-caused failures are communicated via the top-right gear, but + // cached/offline chat actions still need their own visible errors. + if let errorMessage, !syncService.connectionState.isHostUnreachable { ADENoticeCard( title: "Chat error", message: errorMessage,