diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 94774ce80..76105b068 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -219,6 +219,7 @@ function createRuntime() { }, sessionService: { get: vi.fn(), + updateMeta: vi.fn(), readTranscriptTail: vi.fn(() => "") }, sessionDeltaService: { @@ -2446,6 +2447,8 @@ describe("adeRpcServer", () => { provider: "codex", permissionMode: "edit", initialInput: "fix failing tests", + modelId: "openai/gpt-5.5", + reasoningEffort: "xhigh", cols: 90, rows: 24, }); @@ -2454,7 +2457,7 @@ describe("adeRpcServer", () => { expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( expect.objectContaining({ laneId: "lane-1", - title: "Codex", + title: "Fix failing tests", toolType: "codex", cols: 90, rows: 24, @@ -2465,7 +2468,15 @@ describe("adeRpcServer", () => { }), }), ); - expect(fixture.runtime.ptyService.writeBySessionId).toHaveBeenCalledWith("session-1", "fix failing tests\r"); + const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0]; + expect(createCall?.args).toEqual(expect.arrayContaining(["--model", "gpt-5.5", "-c", "model_reasoning_effort=\"xhigh\""])); + expect(createCall?.args.at(-1)).toContain("fix failing tests"); + expect(fixture.runtime.ptyService.writeBySessionId).not.toHaveBeenCalled(); + expect(fixture.runtime.sessionService.updateMeta).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "session-1", + goal: "fix failing tests", + title: "Fix failing tests", + })); expect(response.structuredContent).toMatchObject({ provider: "codex", laneId: "lane-1", @@ -2503,8 +2514,61 @@ describe("adeRpcServer", () => { expect.objectContaining({ cols: 400, rows: 200, + startupCommand: expect.stringContaining("codex --no-alt-screen --sandbox workspace-write --ask-for-approval on-request"), + }), + ); + expect(response.structuredContent.startupCommand).not.toContain("--full-auto"); + }); + + it("starts shell CLI sessions without reading user shell startup files", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + const response = await withEnv({ SHELL: "/bin/zsh" }, async () => { + await initialize(handler, { role: "orchestrator" }); + return await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "shell", + }); + }); + + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + title: "Shell", + toolType: "shell", + command: "/bin/zsh", + args: ["-f"], + env: { ZDOTDIR: "/var/empty" }, + }), + ); + }); + + it("starts Codex spawn_agent with current default permission flags", async () => { + const fixture = createRuntime(); + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-bin-")); + createFakePathExecutable(binDir, "codex"); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + const response = await withEnv({ PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, SHELL: "/bin/sh" }, async () => { + await initialize(handler, { role: "orchestrator" }); + return await callTool(handler, "spawn_agent", { + laneId: "lane-1", + provider: "codex", + prompt: "Check the mobile CLI path", + }); + }); + + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringMatching(/codex$/), + args: expect.arrayContaining(["--sandbox", "workspace-write", "--ask-for-approval", "on-request"]), + startupCommand: expect.stringContaining("codex --sandbox workspace-write --ask-for-approval on-request"), }), ); + expect(response.structuredContent.startupCommand).not.toContain("--full-auto"); }); it("passes selected models to fresh Claude Code terminal launches", async () => { @@ -2547,6 +2611,35 @@ describe("adeRpcServer", () => { expect(response.structuredContent.model).toBe("anthropic/claude-opus-4-7-1m"); }); + it("passes Claude auto permission mode to fresh Claude Code terminal launches", async () => { + const fixture = createRuntime(); + fixture.runtime.sessionService.get.mockReturnValue({ + id: "session-1", + laneId: "lane-1", + ptyId: "pty-1", + tracked: true, + toolType: "claude", + title: "Claude Code", + status: "running", + resumeCommand: null, + resumeMetadata: null, + }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "orchestrator" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "claude", + permissionMode: "auto", + }); + + expect(response?.isError).toBeUndefined(); + const createCall = fixture.runtime.ptyService.create.mock.calls[0]?.[0] as { args?: string[]; startupCommand?: string }; + expect(createCall.args).toEqual(expect.arrayContaining(["--permission-mode", "auto"])); + expect(createCall.startupCommand).toContain("--permission-mode auto"); + expect(response.structuredContent.permissionMode).toBe("auto"); + }); + it("confirms Claude Code initial input after startup", async () => { vi.useFakeTimers(); try { @@ -2596,7 +2689,7 @@ describe("adeRpcServer", () => { await initialize(handler, { role: "orchestrator" }); const response = await callTool(handler, "start_cli_session", { laneId: "lane-1", - provider: "codex", + provider: "cursor", initialInput: "fix failing tests", }); @@ -2661,7 +2754,7 @@ describe("adeRpcServer", () => { expect(response.isError).toBe(true); expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain( - "permissionMode must be one of default, plan, edit, full-auto, or config-toml", + "permissionMode must be one of default, auto, plan, edit, full-auto, or config-toml", ); expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled(); }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 0810bfba3..d7962f372 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -56,10 +56,12 @@ import type { LinearConnectionStatus } from "../../desktop/src/shared/types/line import { resolveAdeLayout } from "../../desktop/src/shared/adeLayout"; import { buildTrackedCliLaunchCommand, + deriveTrackedCliInitialInputSessionMeta, isLaunchProfile, isTrackedCliPermissionMode, LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, + resolveCleanShellLaunchFields, validateLaunchProfilePermissionMode, type CliProvider, type LaunchProfile, @@ -225,7 +227,7 @@ const TOOL_SPECS: ToolSpec[] = [ runId: { type: "string" }, stepId: { type: "string" }, attemptId: { type: "string" }, - permissionMode: { type: "string", enum: ["default", "plan", "edit", "full-auto", "config-toml"], default: "default" }, + permissionMode: { type: "string", enum: ["default", "auto", "plan", "edit", "full-auto", "config-toml"], default: "default" }, toolWhitelist: { type: "array", items: { type: "string" }, maxItems: 24 }, maxPromptChars: { type: "number", minimum: 256, maximum: 12000 }, contextFilePath: { type: "string" }, @@ -322,13 +324,14 @@ const TOOL_SPECS: ToolSpec[] = [ properties: { laneId: { type: "string", minLength: 1 }, provider: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "shell"] }, - permissionMode: { type: "string", enum: ["default", "plan", "edit", "full-auto", "config-toml"], default: "default" }, + permissionMode: { type: "string", enum: ["default", "auto", "plan", "edit", "full-auto", "config-toml"], default: "default" }, title: { type: "string" }, initialInput: { type: "string" }, cols: { type: "number", minimum: 20, maximum: 400, default: 120 }, rows: { type: "number", minimum: 4, maximum: 200, default: 36 }, model: { type: "string" }, modelId: { type: "string" }, + reasoningEffort: { type: "string" }, cwd: { type: "string" }, chatSessionId: { type: "string" }, tracked: { type: "boolean", default: true } @@ -1738,7 +1741,7 @@ const CTO_OPERATOR_TOOL_SPECS: ToolSpec[] = [ laneId: { type: "string" }, modelId: { type: "string" }, reasoningEffort: { type: "string" }, - permissionMode: { type: "string", enum: ["default", "plan", "edit", "full-auto", "config-toml"] }, + permissionMode: { type: "string", enum: ["default", "auto", "plan", "edit", "full-auto", "config-toml"] }, droidPermissionMode: { type: "string", enum: ["read-only", "auto-low", "auto-medium", "auto-high"] }, title: { type: "string" }, initialPrompt: { type: "string" }, @@ -2491,7 +2494,7 @@ function parseCliSessionPermissionMode(value: unknown): AgentChatPermissionMode if (isTrackedCliPermissionMode(mode)) return mode; throw new JsonRpcError( JsonRpcErrorCode.invalidParams, - "permissionMode must be one of default, plan, edit, full-auto, or config-toml", + "permissionMode must be one of default, auto, plan, edit, full-auto, or config-toml", ); } @@ -3201,11 +3204,11 @@ function sha256Text(value: string): string { return createHash("sha256").update(value).digest("hex"); } -type SpawnPermissionMode = "default" | "plan" | "edit" | "full-auto" | "config-toml"; +type SpawnPermissionMode = "default" | "auto" | "plan" | "edit" | "full-auto" | "config-toml"; function parseSpawnPermissionMode(value: unknown): SpawnPermissionMode { const normalized = asTrimmedString(value).toLowerCase(); - if (normalized === "plan" || normalized === "edit" || normalized === "full-auto" || normalized === "config-toml") return normalized; + if (normalized === "auto" || normalized === "plan" || normalized === "edit" || normalized === "full-auto" || normalized === "config-toml") return normalized; return "default"; } @@ -5171,16 +5174,37 @@ async function runTool(args: { } const cols = clampInteger(toolArgs.cols, DEFAULT_PTY_COLS, 20, 400); const rows = clampInteger(toolArgs.rows, DEFAULT_PTY_ROWS, 4, 200); - const title = asOptionalTrimmedString(toolArgs.title) ?? LAUNCH_PROFILE_TITLE[provider]; const initialInput = asOptionalTrimmedString(toolArgs.initialInput)?.slice(0, 20_000) ?? null; const model = asOptionalTrimmedString(toolArgs.model) ?? asOptionalTrimmedString(toolArgs.modelId); + const reasoningEffort = asOptionalTrimmedString(toolArgs.reasoningEffort); + const initialInputMeta = deriveTrackedCliInitialInputSessionMeta({ + provider, + title: asOptionalTrimmedString(toolArgs.title), + initialInput, + }); + const title = initialInputMeta.title || LAUNCH_PROFILE_TITLE[provider]; const ptyService = runtime.ptyService; const preassignedSessionId = provider === "claude" ? randomUUID() : undefined; const laneWorktreePath = resolveLaneWorktreePath(runtime, laneId); const launchFields: { startupCommand?: string; command?: string; args?: string[]; env?: Record } = (() => { + if (provider === "shell") { + return resolveCleanShellLaunchFields({ + platform: process.platform, + shell: process.env.SHELL, + comSpec: process.env.ComSpec, + }); + } if (!isCliProvider(provider)) return {}; - return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId, model, laneWorktreePath }); + return buildTrackedCliLaunchCommand({ + provider, + permissionMode, + sessionId: preassignedSessionId, + model, + reasoningEffort, + initialPrompt: provider === "codex" ? initialInput : null, + laneWorktreePath, + }); })(); const created = await ptyService.create({ @@ -5198,7 +5222,9 @@ async function runTool(args: { }); let initialInputWritten = false; - if (initialInput && isCliProvider(provider)) { + if (initialInput && provider === "codex") { + initialInputWritten = true; + } else if (initialInput && isCliProvider(provider)) { initialInputWritten = ptyService.writeBySessionId(created.sessionId, `${initialInput}\r`); if (!initialInputWritten) { try { @@ -5225,6 +5251,19 @@ async function runTool(args: { } } + const autoTitleApplied = Boolean(initialInputMeta.promptTitle) && title === initialInputMeta.promptTitle; + if (initialInputMeta.goal || autoTitleApplied) { + const session = runtime.sessionService.get(created.sessionId) as TerminalSessionSummary | null; + const metaPatch: Parameters[0] = { + sessionId: created.sessionId, + ...(initialInputMeta.goal && !session?.goal?.trim().length ? { goal: initialInputMeta.goal } : {}), + ...(autoTitleApplied ? { title: initialInputMeta.promptTitle!, manuallyNamed: false } : {}), + }; + if (metaPatch.goal !== undefined || metaPatch.title !== undefined || metaPatch.manuallyNamed !== undefined) { + runtime.sessionService.updateMeta(metaPatch); + } + } + const session = runtime.sessionService.get(created.sessionId) as TerminalSessionSummary | null; const enrichedSession = session ? ptyService.enrichSessions([session])[0] ?? session : session; return { @@ -6855,6 +6894,12 @@ async function runTool(args: { "permissionMode config-toml is only supported for Codex spawn_agent sessions.", ); } + if (provider === "codex" && permissionMode === "auto") { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "permissionMode auto is only supported for Claude spawn_agent sessions.", + ); + } const maxPromptChars = Math.max(256, Math.min(12000, Math.floor(asNumber(toolArgs.maxPromptChars, 2800)))); const prompt = asOptionalTrimmedString(toolArgs.prompt); const runId = asOptionalTrimmedString(toolArgs.runId); @@ -6934,6 +6979,9 @@ async function runTool(args: { case "edit": claudePermission = "acceptEdits"; break; + case "auto": + claudePermission = "auto"; + break; default: claudePermission = "default"; } diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index bcfbbee1c..84ce72b50 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -47,7 +47,7 @@ import { createMemoryService } from "../../desktop/src/main/services/memory/memo import { createCtoStateService } from "../../desktop/src/main/services/cto/ctoStateService"; import { createWorkerAgentService } from "../../desktop/src/main/services/cto/workerAgentService"; import { createWorkerBudgetService } from "../../desktop/src/main/services/cto/workerBudgetService"; -import type { createWorkerRevisionService } from "../../desktop/src/main/services/cto/workerRevisionService"; +import { createWorkerRevisionService } from "../../desktop/src/main/services/cto/workerRevisionService"; import type { createWorkerHeartbeatService } from "../../desktop/src/main/services/cto/workerHeartbeatService"; import type { createWorkerTaskSessionService } from "../../desktop/src/main/services/cto/workerTaskSessionService"; import type { createLinearCredentialService } from "../../desktop/src/main/services/cto/linearCredentialService"; @@ -708,6 +708,11 @@ export async function createAdeRuntime(args: { workerAgentService, projectConfigService, }); + const workerRevisionService = createWorkerRevisionService({ + db, + projectId, + workerAgentService, + }); const missionBudgetService = createMissionBudgetService({ db, logger, @@ -1077,6 +1082,7 @@ export async function createAdeRuntime(args: { agentChatService, workerAgentService, workerBudgetService, + workerRevisionService, workerHeartbeatService: headlessLinearServices.workerHeartbeatService, ctoStateService, flowPolicyService: headlessLinearServices.flowPolicyService, @@ -1158,6 +1164,7 @@ export async function createAdeRuntime(args: { workerAgentService, adeProjectService, workerBudgetService, + workerRevisionService, githubService: headlessLinearServices.githubService as never, workerTaskSessionService: headlessLinearServices.workerTaskSessionService, workerHeartbeatService: headlessLinearServices.workerHeartbeatService, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 3205d3579..de3ef5b6d 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2237,6 +2237,28 @@ describe("ADE CLI", () => { }); }); + it("accepts Claude auto permission mode for provider CLI launches", () => { + const plan = buildCliPlan([ + "shell", + "start-cli", + "claude", + "--lane", + "lane-1", + "--permission-mode", + "auto", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + name: "start_cli_session", + arguments: { + laneId: "lane-1", + provider: "claude", + permissionMode: "auto", + }, + }); + }); + it("accepts --provider on shell start as the CLI-session launcher", () => { const plan = buildCliPlan([ "shell", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 4cfe020b5..08371ea8e 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -4863,7 +4863,7 @@ function buildCliSessionStartPlan( readValue(args, ["--permission-mode", "--permissions"]) ?? "default"; if (!isTrackedCliPermissionMode(permissionMode)) { throw new CliUsageError( - "permissionMode must be one of default, plan, edit, full-auto, or config-toml.", + "permissionMode must be one of default, auto, plan, edit, full-auto, or config-toml.", ); } validateLaunchProfilePermissionMode(provider, permissionMode); @@ -10678,6 +10678,21 @@ async function runServe( const port = parseOptionalPort(readValue(args, ["--port"]), "--port"); const syncEnabled = !readFlag(args, ["--no-sync"]); const projectRegistry = new ProjectRegistry(layout); + let preferredSyncProjectId: string | null = null; + const preferredSyncProjectRoot = process.env.ADE_PROJECT_ROOT?.trim(); + if (preferredSyncProjectRoot) { + try { + preferredSyncProjectId = projectRegistry.add( + path.resolve(preferredSyncProjectRoot), + ).projectId; + } catch (error) { + process.stderr.write( + `ade serve could not register ADE_PROJECT_ROOT for phone sync: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + } + } type ProjectRecord = ReturnType< InstanceType["list"] >[number]; @@ -10894,11 +10909,13 @@ async function runServe( } if (syncEnabled) { - void scopeRegistry.ensureSyncHost().catch((error: unknown) => { - process.stderr.write( - `ade serve sync host failed: ${error instanceof Error ? error.message : String(error)}\n`, - ); - }); + void scopeRegistry + .ensureSyncHost(preferredSyncProjectId ?? undefined) + .catch((error: unknown) => { + process.stderr.write( + `ade serve sync host failed: ${error instanceof Error ? error.message : String(error)}\n`, + ); + }); } process.stderr.write( diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index e11feddac..888f1830c 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -3036,9 +3036,35 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const deviceId = peer.metadata?.deviceId; if (!deviceId) return; const kind = payload?.kind === "activity" ? "activity" : "alert"; - const result = args.notificationEventBus - ? await args.notificationEventBus.sendTestPush(deviceId, kind) - : { ok: false, reason: "notification_bus_unavailable" as const }; + if (!args.notificationEventBus) { + if (isIosPeerConnected(deviceId)) { + sendInAppNotification(deviceId, { + category: "system", + title: payload?.title?.trim() || "ADE test push", + body: payload?.body?.trim() || "This device reached its paired ADE machine.", + collapseId: "ade:test", + }); + sendRequired(peer, "command_result", { + commandId: `push-test:${deviceId}:${kind}`, + ok: true, + result: { + mode: "in_app", + message: "Test notification delivered in app. APNs is not wired in this runtime.", + }, + }, requestId ?? null); + return; + } + sendRequired(peer, "command_result", { + commandId: `push-test:${deviceId}:${kind}`, + ok: false, + error: { + code: "test_push_failed", + message: "Notifications are not wired in this ADE runtime.", + }, + }, requestId ?? null); + return; + } + const result = await args.notificationEventBus.sendTestPush(deviceId, kind); sendRequired(peer, "command_result", { commandId: `push-test:${deviceId}:${kind}`, ok: result.ok, diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index bb3bcfc48..8c6875369 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -33,6 +33,8 @@ import type { CtoIdentity, CtoTriggerAgentWakeupArgs, CreateChildLaneArgs, + CommitIntegrationArgs, + CreateQueuePrsArgs, CreateLaneArgs, CreateLaneFromUnstagedArgs, CreatePrFromLaneArgs, @@ -90,7 +92,9 @@ import type { RerunPrChecksArgs, SetPrLabelsArgs, SetPrReviewThreadResolvedArgs, + SimulateIntegrationArgs, StartIntegrationResolutionArgs, + StartQueueAutomationArgs, SubmitPrReviewArgs, SyncCommandPayload, SyncRemoteCommandAction, @@ -113,11 +117,13 @@ import type { import { buildTrackedCliLaunchCommand, buildTrackedCliResumeCommand, + deriveTrackedCliInitialInputSessionMeta, isLaunchProfile, isTrackedCliPermissionMode, LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, launchProfileForTerminalSession, + resolveCleanShellLaunchFields, resolveTrackedCliResumeCommand, validateLaunchProfilePermissionMode, } from "../../../../desktop/src/shared/cliLaunch"; @@ -227,11 +233,26 @@ function asOptionalNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function asConfidenceThreshold(value: unknown): number | undefined { + const numeric = asOptionalNumber(value); + if (numeric == null) return undefined; + if (numeric < 0 || numeric > 1) return undefined; + return numeric; +} + function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.map((entry) => asTrimmedString(entry)).filter((entry): entry is string => Boolean(entry)); } +function asStringRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const entries = Object.entries(value) + .map(([key, entry]) => [key.trim(), typeof entry === "string" ? entry.trim() : ""] as const) + .filter(([key, entry]) => key.length > 0 && entry.length > 0); + return entries.length ? Object.fromEntries(entries) : undefined; +} + function parseAgentChatFileRefs(value: unknown): AgentChatFileRef[] | undefined { if (!Array.isArray(value)) return undefined; const attachments: AgentChatFileRef[] = []; @@ -552,6 +573,7 @@ function parseStartCliSessionArgs(value: Record): SyncStartCliS rows: asOptionalNumber(value.rows), model: asTrimmedString(value.model), modelId: asTrimmedString(value.modelId), + reasoningEffort: asTrimmedString(value.reasoningEffort), }; } @@ -587,7 +609,14 @@ async function listRemoteWorkSessions( if (!isChatToolType(session.toolType) || session.status !== "running") return session; const chat = chatSummaryBySessionId.get(session.id); if (!chat) return session; - if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const, chatIdleSinceAt: null }; + if (chat.awaitingInput) { + return { + ...session, + runtimeState: "waiting-input" as const, + chatIdleSinceAt: null, + pendingInputItemId: chat.pendingInputItemId ?? null, + }; + } if (chat.status === "active") return { ...session, runtimeState: "running" as const, chatIdleSinceAt: null }; if (chat.status === "idle") return { ...session, runtimeState: "idle" as const, chatIdleSinceAt: chat.idleSinceAt ?? null }; return session; @@ -614,6 +643,7 @@ function parseAgentChatListArgs(value: Record): AgentChatListAr return { ...(asTrimmedString(value.laneId) ? { laneId: asTrimmedString(value.laneId)! } : {}), includeAutomation: asOptionalBoolean(value.includeAutomation), + includeArchived: asOptionalBoolean(value.includeArchived), }; } @@ -992,6 +1022,22 @@ function parseCreatePrArgs(value: Record): CreatePrFromLaneArgs }; } +function parseCreateQueuePrsArgs(value: Record): CreateQueuePrsArgs { + const laneIds = requireStringArray(value.laneIds, "prs.createQueue requires laneIds."); + const targetBranch = requireString(value.targetBranch, "prs.createQueue requires targetBranch."); + const titles = asStringRecord(value.titles); + return { + laneIds, + targetBranch, + ...(titles ? { titles } : {}), + ...(typeof value.draft === "boolean" ? { draft: value.draft } : {}), + ...(typeof value.autoRebase === "boolean" ? { autoRebase: value.autoRebase } : {}), + ...(typeof value.ciGating === "boolean" ? { ciGating: value.ciGating } : {}), + ...(asTrimmedString(value.queueName) ? { queueName: asTrimmedString(value.queueName)! } : {}), + ...(typeof value.allowDirtyWorktree === "boolean" ? { allowDirtyWorktree: value.allowDirtyWorktree } : {}), + }; +} + function parseLinkPrToLaneArgs(value: Record): LinkPrToLaneArgs { return { laneId: requireString(value.laneId, "prs.linkToLane requires laneId."), @@ -1138,6 +1184,32 @@ function parseListIntegrationWorkflowsArgs(value: Record): List return view ? { view: view as ListIntegrationWorkflowsArgs["view"] } : {}; } +function parseSimulateIntegrationArgs(value: Record): SimulateIntegrationArgs { + return { + sourceLaneIds: requireStringArray(value.sourceLaneIds, "prs.simulateIntegration requires sourceLaneIds."), + baseBranch: requireString(value.baseBranch, "prs.simulateIntegration requires baseBranch."), + ...(typeof value.persist === "boolean" ? { persist: value.persist } : {}), + ...(typeof value.mergeIntoLaneId === "string" || value.mergeIntoLaneId === null + ? { mergeIntoLaneId: value.mergeIntoLaneId } + : {}), + }; +} + +function parseCommitIntegrationArgs(value: Record): CommitIntegrationArgs { + return { + proposalId: requireString(value.proposalId, "prs.commitIntegration requires proposalId."), + integrationLaneName: requireString(value.integrationLaneName, "prs.commitIntegration requires integrationLaneName."), + title: requireString(value.title, "prs.commitIntegration requires title."), + ...(typeof value.body === "string" ? { body: value.body } : {}), + ...(typeof value.draft === "boolean" ? { draft: value.draft } : {}), + ...(typeof value.pauseOnConflict === "boolean" ? { pauseOnConflict: value.pauseOnConflict } : {}), + ...(typeof value.allowDirtyWorktree === "boolean" ? { allowDirtyWorktree: value.allowDirtyWorktree } : {}), + ...(typeof value.preferredIntegrationLaneId === "string" || value.preferredIntegrationLaneId === null + ? { preferredIntegrationLaneId: value.preferredIntegrationLaneId } + : {}), + }; +} + function parseUpdateIntegrationProposalArgs(value: Record): UpdateIntegrationProposalArgs { return { proposalId: requireString(value.proposalId, "prs.updateIntegrationProposal requires proposalId."), @@ -1209,7 +1281,35 @@ function parseLandQueueNextArgs(value: Record): LandQueueNextAr method, ...(typeof value.archiveLane === "boolean" ? { archiveLane: value.archiveLane } : {}), ...(typeof value.autoResolve === "boolean" ? { autoResolve: value.autoResolve } : {}), - ...(asOptionalNumber(value.confidenceThreshold) != null ? { confidenceThreshold: asOptionalNumber(value.confidenceThreshold)! } : {}), + ...(asConfidenceThreshold(value.confidenceThreshold) != null ? { confidenceThreshold: asConfidenceThreshold(value.confidenceThreshold)! } : {}), + }; +} + +function parseStartQueueAutomationArgs(value: Record): StartQueueAutomationArgs { + const method = asTrimmedString(value.method); + if (!method || !["merge", "squash", "rebase"].includes(method)) { + throw new Error("prs.startQueueAutomation requires method to be merge, squash, or rebase."); + } + const pipeline = isRecord(value.pipeline) ? (value.pipeline as StartQueueAutomationArgs["pipeline"]) : undefined; + const resolverProvider = asTrimmedString(value.resolverProvider); + const resolverModel = asTrimmedString(value.resolverModel); + const reasoningEffort = asTrimmedString(value.reasoningEffort); + const permissionMode = asTrimmedString(value.permissionMode); + const confidenceThreshold = asConfidenceThreshold(value.confidenceThreshold); + const originLabel = asTrimmedString(value.originLabel); + return { + groupId: requireString(value.groupId, "prs.startQueueAutomation requires groupId."), + method: method as StartQueueAutomationArgs["method"], + ...(typeof value.archiveLane === "boolean" ? { archiveLane: value.archiveLane } : {}), + ...(typeof value.ciGating === "boolean" ? { ciGating: value.ciGating } : {}), + ...(pipeline ? { pipeline } : {}), + ...(typeof value.autoResolve === "boolean" ? { autoResolve: value.autoResolve } : {}), + ...(resolverProvider ? { resolverProvider: resolverProvider as StartQueueAutomationArgs["resolverProvider"] } : {}), + ...(resolverModel ? { resolverModel } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(permissionMode ? { permissionMode: permissionMode as StartQueueAutomationArgs["permissionMode"] } : {}), + ...(confidenceThreshold != null ? { confidenceThreshold } : {}), + ...(originLabel ? { originLabel } : {}), }; } @@ -1237,7 +1337,7 @@ function parseResumeQueueAutomationArgs(value: Record): ResumeQ ...(typeof value.archiveLane === "boolean" ? { archiveLane: value.archiveLane } : {}), ...(typeof value.autoResolve === "boolean" ? { autoResolve: value.autoResolve } : {}), ...(typeof value.ciGating === "boolean" ? { ciGating: value.ciGating } : {}), - ...(asOptionalNumber(value.confidenceThreshold) != null ? { confidenceThreshold: asOptionalNumber(value.confidenceThreshold)! } : {}), + ...(asConfidenceThreshold(value.confidenceThreshold) != null ? { confidenceThreshold: asConfidenceThreshold(value.confidenceThreshold)! } : {}), ...(asTrimmedString(value.originLabel) ? { originLabel: asTrimmedString(value.originLabel)! } : {}), }; } @@ -1730,6 +1830,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg register("lanes.previewBranchSwitch", { viewerAllowed: true }, async (payload) => args.laneService.previewBranchSwitch(parseGitCheckoutBranchArgs(payload))); register("lanes.attach", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.attach(parseAttachLaneArgs(payload))); + register("lanes.listUnregisteredWorktrees", { viewerAllowed: true }, async () => args.laneService.listUnregisteredWorktrees()); register("lanes.adoptAttached", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.adoptAttached({ laneId: requireString(payload.laneId, "lanes.adoptAttached requires laneId.") })); register("lanes.rename", { viewerAllowed: true, queueable: true }, async (payload) => { @@ -1854,16 +1955,29 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg const permissionMode = parsed.permissionMode ?? "default"; validateLaunchProfilePermissionMode(provider, permissionMode); const toolType = LAUNCH_PROFILE_TOOL_TYPE[provider] as TerminalToolType; - const title = parsed.title?.trim() || LAUNCH_PROFILE_TITLE[provider]; + const initialInputMeta = deriveTrackedCliInitialInputSessionMeta({ + provider, + title: parsed.title, + initialInput: parsed.initialInput, + }); + const title = initialInputMeta.title || LAUNCH_PROFILE_TITLE[provider]; const preassignedSessionId = provider === "claude" ? randomUUID() : undefined; function resolveLaunch(): { startupCommand?: string; command?: string; args?: string[]; env?: Record } { - if (provider === "shell") return {}; + if (provider === "shell") { + return resolveCleanShellLaunchFields({ + platform: process.platform, + shell: process.env.SHELL, + comSpec: process.env.ComSpec, + }); + } return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId, model: parsed.modelId ?? parsed.model ?? undefined, + reasoningEffort: parsed.reasoningEffort ?? undefined, + initialPrompt: provider === "codex" ? parsed.initialInput : null, laneWorktreePath: resolveLaneWorktreePathForSync(args, parsed.laneId), }); } @@ -1880,7 +1994,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg ...resolveLaunch(), }); - if (parsed.initialInput && provider !== "shell") { + if (parsed.initialInput && provider !== "shell" && provider !== "codex") { const written = args.ptyService.writeBySessionId(result.sessionId, `${parsed.initialInput}\r`); if (!written) { try { @@ -1907,6 +2021,17 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg } } + if (initialInputMeta.goal) { + const session = args.sessionService.get(result.sessionId); + args.sessionService.updateMeta({ + sessionId: result.sessionId, + ...(session?.goal?.trim().length ? {} : { goal: initialInputMeta.goal }), + ...(initialInputMeta.promptTitle && title === initialInputMeta.promptTitle + ? { title: initialInputMeta.promptTitle, manuallyNamed: false } + : {}), + }); + } + const session = args.sessionService.get(result.sessionId); const enriched = session ? args.ptyService.enrichSessions([session])[0] ?? session : null; return { @@ -1955,7 +2080,10 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg register("chat.listSessions", { viewerAllowed: true }, async (payload) => { const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); const parsed = parseAgentChatListArgs(payload); - return agentChatService.listSessions(parsed.laneId, { includeAutomation: parsed.includeAutomation }); + return agentChatService.listSessions(parsed.laneId, { + includeAutomation: parsed.includeAutomation, + includeArchived: parsed.includeArchived, + }); }); register("chat.getSummary", { viewerAllowed: true }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").getSessionSummary(parseAgentChatGetSummaryArgs(payload).sessionId)); @@ -1970,7 +2098,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg register("chat.send", { viewerAllowed: true, queueable: true }, async (payload) => { await requireService(args.agentChatService, "Agent chat service not available.").sendMessage( parseAgentChatSendArgs(payload), - { awaitDispatch: true }, + { awaitDispatch: false }, ); return { ok: true }; }); @@ -1979,8 +2107,8 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg return { ok: true }; }); register("chat.steer", { viewerAllowed: true, queueable: false }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").steer(parseAgentChatSteerArgs(payload)); - return { ok: true }; + const result = await requireService(args.agentChatService, "Agent chat service not available.").steer(parseAgentChatSteerArgs(payload)); + return isRecord(result) ? { ...result, ok: true } : { ok: true }; }); register("chat.cancelSteer", { viewerAllowed: true, queueable: false }, async (payload) => { await requireService(args.agentChatService, "Agent chat service not available.").cancelSteer(parseAgentChatCancelSteerArgs(payload)); @@ -2179,9 +2307,9 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg return workerBudgetService.getBudgetSnapshot(monthKey ? { monthKey } : {}); }); register("cto.getAgentCoreMemory", { viewerAllowed: true }, async (payload) => { - const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); + const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); const agentId = requireString(payload.agentId, "cto.getAgentCoreMemory requires agentId."); - return workerHeartbeatService.getAgentCoreMemory(agentId); + return workerAgentService.getCoreMemory(agentId); }); register("cto.listAgentRuns", { viewerAllowed: true }, async (payload) => { const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); @@ -2206,8 +2334,12 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg return flowPolicyService.getPolicy(); }); register("cto.getLinearConnectionStatus", { viewerAllowed: true }, async () => { - const linearCredentialService = requireService(args.linearCredentialService, "Linear credential service not available."); - const credentialStatus = linearCredentialService.getStatus(); + const credentialStatus = args.linearCredentialService?.getStatus() ?? { + tokenStored: false, + authMode: null, + tokenExpiresAt: null, + oauthConfigured: false, + }; const tokenStored = Boolean(credentialStatus.tokenStored); const checkedAt = new Date().toISOString(); const linearIssueTracker = args.getLinearIssueTracker?.() ?? null; @@ -2264,6 +2396,13 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg const patch = isRecord(payload.patch) ? (payload.patch as Partial) : {}; return ctoStateService.updateCoreMemory(patch); }); + register("cto.removeAgent", { viewerAllowed: true, queueable: true }, async (payload) => { + const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); + const agentId = requireString(payload.agentId, "cto.removeAgent requires agentId."); + workerAgentService.removeAgent(agentId); + (args.workerHeartbeatService as { syncFromConfig?: () => void } | null | undefined)?.syncFromConfig?.(); + return {}; + }); register("cto.setAgentStatus", { viewerAllowed: true, queueable: true }, async (payload) => { const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); const agentId = requireString(payload.agentId, "cto.setAgentStatus requires agentId."); @@ -2408,6 +2547,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg register("prs.getActivity", { viewerAllowed: true }, async (payload) => args.prService.getActivity(requirePrId(payload, "prs.getActivity"))); register("prs.getDeployments", { viewerAllowed: true }, async (payload) => args.prService.getDeployments(requirePrId(payload, "prs.getDeployments"))); register("prs.createFromLane", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.createFromLane(parseCreatePrArgs(payload))); + register("prs.createQueue", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.createQueuePrs(parseCreateQueuePrsArgs(payload))); register("prs.linkToLane", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.linkToLane(parseLinkPrToLaneArgs(payload))); register("prs.draftDescription", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.draftDescription(parseDraftPrDescriptionArgs(payload))); @@ -2456,6 +2596,10 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg }); register("prs.aiReviewSummary", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.aiReviewSummary(parseAiReviewSummaryArgs(payload))); + register("prs.simulateIntegration", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.simulateIntegration(parseSimulateIntegrationArgs(payload))); + register("prs.commitIntegration", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.commitIntegration(parseCommitIntegrationArgs(payload))); register("prs.listIntegrationWorkflows", { viewerAllowed: true }, async (payload) => args.prService.listIntegrationWorkflows(parseListIntegrationWorkflowsArgs(payload))); register("prs.updateIntegrationProposal", { viewerAllowed: true, queueable: true }, async (payload) => { @@ -2476,6 +2620,10 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg args.prService.recheckIntegrationStep(parseRecheckIntegrationStepArgs(payload))); register("prs.landQueueNext", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.landQueueNext(parseLandQueueNextArgs(payload))); + register("prs.startQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.queueLandingService) throw new Error("Queue automation is not available."); + return args.queueLandingService.startQueue(parseStartQueueAutomationArgs(payload)); + }); register("prs.pauseQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { if (!args.queueLandingService) throw new Error("Queue automation is not available."); return args.queueLandingService.pauseQueue(parsePauseQueueAutomationArgs(payload).queueId); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index c74437a5e..2cfb4b925 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -2941,6 +2941,24 @@ describe("createAgentChatService", () => { expect(sessionsWithAutomation.length).toBe(1); }); + it("keeps archived sessions by default and can filter them out", async () => { + const { service } = createService(); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "", + modelId: "opencode/anthropic/claude-sonnet-4-6", + }); + await service.archiveSession({ sessionId: session.id }); + + const sessions = await service.listSessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0]!.archivedAt).toEqual(expect.any(String)); + + await expect(service.listSessions(undefined, { includeArchived: false })).resolves.toEqual([]); + }); + it("does not expose completion summaries as the session summary before the chat is ended", async () => { const { service } = createService(); @@ -8376,6 +8394,66 @@ describe("createAgentChatService", () => { expect(setPermissionMode.mock.invocationCallOrder[0]).toBeLessThan(send.mock.invocationCallOrder[1]); }); + it("does not reapply unchanged Claude permission controls during session updates", async () => { + 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-stable-permission", + slash_commands: [], + }; + return; + } + + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Ready" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-stable-permission", + setPermissionMode, + } as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + claudePermissionMode: "bypassPermissions", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Confirm readiness.", + }); + expect(setPermissionMode).toHaveBeenCalledWith("bypassPermissions"); + + setPermissionMode.mockClear(); + const updated = await service.updateSession({ + sessionId: session.id, + claudePermissionMode: "bypassPermissions", + }); + + expect(updated.claudePermissionMode).toBe("bypassPermissions"); + expect(setPermissionMode).not.toHaveBeenCalled(); + }); + it("uses Claude SDK query controls for plan mode when the wrapper lacks setPermissionMode", async () => { const setPermissionMode = vi.fn().mockResolvedValue(undefined); const send = vi.fn().mockResolvedValue(undefined); @@ -8829,6 +8907,72 @@ describe("createAgentChatService", () => { displayText: "Pearl UI audit handoff", }); }); + + it("coalesces streamed assistant fragments before applying transcript limits", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const events: AgentChatEventEnvelope[] = [{ + sessionId: session.id, + timestamp: "2026-05-18T23:40:00.000Z", + sequence: 1, + event: { + type: "user_message", + text: "Count to 6000.", + turnId: "turn-long", + }, + }]; + for (let index = 1; index <= 130; index += 1) { + events.push({ + sessionId: session.id, + timestamp: "2026-05-18T23:40:01.000Z", + sequence: (index * 2), + event: { + type: "text", + text: `${index}\n`, + messageId: "assistant-message-long", + turnId: "turn-long", + }, + }); + events.push({ + sessionId: session.id, + timestamp: "2026-05-18T23:40:01.000Z", + sequence: (index * 2) + 1, + event: { + type: "text", + text: `other-${index}\n`, + turnId: "turn-other", + }, + }); + } + fs.writeFileSync(path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`), "ignored\n", "utf8"); + vi.mocked(parseAgentChatTranscript).mockReturnValue(events); + + const transcript = await service.getChatTranscript({ + sessionId: session.id, + limit: 100, + maxChars: 40_000, + }); + + expect(transcript.totalEntries).toBe(3); + expect(transcript.truncated).toBe(false); + expect(transcript.entries).toHaveLength(3); + expect(transcript.entries[1]).toMatchObject({ + role: "assistant", + text: expect.stringMatching(/^1\n2\n3/), + turnId: "turn-long", + }); + expect(transcript.entries[1]!.text).toContain("\n130"); + expect(transcript.entries[2]).toMatchObject({ + role: "assistant", + text: expect.stringMatching(/^other-1\nother-2/), + turnId: "turn-other", + }); + }); }); describe("getChatEventHistory", () => { @@ -14159,6 +14303,10 @@ describe("createAgentChatService", () => { ); expect(readPersistedChatState(session.id).awaitingInput).toBe(true); + await expect(service.getSessionSummary(session.id)).resolves.toMatchObject({ + awaitingInput: true, + pendingInputItemId: approvalEvent.event.itemId, + }); await service.respondToInput({ sessionId: session.id, diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index bf16132fd..6d9eb1bfe 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -1699,7 +1699,7 @@ const CHAT_TRANSCRIPT_LIMIT_NOTICE = "\n[ADE] chat transcript limit reached (8MB const DEFAULT_TRANSCRIPT_READ_LIMIT = 20; const MAX_TRANSCRIPT_READ_LIMIT = 100; const DEFAULT_TRANSCRIPT_READ_CHARS = 8_000; -const MAX_TRANSCRIPT_READ_CHARS = 40_000; +const MAX_TRANSCRIPT_READ_CHARS = 120_000; const AUTOMATIC_MACOS_VM_CONTEXT_HEADER = "ADE macOS VM capability for this lane (automatic context)."; const AUTOMATIC_MACOS_VM_CONTEXT_ENDINGS = [ "- Tools: macos_vm_status, macos_vm_start, macos_vm_screenshot, macos_vm_click, macos_vm_type.", @@ -5459,10 +5459,35 @@ export function createAgentChatService(args: { const transcriptPath = resolveReadableChatPath(managed.transcriptPath, "agent_chat.transcript_read_skipped_path_outside_ade"); if (!transcriptPath) return []; const raw = fs.readFileSync(transcriptPath, "utf8"); - const entries: AgentChatTranscriptEntry[] = []; + type TranscriptDraftEntry = AgentChatTranscriptEntry & Partial; + const entries: TranscriptDraftEntry[] = []; + const assistantDraftsByKey = new Map(); + let assistantDraft: (AgentChatTranscriptEntry & BufferedAssistantText) | null = null; + const flushAssistantDraft = (): void => { + if (!assistantDraft) return; + const text = assistantDraft.text.trim(); + if (text.length > 0) { + entries.push({ + role: "assistant", + text, + timestamp: assistantDraft.timestamp, + ...(assistantDraft.turnId ? { turnId: assistantDraft.turnId } : {}), + }); + } + assistantDraft = null; + }; + const assistantTranscriptMergeKey = (event: Extract): string | null => { + const messageId = event.messageId?.trim(); + if (messageId) return `message:${messageId}`; + const turnId = event.turnId?.trim(); + if (turnId) return `turn:${turnId}`; + return null; + }; + for (const entry of parseAgentChatTranscript(raw)) { if (entry.sessionId !== managed.session.id) continue; if (entry.event.type === "user_message") { + flushAssistantDraft(); const text = entry.event.text.trim(); if (!text.length) continue; const displayText = typeof entry.event.displayText === "string" && entry.event.displayText.trim().length > 0 @@ -5478,17 +5503,54 @@ export function createAgentChatService(args: { continue; } if (entry.event.type === "text") { - const text = entry.event.text.trim(); - if (!text.length) continue; - entries.push({ + if (!entry.event.text.trim().length) continue; + const mergeKey = assistantTranscriptMergeKey(entry.event); + if (mergeKey) { + flushAssistantDraft(); + const existing = assistantDraftsByKey.get(mergeKey); + if (existing) { + existing.text = `${existing.text}${entry.event.text}`; + continue; + } + const draft: TranscriptDraftEntry = { + role: "assistant", + text: entry.event.text, + timestamp: entry.timestamp, + ...(entry.event.messageId ? { messageId: entry.event.messageId } : {}), + ...(entry.event.turnId ? { turnId: entry.event.turnId } : {}), + ...(entry.event.itemId ? { itemId: entry.event.itemId } : {}), + }; + assistantDraftsByKey.set(mergeKey, draft); + entries.push(draft); + continue; + } + if (assistantDraft && canAppendBufferedAssistantText(assistantDraft, entry.event)) { + assistantDraft.text = `${assistantDraft.text}${entry.event.text}`; + continue; + } + flushAssistantDraft(); + assistantDraft = { role: "assistant", - text, + text: entry.event.text, timestamp: entry.timestamp, - turnId: entry.event.turnId, - }); + ...(entry.event.messageId ? { messageId: entry.event.messageId } : {}), + ...(entry.event.turnId ? { turnId: entry.event.turnId } : {}), + ...(entry.event.itemId ? { itemId: entry.event.itemId } : {}), + }; + continue; } + flushAssistantDraft(); } - return entries; + flushAssistantDraft(); + return entries + .map((entry) => ({ + role: entry.role, + text: entry.text.trim(), + ...(entry.displayText ? { displayText: entry.displayText } : {}), + timestamp: entry.timestamp, + ...(entry.turnId ? { turnId: entry.turnId } : {}), + })) + .filter((entry) => entry.text.length > 0); } catch { return []; } @@ -19398,6 +19460,61 @@ export function createAgentChatService(args: { return managed.session; }; + const latestLivePendingInputItemId = (managed: ManagedChatSession | null | undefined): string | null => { + if (!managed) return null; + const localPending = managed.localPendingInputs.keys().next().value; + if (typeof localPending === "string" && localPending.trim().length) return localPending; + const runtime = managed.runtime; + if (!runtime) return null; + switch (runtime.kind) { + case "codex": + case "claude": { + const approval = runtime.approvals.keys().next().value; + return typeof approval === "string" && approval.trim().length ? approval : null; + } + case "opencode": { + const approval = runtime.pendingApprovals.keys().next().value; + return typeof approval === "string" && approval.trim().length ? approval : null; + } + case "cursor": + case "droid": { + const permission = runtime.permissionWaiters.keys().next().value; + return typeof permission === "string" && permission.trim().length ? permission : null; + } + } + }; + + const latestPendingInputItemIdFromEvents = (events: AgentChatEventEnvelope[]): string | null => { + const pending = new Set(); + for (const envelope of events) { + const event = envelope.event; + if (event.type === "approval_request" || event.type === "structured_question") { + if (typeof event.itemId === "string" && event.itemId.trim().length) { + pending.delete(event.itemId); + pending.add(event.itemId); + } + } else if (event.type === "pending_input_resolved") { + pending.delete(event.itemId); + } else if (event.type === "auto_approval_review") { + pending.delete(event.targetItemId); + } + } + let latest: string | null = null; + for (const id of pending) latest = id; + return latest; + }; + + const latestPendingInputItemIdForSession = ( + sessionId: string, + managed: ManagedChatSession | null | undefined, + ): string | null => { + const live = latestLivePendingInputItemId(managed); + if (live) return live; + return latestPendingInputItemIdFromEvents( + getChatEventHistory(sessionId, { maxEvents: 512 }).events, + ); + }; + const summarizeSessionRow = ( row: ReturnType["list"]>[number], ): AgentChatSessionSummary => { @@ -19426,6 +19543,10 @@ export function createAgentChatService(args: { ?? persisted?.sdkSessionId ?? null : null; + const sessionHasPendingInput = hasLivePendingInput(liveManaged) || persisted?.awaitingInput === true; + const pendingInputItemId = sessionHasPendingInput + ? latestPendingInputItemIdForSession(row.id, liveManaged) + : null; return { sessionId: row.id, laneId: row.laneId, @@ -19501,7 +19622,8 @@ export function createAgentChatService(args: { lastActivityAt: liveSession?.lastActivityAt ?? persisted?.updatedAt ?? row.endedAt ?? row.startedAt, lastOutputPreview: row.lastOutputPreview, summary: row.summary ?? null, - ...((hasLivePendingInput(liveManaged) || persisted?.awaitingInput === true) ? { awaitingInput: true } : {}), + ...(sessionHasPendingInput ? { awaitingInput: true } : {}), + ...(pendingInputItemId ? { pendingInputItemId } : {}), ...(liveSession?.threadId || persisted?.threadId ? { threadId: liveSession?.threadId ?? persisted?.threadId } : {}), @@ -19513,17 +19635,19 @@ export function createAgentChatService(args: { const listSessions = async ( laneId?: string, - options?: { includeIdentity?: boolean; includeAutomation?: boolean }, + options?: { includeIdentity?: boolean; includeAutomation?: boolean; includeArchived?: boolean }, ): Promise => { const rows = sessionService.list({ ...(laneId ? { laneId } : {}), limit: 500 }); const chatRows = rows.filter((row) => isChatToolType(row.toolType)); const includeIdentity = options?.includeIdentity === true; const includeAutomation = options?.includeAutomation === true; + const includeArchived = options?.includeArchived !== false; return chatRows .map((row) => summarizeSessionRow(row)) .filter((summary) => includeIdentity || !summary.identityKey) - .filter((summary) => includeAutomation || (summary.surface ?? "work") === "work"); + .filter((summary) => includeAutomation || (summary.surface ?? "work") === "work") + .filter((summary) => includeArchived || summary.archivedAt == null); }; const getSessionSummary = async (sessionId: string): Promise => { @@ -20748,6 +20872,9 @@ export function createAgentChatService(args: { const prevCodexSandbox = managed.session.codexSandbox; const prevCodexConfigSource = managed.session.codexConfigSource; const prevCodexFastMode = managed.session.codexFastMode === true; + const prevClaudeTurnPermissionMode = managed.session.provider === "claude" + ? resolveClaudeTurnPermissionMode(managed) + : null; if (modelId !== undefined) { const nextModelId = String(modelId ?? "").trim(); @@ -20974,7 +21101,11 @@ export function createAgentChatService(args: { managed.runtime.threadResumed = false; managed.runtime.canAttachResumedTurnStart = false; } - if (managed.runtime?.kind === "claude" && managed.runtime.query) { + if ( + managed.runtime?.kind === "claude" + && managed.runtime.query + && resolveClaudeTurnPermissionMode(managed) !== prevClaudeTurnPermissionMode + ) { const turnPermissionMode = resolveClaudeTurnPermissionMode(managed); const control = getClaudeQueryControl(managed.runtime.query); if (typeof control.setPermissionMode === "function") { diff --git a/apps/desktop/src/main/services/git/gitOperationsService.test.ts b/apps/desktop/src/main/services/git/gitOperationsService.test.ts index 9bcd2f966..e517234cf 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.test.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.test.ts @@ -20,6 +20,7 @@ function createTestGitOperationsService( ) { const mockStart = vi.fn().mockReturnValue({ operationId: "op-1" }); const mockFinish = vi.fn(); + const mockInvalidateListCache = vi.fn(); const mockLogger = { info: vi.fn(), warn: vi.fn(), @@ -35,6 +36,7 @@ function createTestGitOperationsService( worktreePath: overrides.worktreePath ?? "/tmp/ade-lane", laneType: "worktree", }), + invalidateListCache: mockInvalidateListCache, } as any, operationService: { start: mockStart, @@ -55,6 +57,7 @@ function createTestGitOperationsService( service, mockStart, mockFinish, + mockInvalidateListCache, mockLogger, }; } @@ -67,7 +70,7 @@ describe("gitOperationsService.stashClear", () => { it("calls git stash clear with the lane worktree path and returns the action result", async () => { mockGit.getHeadSha.mockResolvedValue("abc123"); mockGit.runGitOrThrow.mockResolvedValue(undefined); - const { service, mockStart, mockFinish } = createTestGitOperationsService(); + const { service, mockStart, mockFinish, mockInvalidateListCache } = createTestGitOperationsService(); const result = await service.stashClear({ laneId: "lane-1" }); @@ -92,6 +95,7 @@ describe("gitOperationsService.stashClear", () => { status: "succeeded", }), ); + expect(mockInvalidateListCache).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/desktop/src/main/services/git/gitOperationsService.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index d54d94cc3..7a28467d8 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.ts @@ -400,6 +400,11 @@ export function createGitOperationsService({ throw error; } finally { invalidateLaneReadCache(laneId); + try { + laneService.invalidateListCache?.(); + } catch { + // Never fail git operation cleanup due to cache invalidation issues. + } } }; diff --git a/apps/desktop/src/main/services/notifications/notificationEventBus.test.ts b/apps/desktop/src/main/services/notifications/notificationEventBus.test.ts index 72a2511f2..7a71dde31 100644 --- a/apps/desktop/src/main/services/notifications/notificationEventBus.test.ts +++ b/apps/desktop/src/main/services/notifications/notificationEventBus.test.ts @@ -221,6 +221,25 @@ describe("notificationEventBus", () => { expect(result.reason).toBe("no_token"); }); + it("sendTestPush delivers an in-app notification when APNs is unavailable but the device is connected", async () => { + const inAppSpy = vi.fn(); + const bus = createNotificationEventBus({ + logger: createLogger(), + apnsService: null, + listPushTargets: () => [makeTarget()], + getPrefsForDevice: () => prefsOn, + sendInAppNotification: inAppSpy, + isDeviceConnected: () => true, + }); + const result = await bus.sendTestPush("device-A", "alert"); + expect(result).toEqual({ ok: true, reason: "in_app_only" }); + expect(inAppSpy).toHaveBeenCalledWith("device-A", expect.objectContaining({ + category: "system", + title: "ADE test push", + collapseId: "ade:test", + })); + }); + it("sendTestPush uses `bundle.push-type.liveactivity` topic when kind=activity", async () => { const { service, calls } = makeApnsService(); const bus = createNotificationEventBus({ diff --git a/apps/desktop/src/main/services/notifications/notificationEventBus.ts b/apps/desktop/src/main/services/notifications/notificationEventBus.ts index c4d5b25ab..3f285208a 100644 --- a/apps/desktop/src/main/services/notifications/notificationEventBus.ts +++ b/apps/desktop/src/main/services/notifications/notificationEventBus.ts @@ -303,7 +303,18 @@ export function createNotificationEventBus(args: NotificationEventBusArgs) { const activityUpdateToken = target.activityUpdateTokens ? Object.values(target.activityUpdateTokens)[0] : null; const token = kind === "alert" ? target.alertToken : activityUpdateToken ?? target.activityStartToken; if (!token) return { ok: false, reason: "no_token" }; - if (!args.apnsService || !args.apnsService.isConfigured()) return { ok: false, reason: "apns_not_configured" }; + if (!args.apnsService || !args.apnsService.isConfigured()) { + if (args.isDeviceConnected(deviceId)) { + args.sendInAppNotification(deviceId, { + category: "system", + title: "ADE test push", + body: "This device reached its paired ADE machine.", + collapseId: "ade:test", + }); + return { ok: true, reason: "in_app_only" }; + } + return { ok: false, reason: "apns_not_configured" }; + } const topic = kind === "activity" ? `${target.bundleId}.push-type.liveactivity` : target.bundleId; const activityEvent = activityUpdateToken ? "update" : "start"; diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 17b935069..60f34a719 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -433,6 +433,35 @@ describe("ptyService", () => { expect(result.pid).toBe(12345); }); + it("starts plain shell sessions without user startup files", async () => { + const previousShell = process.env.SHELL; + process.env.SHELL = "/bin/zsh"; + try { + const { service, loadPty } = createHarness(); + await service.create({ + laneId: "lane-1", + title: "Shell", + cols: 80, + rows: 24, + toolType: "shell", + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + expect(ptyLib.spawn).toHaveBeenCalledWith( + "/bin/zsh", + ["-f"], + expect.objectContaining({ + env: expect.objectContaining({ + ZDOTDIR: "/var/empty", + }), + }), + ); + } finally { + if (previousShell == null) delete process.env.SHELL; + else process.env.SHELL = previousShell; + } + }); + it("uses a caller-provided sessionId when creating a new tracked session", async () => { const { service, sessionService } = createHarness(); const result = await service.create({ diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 9c3a01261..d0f5670f7 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -360,7 +360,7 @@ type PtyDataListener = (event: PtyDataEvent & { laneId: string }) => void; type PtyExitListener = (event: PtyExitEvent & { laneId: string }) => void; -type ShellSpec = { file: string; args: string[] }; +type ShellSpec = { file: string; args: string[]; env?: Record }; type HeadlessTerminalInstance = InstanceType; type SerializeAddonInstance = InstanceType; @@ -372,19 +372,32 @@ type TerminalSnapshotMirror = { lastErrorAt: number; }; -function resolveShellCandidates(): ShellSpec[] { +function cleanShellSpec(file: string): ShellSpec { + const name = path.basename(file).toLowerCase(); + if (name === "zsh") return { file, args: ["-f"], env: { ZDOTDIR: "/var/empty" } }; + if (name === "bash") return { file, args: ["--noprofile", "--norc"], env: { BASH_ENV: "" } }; + if (name === "fish") return { file, args: ["--no-config"] }; + return { file, args: [], env: { ENV: "" } }; +} + +function resolveShellCandidates(options: { clean?: boolean } = {}): ShellSpec[] { if (process.platform === "win32") { - return [ - { file: "powershell.exe", args: [] }, - { file: "cmd.exe", args: [] } - ]; + return options.clean + ? [ + { file: "powershell.exe", args: ["-NoLogo", "-NoProfile"] }, + { file: "cmd.exe", args: ["/d"] }, + ] + : [ + { file: "powershell.exe", args: [] }, + { file: "cmd.exe", args: [] }, + ]; } const candidates: string[] = []; const fromEnv = process.env.SHELL?.trim(); if (fromEnv) candidates.push(fromEnv); candidates.push("/bin/zsh", "/bin/bash", "/bin/sh"); const uniq = Array.from(new Set(candidates.filter(Boolean))); - return uniq.map((file) => ({ file, args: [] })); + return uniq.map((file) => options.clean ? cleanShellSpec(file) : { file, args: [] }); } function quotePosixShellArg(value: string): string { @@ -2470,11 +2483,12 @@ export function createPtyService({ } } - const shellCandidates = resolveShellCandidates(); let pty: IPty; let selectedShell: ShellSpec | null = null; const directCommand = typeof args.command === "string" ? args.command.trim() : ""; const directArgs = Array.isArray(args.args) ? args.args.filter((value): value is string => typeof value === "string") : []; + const useCleanInteractiveShell = toolTypeHint === "shell" && !directCommand && !startupCommand; + const shellCandidates = resolveShellCandidates({ clean: useCleanInteractiveShell }); let launchedDirectCommand = false; try { const spawnHelperRepair = ensureNodePtySpawnHelperExecutable(); @@ -2513,7 +2527,10 @@ export function createPtyService({ if (!created && (!directCommand || startupCommand)) { for (const shell of shellCandidates) { try { - created = ptyLib.spawn(shell.file, shell.args, opts); + created = ptyLib.spawn(shell.file, shell.args, { + ...opts, + env: shell.env ? { ...launchEnv, ...shell.env } : launchEnv, + }); selectedShell = shell; launchedDirectCommand = false; break; diff --git a/apps/desktop/src/main/services/state/kvDb.sync.test.ts b/apps/desktop/src/main/services/state/kvDb.sync.test.ts index 40201d117..6343a9b1b 100644 --- a/apps/desktop/src/main/services/state/kvDb.sync.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.sync.test.ts @@ -106,6 +106,55 @@ describe.skipIf(!isCrsqliteAvailable())("kvDb sync foundation", () => { db2.close(); }); + it("normalizes legacy text primary keys before applying remote CRDT changes", async () => { + const db1 = await openKvDb(makeDbPath("ade-kvdb-sync-legacy-pk-a-"), createLogger() as any); + const db2 = await openKvDb(makeDbPath("ade-kvdb-sync-legacy-pk-b-"), createLogger() as any); + + db1.run( + `insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) + values (?, ?, ?, ?, ?, ?)`, + ["project-legacy", "/repo/legacy", "Legacy", "main", "2026-03-15T00:00:00.000Z", "2026-03-15T00:00:00.000Z"] + ); + db1.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path, + is_edit_protected, parent_lane_id, color, icon, tags_json, folder, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "lane-legacy", + "project-legacy", + "Legacy Lane", + null, + "worktree", + "main", + "feature/legacy", + "/repo/legacy/.ade/worktrees/lane-legacy", + null, + 0, + null, + null, + null, + null, + null, + "active", + "2026-03-15T00:00:00.000Z", + null, + ] + ); + + const legacyChanges = db1.sync.exportChangesSince(0).map((change) => { + if (change.table === "projects") return { ...change, pk: "project-legacy" }; + if (change.table === "lanes") return { ...change, pk: "lane-legacy" }; + return change; + }); + + expect(() => db2.sync.applyChanges(legacyChanges)).not.toThrow(); + expect(db2.get<{ name: string }>("select name from lanes where id = ?", ["lane-legacy"])?.name).toBe("Legacy Lane"); + + db1.close(); + db2.close(); + }); + it("repairs a legacy projects unique constraint before CRR marking", async () => { const dbPath = makeDbPath("ade-kvdb-sync-projects-legacy-"); const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: new (path: string) => { exec: (sql: string) => void; close: () => void } }; diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 31276cb72..7a44998a6 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -675,6 +675,91 @@ function encodeSyncScalar(value: unknown): SyncScalar { throw new Error(`Unsupported sync scalar type: ${typeof value}`); } +function isSyncScalarBytes(value: SyncScalar): value is { type: "bytes"; base64: string } { + return Boolean( + value + && typeof value === "object" + && "type" in value + && value.type === "bytes" + && typeof value.base64 === "string" + ); +} + +function packedCrsqlPrimaryKey(value: SyncScalar): SyncScalar | null { + if (isSyncScalarBytes(value)) { + const bytes = Buffer.from(value.base64, "base64"); + return bytes.length >= 2 && bytes[0] > 0 ? value : null; + } + + if (typeof value === "string") { + const textBytes = Buffer.from(value, "utf8"); + if (textBytes.length > 0xff) return null; + return { + type: "bytes", + base64: Buffer.concat([Buffer.from([0x01, 0x0b, textBytes.length]), textBytes]).toString("base64"), + }; + } + + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) return null; + if (value === 0) return { type: "bytes", base64: Buffer.from([0x01, 0x08]).toString("base64") }; + if (value === 1) return { type: "bytes", base64: Buffer.from([0x01, 0x09]).toString("base64") }; + if (value >= -0x80 && value <= 0x7f) { + const bytes = Buffer.alloc(3); + bytes[0] = 0x01; + bytes[1] = 0x01; + bytes.writeInt8(value, 2); + return { type: "bytes", base64: bytes.toString("base64") }; + } + if (value >= -0x8000 && value <= 0x7fff) { + const bytes = Buffer.alloc(4); + bytes[0] = 0x01; + bytes[1] = 0x02; + bytes.writeInt16BE(value, 2); + return { type: "bytes", base64: bytes.toString("base64") }; + } + if (value >= -0x80000000 && value <= 0x7fffffff) { + const bytes = Buffer.alloc(6); + bytes[0] = 0x01; + bytes[1] = 0x04; + bytes.writeInt32BE(value, 2); + return { type: "bytes", base64: bytes.toString("base64") }; + } + const bytes = Buffer.alloc(10); + bytes[0] = 0x01; + bytes[1] = 0x06; + bytes.writeBigInt64BE(BigInt(value), 2); + return { type: "bytes", base64: bytes.toString("base64") }; + } + + return null; +} + +function normalizeIncomingCrsqlChange(db: DatabaseSyncType, change: CrsqlChangeRow): CrsqlChangeRow { + const tableInfo = allRows<{ pk: number }>( + db, + `pragma table_info('${change.table.replace(/'/g, "''")}')` + ); + const primaryKeyColumns = tableInfo.filter((column) => Number(column.pk) > 0); + if (primaryKeyColumns.length !== 1) { + const shape = primaryKeyColumns.length === 0 + ? "no primary key" + : `${primaryKeyColumns.length} primary key columns`; + throw new Error(`Unsupported incoming CRSQL primary key for ${change.table}.${change.cid}: ${shape}.`); + } + + if (isSyncScalarBytes(change.pk)) { + const packedPk = packedCrsqlPrimaryKey(change.pk); + if (packedPk) return change; + throw new Error(`Unsupported incoming CRSQL primary key for ${change.table}.${change.cid}: invalid packed key.`); + } + + const packedPk = packedCrsqlPrimaryKey(change.pk); + if (packedPk) return { ...change, pk: packedPk }; + + throw new Error(`Unsupported incoming CRSQL primary key for ${change.table}.${change.cid}: unsupported scalar shape.`); +} + function rebuildUnifiedMemoriesFts(db: DatabaseSyncType): void { if (!rawHasTable(db, "unified_memories") || !rawHasTable(db, "unified_memories_fts")) { return; @@ -3908,7 +3993,8 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { const touchedTables = new Set(); runStatement(db, "begin"); try { - for (const change of changes) { + for (const rawChange of changes) { + const change = normalizeIncomingCrsqlChange(db, rawChange); const result = runStatement( db, `insert or ignore into crsql_changes ([table], pk, cid, val, col_version, db_version, site_id, cl, seq) diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts index 4f065f475..9539d4e61 100644 --- a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts +++ b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { DEFAULT_NOTIFICATION_PREFERENCES } from "../../../shared/types/sync"; +import { DEFAULT_NOTIFICATION_PREFERENCES, normalizeNotificationPreferences } from "../../../shared/types/sync"; import { openKvDb } from "../state/kvDb"; import { createDeviceRegistryService } from "./deviceRegistryService"; @@ -187,6 +187,22 @@ describe("deviceRegistryService", () => { db2.close(); }); + it("drops inactive per-session notification overrides while normalizing", () => { + const prefs = normalizeNotificationPreferences({ + ...DEFAULT_NOTIFICATION_PREFERENCES, + perSessionOverrides: { + inactive: { muted: false, awaitingInputOnly: false }, + muted: { muted: true, awaitingInputOnly: false }, + awaiting: { muted: false, awaitingInputOnly: true }, + }, + }); + + expect(prefs.perSessionOverrides).toEqual({ + muted: { muted: true, awaitingInputOnly: false }, + awaiting: { muted: false, awaitingInputOnly: true }, + }); + }); + it("stores workspace Live Activity update tokens and invalidates only the rejected token", async () => { const projectRoot = makeProjectRoot("ade-device-registry-apns-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index bf142e2be..5c96aaa83 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -1400,6 +1400,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const writeBySessionId = vi.fn().mockReturnValue(true); const resizeBySessionId = vi.fn().mockReturnValue(true); const readTranscriptTail = vi.fn(async () => "prior output\n"); + const updateSessionMeta = vi.fn(); const host = createSyncHostService({ db: brainDb, @@ -1531,6 +1532,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { summary: null, resumeCommand: "codex resume picker", }), + updateMeta: updateSessionMeta, readTranscriptTail: async () => "prior output\n", } as any, ptyService: { @@ -1701,8 +1703,10 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const startCliResult = await client.queue.next("command_result"); const startCliPayload = startCliResult.payload as { ok: boolean; + error?: { message?: string }; result: { sessionId: string; ptyId: string; session: { id: string; toolType: string } }; }; + expect(startCliPayload.ok, startCliPayload.error?.message).toBe(true); expect(startCliPayload.result.sessionId).toBe("session-1"); expect(startCliPayload.result.ptyId).toBe("pty-1"); expect(startCliPayload.result.session).toEqual(expect.objectContaining({ @@ -1710,7 +1714,15 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { toolType: "codex", })); expect(createSpy).toHaveBeenCalledTimes(2); - expect(writeBySessionId).toHaveBeenCalledWith("session-1", "fix from phone\r"); + const startCliCreateCall = createSpy.mock.calls.at(-1)?.[0]; + expect(startCliCreateCall?.command).toBe("codex"); + expect(startCliCreateCall?.args.at(-1)).toContain("fix from phone"); + expect(writeBySessionId).toHaveBeenCalledTimes(1); + expect(updateSessionMeta).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "session-1", + title: "Fix from phone", + manuallyNamed: false, + })); await waitFor(() => fs.readFileSync(commandLedgerPath, "utf8").includes("cmd-start-cli")); const startCliLedger = fs.readFileSync(commandLedgerPath, "utf8"); @@ -1738,8 +1750,10 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const startCliReplayResult = await client.queue.next("command_result"); const startCliReplayPayload = startCliReplayResult.payload as { ok: boolean; + error?: { message?: string }; result: { sessionId: string; ptyId: string; session: { id: string; toolType: string } }; }; + expect(startCliReplayPayload.ok, startCliReplayPayload.error?.message).toBe(true); expect(startCliReplayPayload.result.sessionId).toBe("session-1"); expect(startCliReplayPayload.result.ptyId).toBe("pty-1"); expect(startCliReplayPayload.result.session).toEqual(expect.objectContaining({ diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 0306f3cce..3772d425b 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -25,6 +25,7 @@ const IOS_REMOTE_COMMAND_ACTIONS = [ "lanes.importBranch", "lanes.createChild", "lanes.attach", + "lanes.listUnregisteredWorktrees", "lanes.adoptAttached", "lanes.rename", "lanes.reparent", @@ -96,6 +97,7 @@ const IOS_REMOTE_COMMAND_ACTIONS = [ "cto.ensureSession", "cto.ensureAgentSession", "prs.createFromLane", + "prs.createQueue", "prs.land", "prs.close", "prs.reopen", @@ -111,6 +113,8 @@ const IOS_REMOTE_COMMAND_ACTIONS = [ "prs.setReviewThreadResolved", "prs.reactToComment", "prs.aiReviewSummary", + "prs.simulateIntegration", + "prs.commitIntegration", "prs.listIntegrationWorkflows", "prs.updateIntegrationProposal", "prs.deleteIntegrationProposal", @@ -120,6 +124,7 @@ const IOS_REMOTE_COMMAND_ACTIONS = [ "prs.startIntegrationResolution", "prs.recheckIntegrationStep", "prs.landQueueNext", + "prs.startQueueAutomation", "prs.pauseQueueAutomation", "prs.resumeQueueAutomation", "prs.cancelQueueAutomation", @@ -190,6 +195,9 @@ function createMockLaneService() { targetProfile: null, }), attach: vi.fn().mockResolvedValue({ id: "attached-1" }), + listUnregisteredWorktrees: vi.fn().mockResolvedValue([ + { path: "/repo/.ade/unregistered-lanes/feature-one", branch: "feature/one" }, + ]), adoptAttached: vi.fn().mockResolvedValue({ ok: true }), rename: vi.fn(), reparent: vi.fn().mockResolvedValue({ ok: true }), @@ -220,6 +228,7 @@ function createMockPrService() { getComments: vi.fn().mockResolvedValue([]), getFiles: vi.fn().mockResolvedValue([]), createFromLane: vi.fn().mockResolvedValue({ prId: "pr-1" }), + createQueuePrs: vi.fn().mockResolvedValue({ groupId: "group-1", prs: [], errors: [] }), draftDescription: vi.fn().mockResolvedValue({ title: "Draft title", body: "Draft body" }), land: vi.fn().mockResolvedValue({ ok: true }), closePr: vi.fn().mockResolvedValue(undefined), @@ -235,6 +244,8 @@ function createMockPrService() { setReviewThreadResolved: vi.fn().mockResolvedValue({ threadId: "thread-1", isResolved: true }), reactToComment: vi.fn().mockResolvedValue(undefined), aiReviewSummary: vi.fn().mockResolvedValue({ summary: "ready" }), + simulateIntegration: vi.fn().mockResolvedValue({ proposalId: "proposal-1", status: "proposed", overallOutcome: "clean" }), + commitIntegration: vi.fn().mockResolvedValue({ groupId: "group-1", integrationLaneId: "lane-int", pr: { id: "pr-1" }, mergeResults: [] }), listIntegrationWorkflows: vi.fn().mockResolvedValue([]), updateIntegrationProposal: vi.fn().mockResolvedValue(undefined), deleteIntegrationProposal: vi.fn().mockResolvedValue({ proposalId: "proposal-1", integrationLaneId: null, deletedIntegrationLane: false }), @@ -317,6 +328,7 @@ function createMockIssueInventoryService() { function createMockQueueLandingService() { return { + startQueue: vi.fn().mockReturnValue({ queueId: "queue-1", state: "landing" }), pauseQueue: vi.fn().mockReturnValue({ queueId: "queue-1", state: "paused" }), resumeQueue: vi.fn().mockReturnValue({ queueId: "queue-1", state: "landing" }), cancelQueue: vi.fn().mockReturnValue({ queueId: "queue-1", state: "cancelled" }), @@ -344,6 +356,7 @@ function createMockSessionService() { return { list: vi.fn().mockReturnValue([]), get: vi.fn().mockReturnValue(null), + updateMeta: vi.fn(), } as any; } @@ -769,6 +782,14 @@ describe("createSyncRemoteCommandService", () => { .rejects.toThrow(/branchName/); }); + it("lanes.listUnregisteredWorktrees routes to laneService", async () => { + const result = await service.execute(makePayload("lanes.listUnregisteredWorktrees")); + expect(laneService.listUnregisteredWorktrees).toHaveBeenCalledTimes(1); + expect(result).toEqual([ + { path: "/repo/.ade/unregistered-lanes/feature-one", branch: "feature/one" }, + ]); + }); + it("lanes.rename parses laneId and name", async () => { await service.execute(makePayload("lanes.rename", { laneId: "lane-1", @@ -915,6 +936,35 @@ describe("createSyncRemoteCommandService", () => { .rejects.toThrow("prs.createFromLane requires laneId and title."); }); + it("prs.createQueue parses lane order, target branch, and options", async () => { + const result = await service.execute(makePayload("prs.createQueue", { + laneIds: ["lane-1", "lane-2"], + targetBranch: "main", + titles: { "lane-1": "First", "lane-2": "Second" }, + draft: true, + autoRebase: false, + ciGating: true, + queueName: "Mobile queue", + allowDirtyWorktree: true, + })); + expect(prService.createQueuePrs).toHaveBeenCalledWith({ + laneIds: ["lane-1", "lane-2"], + targetBranch: "main", + titles: { "lane-1": "First", "lane-2": "Second" }, + draft: true, + autoRebase: false, + ciGating: true, + queueName: "Mobile queue", + allowDirtyWorktree: true, + }); + expect(result).toEqual({ groupId: "group-1", prs: [], errors: [] }); + }); + + it("prs.createQueue requires laneIds and targetBranch", async () => { + await expect(service.execute(makePayload("prs.createQueue", { laneIds: ["lane-1"] }))) + .rejects.toThrow("prs.createQueue requires targetBranch."); + }); + it("prs.draftDescription parses laneId and optional model controls", async () => { const result = await service.execute(makePayload("prs.draftDescription", { laneId: "lane-1", @@ -1011,6 +1061,83 @@ describe("createSyncRemoteCommandService", () => { expect(result).toEqual({ id: "comment-1", body: "Looks good" }); }); + it("prs.simulateIntegration parses source lanes, base branch, and merge target", async () => { + const result = await service.execute(makePayload("prs.simulateIntegration", { + sourceLaneIds: ["lane-a", "lane-b"], + baseBranch: "main", + persist: true, + mergeIntoLaneId: "lane-int", + })); + expect(prService.simulateIntegration).toHaveBeenCalledWith({ + sourceLaneIds: ["lane-a", "lane-b"], + baseBranch: "main", + persist: true, + mergeIntoLaneId: "lane-int", + }); + expect(result).toEqual({ proposalId: "proposal-1", status: "proposed", overallOutcome: "clean" }); + }); + + it("prs.commitIntegration parses proposal metadata and options", async () => { + const result = await service.execute(makePayload("prs.commitIntegration", { + proposalId: "proposal-1", + integrationLaneName: "Mobile integration", + title: "Integration PR", + body: "Body", + draft: true, + pauseOnConflict: true, + allowDirtyWorktree: false, + preferredIntegrationLaneId: null, + })); + expect(prService.commitIntegration).toHaveBeenCalledWith({ + proposalId: "proposal-1", + integrationLaneName: "Mobile integration", + title: "Integration PR", + body: "Body", + draft: true, + pauseOnConflict: true, + allowDirtyWorktree: false, + preferredIntegrationLaneId: null, + }); + expect(result).toEqual({ groupId: "group-1", integrationLaneId: "lane-int", pr: { id: "pr-1" }, mergeResults: [] }); + }); + + it("prs.startQueueAutomation parses queue automation options", async () => { + const result = await service.execute(makePayload("prs.startQueueAutomation", { + groupId: "group-1", + method: "squash", + archiveLane: true, + autoResolve: true, + ciGating: false, + resolverProvider: "codex", + resolverModel: "gpt-5.4", + reasoningEffort: "medium", + permissionMode: "default", + confidenceThreshold: 0.82, + originLabel: "Mobile audit", + })); + expect(queueLandingService.startQueue).toHaveBeenCalledWith({ + groupId: "group-1", + method: "squash", + archiveLane: true, + autoResolve: true, + ciGating: false, + resolverProvider: "codex", + resolverModel: "gpt-5.4", + reasoningEffort: "medium", + permissionMode: "default", + confidenceThreshold: 0.82, + originLabel: "Mobile audit", + }); + expect(result).toEqual({ queueId: "queue-1", state: "landing" }); + }); + + it("prs.startQueueAutomation requires a merge method", async () => { + await expect(service.execute(makePayload("prs.startQueueAutomation", { + groupId: "group-1", + method: "invalid", + }))).rejects.toThrow("prs.startQueueAutomation requires method to be merge, squash, or rebase."); + }); + it("prs.getMobileSnapshot is viewer-allowed and returns the aggregated payload", async () => { const policy = service.getPolicy("prs.getMobileSnapshot"); expect(policy).not.toBeNull(); @@ -1348,7 +1475,7 @@ describe("createSyncRemoteCommandService", () => { sessionId: "sess-1", text: "hello", }, { - awaitDispatch: true, + awaitDispatch: false, }); expect(result).toEqual({ ok: true }); }); @@ -1405,6 +1532,16 @@ describe("createSyncRemoteCommandService", () => { expect(result).toEqual({ ok: true }); }); + it("chat.steer returns the backend queued result for mobile clients", async () => { + agentChatService.steer.mockResolvedValueOnce({ steerId: "steer-1", queued: true }); + const result = await service.execute(makePayload("chat.steer", { + sessionId: "sess-1", + text: "change direction", + })); + + expect(result).toEqual({ ok: true, steerId: "steer-1", queued: true }); + }); + it("chat.steer throws when text is missing", async () => { await expect(service.execute(makePayload("chat.steer", { sessionId: "sess-1" }))) .rejects.toThrow("chat.steer requires text."); @@ -1487,7 +1624,7 @@ describe("createSyncRemoteCommandService", () => { { path: "b", type: "file" }, ], }, { - awaitDispatch: true, + awaitDispatch: false, }); }); @@ -1555,7 +1692,7 @@ describe("createSyncRemoteCommandService", () => { executionMode: "autonomous", interactionMode: "chat", }, { - awaitDispatch: true, + awaitDispatch: false, }); }); @@ -1628,6 +1765,19 @@ describe("createSyncRemoteCommandService", () => { expect(result).toEqual({ groups: [], fetchedAt: "2026-01-01T00:00:00.000Z" }); }); + it("chat.listSessions forwards automation and archived filters", async () => { + await service.execute(makePayload("chat.listSessions", { + laneId: "lane-1", + includeAutomation: true, + includeArchived: true, + })); + + expect(agentChatService.listSessions).toHaveBeenCalledWith("lane-1", { + includeAutomation: true, + includeArchived: true, + }); + }); + it("chat commands throw when agentChatService is not available", async () => { const svcNoChat = createSyncRemoteCommandService({ laneService, @@ -1656,6 +1806,57 @@ describe("createSyncRemoteCommandService", () => { ); }); + it("work.listSessions forwards pending input item ids for awaiting chat sessions", async () => { + sessionService.list.mockReturnValueOnce([{ + id: "chat-awaiting", + laneId: "lane-1", + laneName: "Primary", + ptyId: null, + tracked: true, + pinned: false, + manuallyNamed: false, + goal: null, + toolType: "codex-chat", + title: "Needs approval", + status: "running", + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: null, + archivedAt: null, + exitCode: null, + transcriptPath: "", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "running", + resumeCommand: null, + resumeMetadata: null, + chatIdleSinceAt: null, + }]); + agentChatService.listSessions.mockResolvedValueOnce([{ + sessionId: "chat-awaiting", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + status: "active", + awaitingInput: true, + pendingInputItemId: "pending-input-1", + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T00:00:01.000Z", + lastOutputPreview: null, + summary: null, + }]); + + const result = await service.execute(makePayload("work.listSessions", { laneId: "lane-1" })); + + expect(result).toMatchObject([{ + id: "chat-awaiting", + runtimeState: "waiting-input", + pendingInputItemId: "pending-input-1", + }]); + }); + it("work.runQuickCommand parses laneId + title + startupCommand", async () => { await service.execute(makePayload("work.runQuickCommand", { laneId: "lane-1", @@ -1724,13 +1925,15 @@ describe("createSyncRemoteCommandService", () => { provider: "codex", permissionMode: "edit", initialInput: "fix the tests", + modelId: "openai/gpt-5.5", + reasoningEffort: "xhigh", cols: 70, rows: 24, })); expect(ptyService.create).toHaveBeenCalledWith( expect.objectContaining({ laneId: "lane-1", - title: "Codex", + title: "Fix the tests", toolType: "codex", cols: 70, rows: 24, @@ -1738,7 +1941,15 @@ describe("createSyncRemoteCommandService", () => { startupCommand: expect.stringContaining("codex"), }), ); - expect(ptyService.writeBySessionId).toHaveBeenCalledWith("pty-1", "fix the tests\r"); + const createCall = ptyService.create.mock.calls.at(-1)?.[0]; + expect(createCall?.args).toEqual(expect.arrayContaining(["--model", "gpt-5.5", "-c", "model_reasoning_effort=\"xhigh\""])); + expect(createCall?.args.at(-1)).toContain("fix the tests"); + expect(ptyService.writeBySessionId).not.toHaveBeenCalled(); + expect(sessionService.updateMeta).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "pty-1", + goal: "fix the tests", + title: "Fix the tests", + })); expect(result).toEqual(expect.objectContaining({ sessionId: "pty-1", ptyId: "pty-proc", @@ -1775,12 +1986,19 @@ describe("createSyncRemoteCommandService", () => { }); it("work.startCliSession opens a shell without accepting arbitrary startup commands", async () => { - await service.execute(makePayload("work.startCliSession", { - laneId: "lane-1", - provider: "shell", - startupCommand: "rm -rf nope", - initialInput: "rm -rf also-nope", - })); + const previousShell = process.env.SHELL; + process.env.SHELL = "/bin/zsh"; + try { + await service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "shell", + startupCommand: "rm -rf nope", + initialInput: "rm -rf also-nope", + })); + } finally { + if (previousShell == null) delete process.env.SHELL; + else process.env.SHELL = previousShell; + } expect(ptyService.create).toHaveBeenCalledWith( expect.not.objectContaining({ startupCommand: "rm -rf nope", @@ -1793,6 +2011,13 @@ describe("createSyncRemoteCommandService", () => { toolType: "shell", }), ); + const call = ptyService.create.mock.calls.at(-1)?.[0]; + expect(call?.command).toBeTruthy(); + expect(call?.args).toEqual(expect.any(Array)); + expect(call?.env).toEqual(expect.objectContaining({ + ZDOTDIR: "/var/empty", + })); + expect(call).not.toHaveProperty("startupCommand"); expect(ptyService.writeBySessionId).not.toHaveBeenCalled(); }); @@ -1826,6 +2051,17 @@ describe("createSyncRemoteCommandService", () => { expect(call?.toolType).toBe("claude"); }); + it("work.startCliSession preserves Claude auto permission mode from mobile", async () => { + await service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "claude", + permissionMode: "auto", + })); + const call = ptyService.create.mock.calls.at(-1)?.[0]; + expect(call?.args).toEqual(expect.arrayContaining(["--permission-mode", "auto"])); + expect(call?.startupCommand).toContain("--permission-mode auto"); + }); + it("work.startCliSession passes Claude model and confirms initial input", async () => { vi.useFakeTimers(); try { @@ -1882,7 +2118,7 @@ describe("createSyncRemoteCommandService", () => { await expect(service.execute(makePayload("work.startCliSession", { laneId: "lane-1", - provider: "codex", + provider: "cursor", initialInput: "fix the tests", }))).rejects.toThrow("could not write initialInput"); expect(ptyService.dispose).toHaveBeenCalledWith({ ptyId: "pty-proc", sessionId: "pty-1" }); @@ -2379,6 +2615,35 @@ describe("createSyncRemoteCommandService", () => { status: "running", }); }); + + it("cto.getAgentCoreMemory reads from workerAgentService so headless heartbeat stubs still work", async () => { + const memory = { + version: 3, + updatedAt: "2026-04-01T00:00:00.000Z", + projectSummary: "Mobile worker fixture.", + criticalConventions: ["Drive the real Simulator"], + userPreferences: [], + activeFocus: ["CTO detail"], + notes: [], + }; + workerAgentService.getCoreMemory.mockReturnValueOnce(memory); + + const result = await service.execute(makePayload("cto.getAgentCoreMemory", { + agentId: "worker-42", + })); + + expect(result).toEqual(memory); + expect(workerAgentService.getCoreMemory).toHaveBeenCalledWith("worker-42"); + }); + + it("cto.removeAgent removes the worker through the mobile sync command surface", async () => { + const result = await service.execute(makePayload("cto.removeAgent", { + agentId: "worker-42", + })); + + expect(result).toEqual({}); + expect(workerAgentService.removeAgent).toHaveBeenCalledWith("worker-42"); + }); }); // --------------------------------------------------------------- diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts index 4a6b44f0e..79d25ad93 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts @@ -4,6 +4,7 @@ import { buildTrackedCliResumeCommand, buildTrackedCliStartupCommand, defaultTrackedCliStartupCommand, + resolveCleanShellLaunchFields, resolveTrackedCliResumeCommand, withCodexNoAltScreen, } from "./cliLaunch"; @@ -56,6 +57,31 @@ describe("defaultTrackedCliStartupCommand", () => { }); }); +describe("resolveCleanShellLaunchFields", () => { + it("starts zsh without reading user startup files", () => { + expect(resolveCleanShellLaunchFields({ platform: "darwin", shell: "/bin/zsh" })).toEqual({ + command: "/bin/zsh", + args: ["-f"], + env: { ZDOTDIR: "/var/empty" }, + }); + }); + + it("starts bash without profile or rc files", () => { + expect(resolveCleanShellLaunchFields({ platform: "linux", shell: "/bin/bash" })).toEqual({ + command: "/bin/bash", + args: ["--noprofile", "--norc"], + env: { BASH_ENV: "" }, + }); + }); + + it("starts Windows PowerShell without profile scripts", () => { + expect(resolveCleanShellLaunchFields({ platform: "win32", comSpec: "cmd.exe" })).toEqual({ + command: "powershell.exe", + args: ["-NoLogo", "-NoProfile"], + }); + }); +}); + describe("buildTrackedCliStartupCommand", () => { describe("claude provider", () => { it("adds --dangerously-skip-permissions for full-auto", () => { @@ -77,6 +103,12 @@ describe("buildTrackedCliStartupCommand", () => { expect(command).toContain("--permission-mode default"); }); + it("adds --permission-mode auto for Claude auto", () => { + const command = buildTrackedCliStartupCommand({ provider: "claude", permissionMode: "auto" }); + expect(command).toContain("--append-system-prompt"); + expect(command).toContain("--permission-mode auto"); + }); + it("adds --permission-mode plan for plan (else branch)", () => { const command = buildTrackedCliStartupCommand({ provider: "claude", permissionMode: "plan" }); expect(command).toContain("--append-system-prompt"); @@ -183,23 +215,11 @@ describe("buildTrackedCliStartupCommand", () => { expect(command).toContain("only normal reason to skip ADE CLI"); }); - it("seeds Codex with ADE guidance as the initial prompt", () => { - const launch = buildTrackedCliLaunchCommand({ provider: "codex", permissionMode: "default" }); - expect(launch.command).toBe("codex"); - expect(launch.args[0]).toBe("--no-alt-screen"); - expect(launch.args.at(-1)).toContain("ADE session guidance"); - expect(ADE_CLI_INLINE_GUIDANCE).toContain("default control plane"); - expect(launch.args.at(-1)).toContain("default control plane"); - expect(launch.args.at(-1)).toContain("ADE_AGENT_SKILLS_DIRS"); - expect(launch.args.at(-1)).toContain("clean up old, stale, or finished processes"); - expect(launch.env?.[ADE_AGENT_SKILLS_DIRS_ENV]).toContain("agent-skills"); - expect(launch.startupCommand).toContain("ADE session guidance"); - }); - it("uses the selected lane worktree to seed skill roots", () => { const launch = buildTrackedCliLaunchCommand({ provider: "codex", permissionMode: "default", + initialPrompt: "Check this lane.", laneWorktreePath: "/repo/.ade/worktrees/chat-lane", }); @@ -282,11 +302,30 @@ describe("buildTrackedCliStartupCommand", () => { "config-toml is only supported for Codex", ); }); + + it("rejects auto for non-Claude CLI providers", () => { + expect(() => buildTrackedCliLaunchCommand({ provider: "codex", permissionMode: "auto" })).toThrow( + "auto is only supported for Claude", + ); + expect(() => buildTrackedCliLaunchCommand({ provider: "cursor", permissionMode: "auto" })).toThrow( + "auto is only supported for Claude", + ); + expect(() => buildTrackedCliLaunchCommand({ provider: "droid", permissionMode: "auto" })).toThrow( + "auto is only supported for Claude", + ); + expect(() => buildTrackedCliLaunchCommand({ provider: "opencode", permissionMode: "auto" })).toThrow( + "auto is only supported for Claude", + ); + }); }); it("covers supported AgentChatPermissionMode values for each provider", () => { - const modes = ["default", "plan", "edit", "full-auto", "config-toml"] as const satisfies readonly AgentChatPermissionMode[]; + const modes = ["default", "auto", "plan", "edit", "full-auto", "config-toml"] as const satisfies readonly AgentChatPermissionMode[]; for (const mode of modes) { + if (mode === "auto") { + expect(buildTrackedCliStartupCommand({ provider: "claude", permissionMode: mode }).length).toBeGreaterThan(0); + continue; + } const codex = buildTrackedCliStartupCommand({ provider: "codex", permissionMode: mode }); expect(codex.length).toBeGreaterThan(0); if (mode === "config-toml") continue; diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index c32e8a68e..57f22e002 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -6,6 +6,7 @@ import type { } from "./types"; import { ADE_AGENT_SKILLS_DIRS_ENV, getAdeAgentSkillRootsForPrompt, joinAdeAgentSkillRoots } from "./agentSkillRoots"; import { buildAdeCliAgentGuidance, buildAdeCliInlineGuidance } from "./adeCliGuidance"; +import { isProviderSlashCommandInput } from "./chatSlashCommands"; import { commandArrayToLine, quoteShellArg } from "./shell"; export type CliProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"; @@ -17,8 +18,14 @@ export type TrackedCliLaunchCommand = { env?: Record; }; +export type CleanShellLaunchFields = { + command: string; + args: string[]; + env?: Record; +}; + export const LAUNCH_PROFILES = ["claude", "codex", "cursor", "droid", "opencode", "shell"] as const satisfies readonly LaunchProfile[]; -export const TRACKED_CLI_PERMISSION_MODES = ["default", "plan", "edit", "full-auto", "config-toml"] as const satisfies readonly AgentChatPermissionMode[]; +export const TRACKED_CLI_PERMISSION_MODES = ["default", "auto", "plan", "edit", "full-auto", "config-toml"] as const satisfies readonly AgentChatPermissionMode[]; export function sanitizeTrackedCliResumeTargetId(value: string | null | undefined): string | null { const target = String(value ?? "").trim(); @@ -49,6 +56,100 @@ export const LAUNCH_PROFILE_TITLE: Record = { shell: "Shell", }; +const TRACKED_CLI_PROMPT_SEED_MIN_LEN = 3; +const TRACKED_CLI_PROMPT_SEED_MAX_LEN = 180; +const TRACKED_CLI_PROMPT_TITLE_MAX_LEN = 72; + +function stripAnsiForCliTitle(raw: string): string { + return raw + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\x1b[@-Z\\-_]/g, ""); +} + +export function sanitizeTrackedCliPromptSeed(raw: string): string { + const stripped = stripAnsiForCliTitle(raw) + .replace(/\r\n/g, "\n") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + .replace(/\n/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!stripped.length) return ""; + return stripped.slice(0, TRACKED_CLI_PROMPT_SEED_MAX_LEN); +} + +function trimPromptLeadIn(raw: string): string { + let text = raw.trim(); + for (let i = 0; i < 4; i += 1) { + const next = text + .replace(/^(?:ok(?:ay)?|so|hey|hi|hello|please|pls|vv)\b[\s,.:;-]*/iu, "") + .trim(); + if (next === text) break; + text = next; + } + return text; +} + +function sentenceCase(raw: string): string { + return raw ? raw.charAt(0).toUpperCase() + raw.slice(1) : raw; +} + +export function trackedCliTitleFromPromptSeed(seed: string): string { + const naturalLanguageSlashTitle = seed.startsWith("/") && !isProviderSlashCommandInput(seed) + ? seed.slice(1).trim() + : seed; + const cleaned = trimPromptLeadIn(naturalLanguageSlashTitle) + .replace(/^["'`]+|["'`]+$/g, "") + .replace(/\s+/g, " ") + .trim(); + if (!cleaned) return ""; + + const clauseMatch = cleaned.match(/^(.{18,}?[,.!?;:])\s/u); + const clause = clauseMatch?.[1]?.replace(/[,.!?;:]+$/u, "").trim(); + const base = clause && clause.length >= 12 ? clause : cleaned; + const clipped = base.length > TRACKED_CLI_PROMPT_TITLE_MAX_LEN + ? base.slice(0, TRACKED_CLI_PROMPT_TITLE_MAX_LEN).replace(/\s+\S*$/u, "").trim() + : base; + return sentenceCase(clipped || base.slice(0, TRACKED_CLI_PROMPT_TITLE_MAX_LEN).trim()).replace(/[.?!,:;]+$/u, ""); +} + +export function isLaunchProfilePlaceholderTitle( + title: string | null | undefined, + profile: LaunchProfile, +): boolean { + const normalized = String(title ?? "").trim().toLowerCase(); + if (!normalized.length) return true; + if (isProviderSlashCommandInput(normalized)) return true; + const defaultTitle = LAUNCH_PROFILE_TITLE[profile]?.trim().toLowerCase(); + if (defaultTitle && normalized === defaultTitle) return true; + if (profile === "codex") return normalized === "codex cli" || normalized === "codex session"; + if (profile === "claude") return normalized === "claude" || normalized === "claude cli" || normalized === "claude session"; + return false; +} + +export function deriveTrackedCliInitialInputSessionMeta(args: { + provider: LaunchProfile; + title?: string | null; + initialInput?: string | null; +}): { goal: string | null; title: string; promptTitle: string | null } { + const explicitTitle = String(args.title ?? "").trim(); + const fallbackTitle = explicitTitle || LAUNCH_PROFILE_TITLE[args.provider]; + if (args.provider === "shell") { + return { goal: null, title: fallbackTitle, promptTitle: null }; + } + + const seed = sanitizeTrackedCliPromptSeed(args.initialInput ?? ""); + if (seed.length < TRACKED_CLI_PROMPT_SEED_MIN_LEN || isProviderSlashCommandInput(seed)) { + return { goal: null, title: fallbackTitle, promptTitle: null }; + } + + const promptTitle = trackedCliTitleFromPromptSeed(seed) || null; + const title = promptTitle && isLaunchProfilePlaceholderTitle(explicitTitle, args.provider) + ? promptTitle + : fallbackTitle; + return { goal: seed, title, promptTitle }; +} + const LAUNCH_PROFILE_TOOL_TYPES: Record = { claude: ["claude", "claude-orchestrated", "claude-chat"], codex: ["codex", "codex-orchestrated", "codex-chat"], @@ -74,11 +175,46 @@ export function validateLaunchProfilePermissionMode( if (profile === "shell" && mode !== "default") { throw new Error(`permissionMode ${mode} is not supported for shell sessions.`); } + if (mode === "auto" && profile !== "claude") { + throw new Error("permissionMode auto is only supported for Claude CLI sessions."); + } if (mode === "config-toml" && profile !== "codex") { throw new Error("permissionMode config-toml is only supported for Codex CLI sessions."); } } +export function resolveCleanShellLaunchFields(args: { + platform: string; + shell?: string | null; + comSpec?: string | null; +}): CleanShellLaunchFields { + if (args.platform === "win32") { + const shell = args.shell?.trim() || ""; + const comSpec = args.comSpec?.trim() || ""; + const powershellPathPattern = /(?:^|[\\/])(?:powershell|pwsh)(?:\.exe)?$/i; + let command: string; + if (powershellPathPattern.test(shell)) { + command = shell; + } else if (powershellPathPattern.test(comSpec)) { + command = comSpec; + } else { + command = "powershell.exe"; + } + return { + command, + args: ["-NoLogo", "-NoProfile"], + }; + } + + const shell = args.shell?.trim() || ""; + const name = shell.split(/[\\/]/).pop()?.toLowerCase() ?? ""; + if (name === "zsh") return { command: shell || "/bin/zsh", args: ["-f"], env: { ZDOTDIR: "/var/empty" } }; + if (name === "bash") return { command: shell || "/bin/bash", args: ["--noprofile", "--norc"], env: { BASH_ENV: "" } }; + if (name === "fish") return { command: shell || "fish", args: ["--no-config"] }; + if (name === "sh" && shell) return { command: shell, args: [], env: { ENV: "" } }; + return { command: "/bin/sh", args: [], env: { ENV: "" } }; +} + export function launchProfileForTerminalSession( session: Pick, ): LaunchProfile | null { @@ -197,7 +333,7 @@ export function buildTrackedCliLaunchCommand(args: { if (args.provider === "codex") { const commandArgs: string[] = [ "--no-alt-screen", - ...modelToCliFlag(args.model), + ...modelToCliFlag(resolveCodexCliModelForLaunch(args.model)), ...codexReasoningEffortFlags(args.reasoningEffort), ...permissionModeToCodexFlags(args.permissionMode), workTabCliPrompt(initialPrompt, skillRoots), @@ -269,6 +405,16 @@ export function modelToCliFlag(model: string | null | undefined): string[] { return normalized ? ["--model", normalized] : []; } +export function resolveCodexCliModelForLaunch(model: string | null | undefined): string | null { + const raw = String(model ?? "").trim(); + if (!raw) return null; + const slash = raw.indexOf("/"); + if (slash > 0 && raw.slice(0, slash).toLowerCase() === "openai") { + return raw.slice(slash + 1).trim() || null; + } + return raw; +} + export function codexReasoningEffortFlags(reasoningEffort: string | null | undefined): string[] { const effort = normalizeCliFlagValue(reasoningEffort); return effort ? ["-c", `model_reasoning_effort="${effort}"`] : []; diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 9dba2368c..b9659d092 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -816,6 +816,7 @@ export type AgentChatSessionSummary = { lastOutputPreview: string | null; summary: string | null; awaitingInput?: boolean; + pendingInputItemId?: string | null; threadId?: string; requestedCwd?: string | null; }; @@ -1102,6 +1103,7 @@ export type AgentChatHandoffResult = { export type AgentChatListArgs = { laneId?: string; includeAutomation?: boolean; + includeArchived?: boolean; }; export type AgentChatSuggestLaneNameArgs = { diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index fd2f43fa8..bc966d901 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -81,6 +81,7 @@ export type TerminalSessionSummary = { lastOutputPreview: string | null; summary: string | null; runtimeState: TerminalRuntimeState; + pendingInputItemId?: string | null; resumeCommand: string | null; resumeMetadata?: TerminalResumeMetadata | null; chatIdleSinceAt?: string | null; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index a5af6f057..6803373e2 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -506,6 +506,7 @@ export type SyncStartCliSessionArgs = { rows?: number; model?: string | null; modelId?: string | null; + reasoningEffort?: string | null; }; export type SyncStartCliSessionResult = { @@ -535,6 +536,7 @@ export type SyncRemoteCommandAction = | "lanes.importBranch" | "lanes.previewBranchSwitch" | "lanes.attach" + | "lanes.listUnregisteredWorktrees" | "lanes.adoptAttached" | "lanes.rename" | "lanes.reparent" @@ -606,6 +608,7 @@ export type SyncRemoteCommandAction = | "cto.listLinearIngressEvents" | "cto.updateIdentity" | "cto.updateCoreMemory" + | "cto.removeAgent" | "cto.setAgentStatus" | "cto.triggerAgentWakeup" | "cto.rollbackAgentRevision" @@ -658,6 +661,7 @@ export type SyncRemoteCommandAction = | "prs.getActivity" | "prs.getDeployments" | "prs.createFromLane" + | "prs.createQueue" | "prs.linkToLane" | "prs.draftDescription" | "prs.land" @@ -674,6 +678,8 @@ export type SyncRemoteCommandAction = | "prs.setReviewThreadResolved" | "prs.reactToComment" | "prs.aiReviewSummary" + | "prs.simulateIntegration" + | "prs.commitIntegration" | "prs.listIntegrationWorkflows" | "prs.updateIntegrationProposal" | "prs.deleteIntegrationProposal" @@ -683,6 +689,7 @@ export type SyncRemoteCommandAction = | "prs.startIntegrationResolution" | "prs.recheckIntegrationStep" | "prs.landQueueNext" + | "prs.startQueueAutomation" | "prs.pauseQueueAutomation" | "prs.resumeQueueAutomation" | "prs.cancelQueueAutomation" @@ -1054,10 +1061,12 @@ export function normalizeNotificationPreferences(input: unknown): NotificationPr const perSessionOverrides: NonNullable = {}; for (const [sessionId, override] of Object.entries(perSessionRaw)) { if (!isRecord(override) || !sessionId.trim()) continue; - perSessionOverrides[sessionId] = { + const normalizedOverride = { muted: booleanOrDefault(override.muted, false), awaitingInputOnly: booleanOrDefault(override.awaitingInputOnly, false), }; + if (!normalizedOverride.muted && !normalizedOverride.awaitingInputOnly) continue; + perSessionOverrides[sessionId] = normalizedOverride; } return { enabled: booleanOrDefault(raw.enabled, DEFAULT_NOTIFICATION_PREFERENCES.enabled), diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 2c9eb7b55..4d9df1961 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -94,6 +94,7 @@ H10000000000000000000009 /* CtoTeamScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000018 /* CtoTeamScreen.swift */; }; H1000000000000000000000A /* CtoWorkerDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000019 /* CtoWorkerDetailScreen.swift */; }; H1000000000000000000000B /* CtoWorkflowsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1000000000000000000001A /* CtoWorkflowsScreen.swift */; }; + H1000000000000000000000C /* CtoReloadHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1000000000000000000001B /* CtoReloadHelpers.swift */; }; E20000000000000000000042 /* FilesModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000042 /* FilesModels.swift */; }; E20000000000000000000043 /* FilesHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000043 /* FilesHelpers.swift */; }; E20000000000000000000044 /* FilesRootScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000044 /* FilesRootScreen.swift */; }; @@ -180,8 +181,6 @@ B1000000000000000000001F /* LaneColorSwatchPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000001F /* LaneColorSwatchPicker.swift */; }; C10000000000000000000001 /* ADEMobilePrimitives.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000000000000000000011 /* ADEMobilePrimitives.swift */; }; C10000000000000000000002 /* ADECodeRenderingCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000000000000000000012 /* ADECodeRenderingCache.swift */; }; - C10000000000000000000003 /* ADEStreamingShimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000000000000000000013 /* ADEStreamingShimmer.swift */; }; - E1000000000000000000003D /* WorkChatSessionView+MessageLiveness.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000003D /* WorkChatSessionView+MessageLiveness.swift */; }; E1000000000000000000003E /* WorkContextCompactDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000003E /* WorkContextCompactDivider.swift */; }; E1000000000000000000003F /* WorkModelCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000003F /* WorkModelCatalog.swift */; }; E10000000000000000000040 /* WorkModelPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000040 /* WorkModelPickerSheet.swift */; }; @@ -283,7 +282,6 @@ D10000000000000000000039 /* WorkChatSessionView+Timeline.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkChatSessionView+Timeline.swift"; path = "ADE/Views/Work/WorkChatSessionView+Timeline.swift"; sourceTree = ""; }; D1000000000000000000003A /* WorkReasoningCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkReasoningCard.swift"; path = "ADE/Views/Work/WorkReasoningCard.swift"; sourceTree = ""; }; D1000000000000000000003B /* WorkActivityIndicator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkActivityIndicator.swift; path = ADE/Views/Work/WorkActivityIndicator.swift; sourceTree = ""; }; - D1000000000000000000003D /* WorkChatSessionView+MessageLiveness.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkChatSessionView+MessageLiveness.swift"; path = "ADE/Views/Work/WorkChatSessionView+MessageLiveness.swift"; sourceTree = ""; }; D1000000000000000000003E /* WorkContextCompactDivider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkContextCompactDivider.swift; path = ADE/Views/Work/WorkContextCompactDivider.swift; sourceTree = ""; }; D1000000000000000000003F /* WorkModelCatalog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkModelCatalog.swift; path = ADE/Views/Work/WorkModelCatalog.swift; sourceTree = ""; }; D10000000000000000000040 /* WorkModelPickerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkModelPickerSheet.swift; path = ADE/Views/Work/WorkModelPickerSheet.swift; sourceTree = ""; }; @@ -305,6 +303,7 @@ H10000000000000000000018 /* CtoTeamScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoTeamScreen.swift; path = ADE/Views/Cto/CtoTeamScreen.swift; sourceTree = ""; }; H10000000000000000000019 /* CtoWorkerDetailScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoWorkerDetailScreen.swift; path = ADE/Views/Cto/CtoWorkerDetailScreen.swift; sourceTree = ""; }; H1000000000000000000001A /* CtoWorkflowsScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoWorkflowsScreen.swift; path = ADE/Views/Cto/CtoWorkflowsScreen.swift; sourceTree = ""; }; + H1000000000000000000001B /* CtoReloadHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoReloadHelpers.swift; path = ADE/Views/Cto/CtoReloadHelpers.swift; sourceTree = ""; }; D20000000000000000000042 /* FilesModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesModels.swift; path = ADE/Views/Files/FilesModels.swift; sourceTree = ""; }; D20000000000000000000043 /* FilesHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesHelpers.swift; path = ADE/Views/Files/FilesHelpers.swift; sourceTree = ""; }; D20000000000000000000044 /* FilesRootScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesRootScreen.swift; path = ADE/Views/Files/FilesRootScreen.swift; sourceTree = ""; }; @@ -400,7 +399,6 @@ C9411193AF56B236BA32EFF5 /* ADEApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEApp.swift; path = ADE/App/ADEApp.swift; sourceTree = ""; }; C10000000000000000000011 /* ADEMobilePrimitives.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEMobilePrimitives.swift; path = ADE/Views/Components/ADEMobilePrimitives.swift; sourceTree = ""; }; C10000000000000000000012 /* ADECodeRenderingCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADECodeRenderingCache.swift; path = ADE/Views/Components/ADECodeRenderingCache.swift; sourceTree = ""; }; - C10000000000000000000013 /* ADEStreamingShimmer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEStreamingShimmer.swift; path = ADE/Views/Components/ADEStreamingShimmer.swift; sourceTree = ""; }; CCAB2414C359E971B780BF99 /* PreviewHost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PreviewHost.app; sourceTree = BUILT_PRODUCTS_DIR; }; D6F9B21C0E4A6D8F1B3C5A77 /* FilesCodeSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesCodeSupport.swift; path = ADE/Views/Components/FilesCodeSupport.swift; sourceTree = ""; }; E3A5721EB84321D201716BC3 /* ADETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ADETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -448,7 +446,6 @@ 4A9E6135ED52117E41DE95F7 /* ADEDesignSystem.swift */, A1000000000000000000001A /* ADEHaptics.swift */, C10000000000000000000011 /* ADEMobilePrimitives.swift */, - C10000000000000000000013 /* ADEStreamingShimmer.swift */, D6F9B21C0E4A6D8F1B3C5A77 /* FilesCodeSupport.swift */, ); name = Components; @@ -573,6 +570,7 @@ children = ( H10000000000000000000012 /* CtoBriefEditor.swift */, H10000000000000000000014 /* CtoIdentityEditor.swift */, + H1000000000000000000001B /* CtoReloadHelpers.swift */, H10000000000000000000010 /* CtoRootScreen.swift */, H10000000000000000000011 /* CtoSessionDestinationView.swift */, H10000000000000000000015 /* CtoSettingsScreen.swift */, @@ -603,7 +601,6 @@ D10000000000000000000039 /* WorkChatSessionView+Timeline.swift */, D1000000000000000000003A /* WorkReasoningCard.swift */, D1000000000000000000003B /* WorkActivityIndicator.swift */, - D1000000000000000000003D /* WorkChatSessionView+MessageLiveness.swift */, D1000000000000000000003E /* WorkContextCompactDivider.swift */, D1000000000000000000003F /* WorkModelCatalog.swift */, D10000000000000000000040 /* WorkModelPickerSheet.swift */, @@ -1102,7 +1099,6 @@ E10000000000000000000039 /* WorkChatSessionView+Timeline.swift in Sources */, E1000000000000000000003A /* WorkReasoningCard.swift in Sources */, E1000000000000000000003B /* WorkActivityIndicator.swift in Sources */, - E1000000000000000000003D /* WorkChatSessionView+MessageLiveness.swift in Sources */, E1000000000000000000003E /* WorkContextCompactDivider.swift in Sources */, E1000000000000000000003F /* WorkModelCatalog.swift in Sources */, E10000000000000000000040 /* WorkModelPickerSheet.swift in Sources */, @@ -1113,7 +1109,6 @@ E10000000000000000000045 /* WorkSelectionActionBar.swift in Sources */, E10000000000000000000046 /* WorkRootScreen+Selection.swift in Sources */, E10000000000000000000048 /* WorkTerminalEmulatorView.swift in Sources */, - C10000000000000000000003 /* ADEStreamingShimmer.swift in Sources */, E1000000000000000000003C /* WorkSessionGrouping.swift in Sources */, E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */, E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */, @@ -1139,6 +1134,7 @@ H10000000000000000000009 /* CtoTeamScreen.swift in Sources */, H1000000000000000000000A /* CtoWorkerDetailScreen.swift in Sources */, H1000000000000000000000B /* CtoWorkflowsScreen.swift in Sources */, + H1000000000000000000000C /* CtoReloadHelpers.swift in Sources */, AA5100000000000000000013 /* LiveActivityCoordinator.swift in Sources */, AA5100000000000000000014 /* LiveActivityIntentsForward.swift in Sources */, AA5500000000000000000014 /* WidgetAppIntents.swift in Sources */, diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 747c07bb3..13f2be2b7 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -3,17 +3,40 @@ import UIKit private let adeAccent = ADEColor.accent -private enum RootTab: Hashable { +private enum RootTab: Hashable, CaseIterable, Identifiable { case work case lanes case prs case files case cto + + var id: Self { self } + + var title: String { + switch self { + case .work: return "Work" + case .lanes: return "Lanes" + case .prs: return "PRs" + case .files: return "Files" + case .cto: return "CTO" + } + } + + var symbol: String { + switch self { + case .work: return "terminal" + case .lanes: return "square.stack.3d.up" + case .prs: return "arrow.triangle.pull" + case .files: return "doc.text" + case .cto: return "brain.head.profile" + } + } } struct ContentView: View { @EnvironmentObject private var syncService: SyncService @State private var selectedTab: RootTab = .work + @State private var rootTabBarHidden = false @AppStorage("ade.colorScheme") private var colorSchemeRaw: String = ADEColorSchemeChoice.system.rawValue private var colorSchemeChoice: ADEColorSchemeChoice { @@ -29,16 +52,17 @@ struct ContentView: View { } } .tint(adeAccent) - .tabBarMinimizeBehavior(.onScrollDown) .adeScreenBackground() .adeNavigationGlass() .adeInspectorHost() .preferredColorScheme(colorSchemeChoice.preferredColorScheme) .sensoryFeedback(.selection, trigger: selectedTab) .environmentObject(syncService.attentionDrawer) + .onAppear { + ADEUIKitAppearance.configureTabBar() + } .sheet(isPresented: $syncService.settingsPresented) { - ConnectionSettingsView() - .environmentObject(syncService) + ConnectionSettingsView(syncService: syncService) } .sheet(isPresented: $syncService.attentionDrawerPresented) { AttentionDrawerSheet() @@ -66,6 +90,13 @@ struct ContentView: View { selectedTab = .prs } } + .onChange(of: syncService.requestedWorkSessionNavigation?.id) { _, requestId in + guard requestId != nil else { return } + syncService.closeProjectHome() + if selectedTab != .work { + selectedTab = .work + } + } } private var rootTabs: some View { @@ -76,6 +107,18 @@ struct ContentView: View { filesTab ctoTab } + .toolbar(.hidden, for: .tabBar) + .safeAreaInset(edge: .bottom, spacing: 0) { + if !rootTabBarHidden { + ADERootBottomTabBar( + selectedTab: $selectedTab, + workBadgeCount: syncService.runningChatSessionCount + ) + } + } + .onPreferenceChange(ADERootTabBarHiddenPreferenceKey.self) { hidden in + rootTabBarHidden = hidden + } } private var workTab: some View { @@ -120,6 +163,64 @@ struct ContentView: View { } } +private struct ADERootBottomTabBar: View { + @Binding var selectedTab: RootTab + let workBadgeCount: Int + + var body: some View { + HStack(spacing: 6) { + ForEach(RootTab.allCases) { tab in + Button { + selectedTab = tab + } label: { + VStack(spacing: 4) { + ZStack(alignment: .topTrailing) { + Image(systemName: tab.symbol) + .font(.system(size: 18, weight: .semibold)) + .frame(width: 38, height: 28) + + if tab == .work, workBadgeCount > 0 { + Text("\(min(workBadgeCount, 99))") + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .frame(minWidth: 18, minHeight: 18) + .background(ADEColor.danger, in: Capsule()) + .offset(x: 10, y: -6) + } + } + + Text(tab.title) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .foregroundStyle(selectedTab == tab ? ADEColor.accentBright : ADEColor.textSecondary) + .padding(.vertical, 8) + .background( + selectedTab == tab + ? ADEColor.accent.opacity(0.14) + : Color.clear, + in: RoundedRectangle(cornerRadius: 16, style: .continuous) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(tab.title) + .accessibilityValue(selectedTab == tab ? "Selected" : "") + } + } + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 8) + .background(ADEColor.surfaceBackground.ignoresSafeArea(edges: .bottom)) + .overlay(alignment: .top) { + Rectangle() + .fill(ADEColor.glassBorder) + .frame(height: 0.5) + } + } +} + private struct ProjectHomeView: View { @EnvironmentObject private var syncService: SyncService diff --git a/apps/ios/ADE/App/DeepLinkRouter.swift b/apps/ios/ADE/App/DeepLinkRouter.swift index 8a27c5f87..97ce160e6 100644 --- a/apps/ios/ADE/App/DeepLinkRouter.swift +++ b/apps/ios/ADE/App/DeepLinkRouter.swift @@ -59,6 +59,9 @@ final class DeepLinkRouter { object: nil, userInfo: ["kind": kind, "identifier": identifier] ) + if kind == "session" { + SyncService.shared?.requestedWorkSessionNavigation = WorkSessionNavigationRequest(sessionId: identifier) + } if kind == "pr", let prId = resolvePrId(from: identifier) { SyncService.shared?.requestedPrNavigation = PrNavigationRequest(prId: prId) } diff --git a/apps/ios/ADE/Models/NotificationPreferences.swift b/apps/ios/ADE/Models/NotificationPreferences.swift index b026854a7..5190dab37 100644 --- a/apps/ios/ADE/Models/NotificationPreferences.swift +++ b/apps/ios/ADE/Models/NotificationPreferences.swift @@ -75,6 +75,17 @@ public struct NotificationPreferences: Codable, Equatable, Hashable { } public extension NotificationPreferences { + /// Returns a copy without inactive per-session entries. Rows with both + /// switches off are equivalent to no override, and keeping them around causes + /// needless payload growth as users toggle agents on and back off. + var pruningInactivePerSessionOverrides: NotificationPreferences { + var next = self + next.perSessionOverrides = perSessionOverrides.filter { _, override in + override.muted || override.awaitingInputOnly + } + return next + } + /// Decodes stored preferences. Returns a default-initialised struct when no /// blob exists yet or decoding fails — this keeps the Settings screen usable /// on first launch. @@ -95,7 +106,7 @@ public extension NotificationPreferences { func save(to defaults: UserDefaults) { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 - guard let data = try? encoder.encode(self) else { return } + guard let data = try? encoder.encode(pruningInactivePerSessionOverrides) else { return } defaults.set(data, forKey: NotificationPreferences.defaultKey) } } diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 28efd5377..ad39e30b3 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -518,6 +518,8 @@ struct DiffChanges: Codable, Equatable { struct DiffSide: Codable, Equatable { var exists: Bool var text: String + var size: Int? + var isTruncated: Bool? } struct FileDiff: Codable, Equatable { @@ -579,6 +581,7 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { var lastOutputPreview: String? var summary: String? var awaitingInput: Bool? + var pendingInputItemId: String? = nil var threadId: String? var requestedCwd: String? } @@ -2209,6 +2212,7 @@ struct TerminalSessionSummary: Codable, Identifiable, Equatable { var status: String var startedAt: String var endedAt: String? + var archivedAt: String? = nil var exitCode: Int? var transcriptPath: String var headShaStart: String? @@ -2222,6 +2226,8 @@ struct TerminalSessionSummary: Codable, Identifiable, Equatable { /// Parent chat session id when this terminal was launched from a chat (e.g. App Control, /// in-chat terminal drawer). Mirrors the desktop `TerminalSessionSummary.chatSessionId`. var chatSessionId: String? = nil + /// Current pending approval/input item id when the backing chat is waiting on the user. + var pendingInputItemId: String? = nil } struct ProcessReadinessConfig: Codable, Equatable { diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index f5ab72e60..e6b293bf0 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -86,6 +86,8 @@ final class DatabaseService { let resumeMetadata: TerminalResumeMetadata? let chatIdleSinceAt: String? let chatSessionId: String? + let pendingInputItemId: String? + let archivedAt: String? } private struct ComputerUseArtifactRow { @@ -306,10 +308,12 @@ final class DatabaseService { var rows: [CrsqlChangeRow] = [] while sqlite3_step(statement) == SQLITE_ROW { + let table = stringValue(statement, index: 0) ?? "" + let rawPk = scalarValue(statement, index: 1) rows.append( CrsqlChangeRow( - table: stringValue(statement, index: 0) ?? "", - pk: scalarValue(statement, index: 1), + table: table, + pk: encodeOutgoingCrsqlPrimaryKey(table: table, pk: rawPk), cid: stringValue(statement, index: 2) ?? "", val: scalarValue(statement, index: 3), colVersion: Int(sqlite3_column_int64(statement, 4)), @@ -548,6 +552,10 @@ final class DatabaseService { ) }).filter { orderedLaneIds.contains($0.lane.id) } + if laneSnapshotHydrationMatchesExisting(hydratedSnapshots) { + return + } + try exec("begin") do { try exec("pragma defer_foreign_keys = on") @@ -762,8 +770,10 @@ final class DatabaseService { func replaceLaneDetail(_ detail: LaneDetailPayload) throws { guard db != nil else { return } - let updatedAt = ISO8601DateFormatter().string(from: Date()) + guard fetchLaneDetail(laneId: detail.lane.id) != detail else { return } + let encodedDetail = try encodeJsonString(detail) + let updatedAt = ISO8601DateFormatter().string(from: Date()) _ = try execute(""" insert into lane_detail_snapshots( lane_id, detail_json, updated_at @@ -779,6 +789,20 @@ final class DatabaseService { notifyDidChange() } + private func laneSnapshotHydrationMatchesExisting(_ snapshots: [LaneListSnapshot]) -> Bool { + let existingSnapshots = fetchLaneListSnapshots(includeArchived: true) + guard existingSnapshots.count == snapshots.count else { return false } + let existingByLaneId = Dictionary(uniqueKeysWithValues: existingSnapshots.map { ($0.lane.id, $0) }) + guard Set(existingByLaneId.keys) == Set(snapshots.map(\.lane.id)) else { return false } + + for snapshot in snapshots { + guard existingByLaneId[snapshot.lane.id] == snapshot else { + return false + } + } + return true + } + func fetchLaneDetail(laneId: String) -> LaneDetailPayload? { let sql = """ select lane_id, detail_json, updated_at @@ -871,8 +895,9 @@ final class DatabaseService { insert into terminal_sessions( id, lane_id, lane_name, pty_id, tracked, goal, tool_type, pinned, title, started_at, ended_at, exit_code, transcript_path, head_sha_start, head_sha_end, status, last_output_preview, - last_output_at, summary, runtime_state, resume_command, resume_metadata_json, manually_named, chat_idle_since_at, chat_session_id - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + last_output_at, summary, runtime_state, resume_command, resume_metadata_json, manually_named, chat_idle_since_at, chat_session_id, + pending_input_item_id, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(id) do update set lane_id = excluded.lane_id, lane_name = excluded.lane_name, @@ -897,7 +922,9 @@ final class DatabaseService { resume_metadata_json = excluded.resume_metadata_json, manually_named = excluded.manually_named, chat_idle_since_at = excluded.chat_idle_since_at, - chat_session_id = excluded.chat_session_id + chat_session_id = excluded.chat_session_id, + pending_input_item_id = excluded.pending_input_item_id, + archived_at = excluded.archived_at """) { statement in try bindText(session.id, to: statement, index: 1) try bindText(session.laneId, to: statement, index: 2) @@ -972,6 +999,16 @@ final class DatabaseService { } else { sqlite3_bind_null(statement, 25) } + if let pendingInputItemId = session.pendingInputItemId, !pendingInputItemId.isEmpty { + try bindText(pendingInputItemId, to: statement, index: 26) + } else { + sqlite3_bind_null(statement, 26) + } + if let archivedAt = session.archivedAt { + try bindText(archivedAt, to: statement, index: 27) + } else { + sqlite3_bind_null(statement, 27) + } } } @@ -1058,7 +1095,7 @@ final class DatabaseService { updated_at = excluded.updated_at """) { statement in try bindText(pr.id, to: statement, index: 1) - try bindText(pr.projectId.isEmpty ? projectId : pr.projectId, to: statement, index: 2) + try bindText(projectId, to: statement, index: 2) try bindText(pr.laneId, to: statement, index: 3) try bindText(pr.repoOwner, to: statement, index: 4) try bindText(pr.repoName, to: statement, index: 5) @@ -1437,7 +1474,7 @@ final class DatabaseService { select s.id, s.lane_id, coalesce(nullif(s.lane_name, ''), l.name, s.lane_id), s.pty_id, s.tracked, s.pinned, s.manually_named, s.goal, s.tool_type, s.title, s.status, s.started_at, s.ended_at, s.exit_code, s.transcript_path, s.head_sha_start, s.head_sha_end, s.last_output_preview, s.summary, s.runtime_state, - s.resume_command, s.resume_metadata_json, s.chat_idle_since_at, s.chat_session_id + s.resume_command, s.resume_metadata_json, s.chat_idle_since_at, s.chat_session_id, s.pending_input_item_id, s.archived_at from terminal_sessions s left join lanes l on l.id = s.lane_id where l.project_id = ? @@ -1472,7 +1509,9 @@ final class DatabaseService { resumeCommand: stringValue(statement, index: 20), resumeMetadata: decodeJson(stringValue(statement, index: 21), as: TerminalResumeMetadata.self), chatIdleSinceAt: stringValue(statement, index: 22), - chatSessionId: stringValue(statement, index: 23) + chatSessionId: stringValue(statement, index: 23), + pendingInputItemId: stringValue(statement, index: 24), + archivedAt: stringValue(statement, index: 25) ) }.map { row in TerminalSessionSummary( @@ -1489,6 +1528,7 @@ final class DatabaseService { status: row.status, startedAt: row.startedAt, endedAt: row.endedAt, + archivedAt: row.archivedAt, exitCode: row.exitCode, transcriptPath: row.transcriptPath, headShaStart: row.headShaStart, @@ -1499,33 +1539,73 @@ final class DatabaseService { resumeCommand: row.resumeCommand, resumeMetadata: row.resumeMetadata, chatIdleSinceAt: row.chatIdleSinceAt, - chatSessionId: row.chatSessionId + chatSessionId: row.chatSessionId, + pendingInputItemId: row.pendingInputItemId ) } } func updateSessionTitle(sessionId: String, title: String) throws { - guard db != nil else { return } - let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - _ = try execute("update terminal_sessions set title = ? where id = ?") { statement in - try bindText(trimmed, to: statement, index: 1) - try bindText(sessionId, to: statement, index: 2) - } - notifyDidChange() + try updateSessionMeta(sessionId: sessionId, title: title) } func setSessionPinned(sessionId: String, pinned: Bool) throws { + try updateSessionMeta(sessionId: sessionId, pinned: pinned) + } + + func updateSessionMeta( + sessionId: String, + title: String? = nil, + pinned: Bool? = nil, + manuallyNamed: Bool? = nil + ) throws { guard db != nil else { return } - _ = try execute("update terminal_sessions set pinned = ? where id = ?") { statement in - sqlite3_bind_int(statement, 1, pinned ? 1 : 0) - try bindText(sessionId, to: statement, index: 2) + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { return } + + var assignments: [String] = [] + var binders: [((OpaquePointer, Int32) throws -> Void)] = [] + + if let title { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + // Skip just the title write when it's blank — other field updates + // (pinned, manually_named) must still apply on the same call. + if !trimmedTitle.isEmpty { + assignments.append("title = ?") + binders.append { statement, index in + try self.bindText(trimmedTitle, to: statement, index: index) + } + } + } + + if let pinned { + assignments.append("pinned = ?") + binders.append { statement, index in + sqlite3_bind_int(statement, index, pinned ? 1 : 0) + } + } + + if let manuallyNamed { + assignments.append("manually_named = ?") + binders.append { statement, index in + sqlite3_bind_int(statement, index, manuallyNamed ? 1 : 0) + } + } + + guard !assignments.isEmpty else { return } + _ = try execute("update terminal_sessions set \(assignments.joined(separator: ", ")) where id = ?") { statement in + for (offset, binder) in binders.enumerated() { + try binder(statement, Int32(offset + 1)) + } + try self.bindText(trimmedSessionId, to: statement, index: Int32(binders.count + 1)) } notifyDidChange() } func fetchComputerUseArtifacts(ownerKind: String, ownerId: String) -> [ComputerUseArtifactSummary] { - guard let projectId = currentProjectId() else { return [] } + let projectIds = currentProjectScopeIds() + guard !projectIds.isEmpty else { return [] } + let projectPlaceholders = Array(repeating: "?", count: projectIds.count).joined(separator: ", ") let sql = """ select a.id, a.artifact_kind, a.backend_style, a.backend_name, a.source_tool_name, a.original_type, @@ -1533,18 +1613,26 @@ final class DatabaseService { l.owner_kind, l.owner_id, l.relation from computer_use_artifacts a inner join computer_use_artifact_links l on l.artifact_id = a.id - where a.project_id = ? - and l.project_id = ? + where a.project_id in (\(projectPlaceholders)) + and l.project_id in (\(projectPlaceholders)) and l.owner_kind = ? and l.owner_id = ? order by a.created_at asc """ return query(sql, bind: { [self] statement in - try self.bindText(projectId, to: statement, index: 1) - try self.bindText(projectId, to: statement, index: 2) - try self.bindText(ownerKind, to: statement, index: 3) - try self.bindText(ownerId, to: statement, index: 4) + var index: Int32 = 1 + for projectId in projectIds { + try self.bindText(projectId, to: statement, index: index) + index += 1 + } + for projectId in projectIds { + try self.bindText(projectId, to: statement, index: index) + index += 1 + } + try self.bindText(ownerKind, to: statement, index: index) + index += 1 + try self.bindText(ownerId, to: statement, index: index) }, map: { statement in ComputerUseArtifactRow( id: stringValue(statement, index: 0) ?? "", @@ -2225,6 +2313,16 @@ final class DatabaseService { columnName: "chat_session_id", definition: "text" ) + try ensureColumn( + tableName: "terminal_sessions", + columnName: "pending_input_item_id", + definition: "text" + ) + try ensureColumn( + tableName: "terminal_sessions", + columnName: "archived_at", + definition: "text" + ) try exec(""" create table if not exists lane_list_snapshots ( lane_id text primary key, @@ -2612,9 +2710,10 @@ final class DatabaseService { // One-time cleanup: excluded cache/snapshot tables should not participate // in phone-side CRDT at all. Drop their CRR metadata and pending changes // so they only flow through explicit hydration commands. - for cacheTable in DatabaseService.excludedCrrTables where hasTable(named: "\(cacheTable)__crsql_clock") { + for cacheTable in DatabaseService.excludedCrrTables where hasTable(named: "\(cacheTable)__crsql_clock") || hasTable(named: "\(cacheTable)__crsql_pks") { try dropCrrTriggers(for: cacheTable) try exec("drop table if exists \(quoteIdentifier("\(cacheTable)__crsql_clock"))") + try exec("drop table if exists \(quoteIdentifier("\(cacheTable)__crsql_pks"))") _ = try execute("delete from crsql_master where tbl_name = ?") { statement in try bindText(cacheTable, to: statement, index: 1) } @@ -2644,6 +2743,11 @@ final class DatabaseService { /// Treating them as CRDT tables is redundant and can break first-connect /// materialization when the incoming delta stream is not row-complete. private static let hydrationOwnedCrrExcludedTables: Set = [ + "files_workspaces", + "file_directory_snapshots", + "file_content_snapshots", + "file_diff_snapshots", + "file_history_snapshots", "lane_state_snapshots", "pull_request_snapshots", ] @@ -2909,6 +3013,33 @@ final class DatabaseService { return queryString("select id from projects order by last_opened_at desc, created_at desc limit 1") } + private func currentProjectScopeIds() -> [String] { + guard let projectId = currentProjectId() else { return [] } + let activeRootPath = queryString("select root_path from projects where id = ? limit 1") { [self] statement in + try self.bindText(projectId, to: statement, index: 1) + } + guard let activeRoot = normalizedProjectCacheRoot(activeRootPath) else { + return [projectId] + } + let matchingIds = query("select id, root_path from projects") { statement in + ( + id: stringValue(statement, index: 0) ?? "", + rootPath: stringValue(statement, index: 1) + ) + } + .filter { row in + guard !row.id.isEmpty else { return false } + return normalizedProjectCacheRoot(row.rootPath) == activeRoot + } + .map(\.id) + + var ordered = [projectId] + for id in matchingIds where !ordered.contains(id) { + ordered.append(id) + } + return ordered + } + private func projectCount() -> Int { Int(queryInt64("select count(*) from projects") ?? 0) } @@ -3671,6 +3802,69 @@ final class DatabaseService { return cols[0] } + private func encodeOutgoingCrsqlPrimaryKey(table: String, pk: SyncScalarValue) -> SyncScalarValue { + guard let tableInfo = syncTableInfo(for: table), + tableInfo.primaryKeyColumns.count == 1 + else { + return pk + } + return encodeCrsqlPrimaryKey(pk) + } + + private func encodeCrsqlPrimaryKey(_ pk: SyncScalarValue) -> SyncScalarValue { + guard case .bytes = pk else { + return packedCrsqlPrimaryKey(pk) ?? pk + } + return pk + } + + private func packedCrsqlPrimaryKey(_ value: SyncScalarValue) -> SyncScalarValue? { + var bytes = Data([0x01]) + + switch value { + case .string(let stringValue): + let utf8 = Array(stringValue.utf8) + guard utf8.count <= Int(UInt8.max) else { return nil } + bytes.append(0x0b) + bytes.append(UInt8(utf8.count)) + bytes.append(contentsOf: utf8) + case .number(let numberValue): + guard numberValue.rounded(.towardZero) == numberValue else { return nil } + guard numberValue >= Double(Int64.min), numberValue <= Double(Int64.max) else { return nil } + let integer = Int64(numberValue) + if integer == 0 { + bytes.append(0x08) + } else if integer == 1 { + bytes.append(0x09) + } else if integer >= Int64(Int8.min), integer <= Int64(Int8.max) { + bytes.append(0x01) + bytes.append(UInt8(bitPattern: Int8(integer))) + } else if integer >= Int64(Int16.min), integer <= Int64(Int16.max) { + bytes.append(0x02) + let value = Int16(integer) + bytes.append(UInt8(truncatingIfNeeded: value >> 8)) + bytes.append(UInt8(truncatingIfNeeded: value)) + } else if integer >= Int64(Int32.min), integer <= Int64(Int32.max) { + bytes.append(0x04) + let value = Int32(integer) + for shift in stride(from: 24, through: 0, by: -8) { + bytes.append(UInt8(truncatingIfNeeded: value >> Int32(shift))) + } + } else { + bytes.append(0x06) + for shift in stride(from: 56, through: 0, by: -8) { + bytes.append(UInt8(truncatingIfNeeded: integer >> Int64(shift))) + } + } + case .bytes: + return value + case .null: + return nil + } + + return .bytes(SyncScalarBytes(type: "bytes", base64: bytes.base64EncodedString())) + } + private func rowChangesRepresentDeletedRow( _ rowChanges: [CrsqlChangeRow], in tableInfo: SyncTableInfo, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 88806d6f1..592a92ffd 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3,6 +3,7 @@ import Foundation import Network import SwiftUI import UIKit +import UserNotifications import WidgetKit import os import zlib @@ -89,7 +90,14 @@ func syncConnectionHealth( enum SyncChatMessageDelivery: Equatable { case sent - case queued + case queued(steerId: String?) +} + +func syncChatMessageDelivery(from response: Any) -> SyncChatMessageDelivery { + if let response = response as? [String: Any], response["queued"] as? Bool == true { + return .queued(steerId: response["steerId"] as? String) + } + return .sent } func unwrapSyncCommandResponse(_ raw: Any) throws -> Any { @@ -217,6 +225,7 @@ enum InitialHydrationGate { enum SyncRequestTimeout { static let defaultTimeoutNanoseconds: UInt64 = 30_000_000_000 + static let modelCatalogTimeoutNanoseconds: UInt64 = 6_000_000_000 static let chatSendTimeoutNanoseconds: UInt64 = 120_000_000_000 static let laneDeleteTimeoutNanoseconds: UInt64 = 240_000_000_000 static let message = "The machine took too long to respond. Reconnecting now." @@ -245,6 +254,7 @@ private let syncChatSubscriptionMaxBytes = 2_000_000 private let syncReducedLoadChatSubscriptionMaxBytes = 160_000 private let syncTerminalBufferMaxCharacters = 240_000 private let chatEventHistoryMaxEvents = 1_000 +private let chatEventNotificationCoalesceNanoseconds: UInt64 = 420_000_000 enum SyncBonjourTiming { static let searchRetryNanoseconds: UInt64 = 2_000_000_000 @@ -356,9 +366,14 @@ func syncEndpointHost(_ rawValue: String) -> String? { } func syncConnectPortCandidates(primaryPort: Int, addresses: [String]) -> [Int] { + let normalizedHosts = addresses + .map(syncNormalizedRouteHost) + .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: ".")) } + let hasBonjourRoute = normalizedHosts.contains { $0.hasSuffix(".local") } + let hasTailnetRoute = addresses.contains(where: syncIsTailscaleRoute) let shouldTryDefaultPair = - SyncDirectHostPorts.portCandidates.contains(primaryPort) - || addresses.contains(where: syncIsTailscaleRoute) + (SyncDirectHostPorts.portCandidates.contains(primaryPort) && !hasBonjourRoute) + || hasTailnetRoute let fallbackPorts = shouldTryDefaultPair ? SyncDirectHostPorts.portCandidates : [] var seen = Set() return ([primaryPort] + fallbackPorts) @@ -497,6 +512,32 @@ func syncShouldPublishForegroundReconnectStarted( return !automaticAddresses.isEmpty } +func syncDiscoveredHostsEqualForPresentation( + _ left: DiscoveredSyncHost, + _ right: DiscoveredSyncHost +) -> Bool { + left.id == right.id + && left.serviceName == right.serviceName + && left.hostName == right.hostName + && left.hostIdentity == right.hostIdentity + && left.port == right.port + && left.addresses == right.addresses + && left.tailscaleAddress == right.tailscaleAddress + && left.runtimeKind == right.runtimeKind + && left.runtimeVersion == right.runtimeVersion + && left.projectIds == right.projectIds + && left.projectNames == right.projectNames + && left.projectCount == right.projectCount +} + +func syncDiscoveredHostListsEqualForPresentation( + _ left: [DiscoveredSyncHost], + _ right: [DiscoveredSyncHost] +) -> Bool { + guard left.count == right.count else { return false } + return zip(left, right).allSatisfy(syncDiscoveredHostsEqualForPresentation) +} + func syncClientHeartbeatIntervalNanoseconds(serverIntervalMs rawValue: Any?) -> UInt64 { let parsedMs: Double? = { if let number = rawValue as? NSNumber { @@ -741,6 +782,16 @@ struct LaneNavigationRequest: Equatable, Identifiable { } } +struct WorkSessionNavigationRequest: Equatable, Identifiable { + let id: String + let sessionId: String + + init(sessionId: String) { + self.id = UUID().uuidString + self.sessionId = sessionId + } +} + struct PrNavigationRequest: Equatable, Identifiable { let id: String let prId: String @@ -843,6 +894,11 @@ func syncOutboundEnvelopeProjectId(type: String, activeProjectId: String?) -> St return syncNormalizedCommandScopeValue(activeProjectId) } +struct SyncSendTestPushResult: Equatable { + var ok: Bool + var message: String +} + @MainActor final class SyncService: ObservableObject { @Published private(set) var connectionState: RemoteConnectionState = .disconnected @@ -867,9 +923,11 @@ final class SyncService: ObservableObject { @Published private(set) var subscribedChatSessionIds: Set = [] @Published private(set) var pendingOperationCount = 0 @Published private(set) var localStateRevision = 0 + @Published private(set) var workspaceSnapshotRevision = 0 @Published var settingsPresented = false @Published var projectHomePresented = true @Published var attentionDrawerPresented = false + @Published var requestedWorkSessionNavigation: WorkSessionNavigationRequest? @Published var requestedFilesNavigation: FilesNavigationRequest? @Published var requestedLaneNavigation: LaneNavigationRequest? @Published var requestedPrNavigation: PrNavigationRequest? @@ -883,6 +941,7 @@ final class SyncService: ObservableObject { } private(set) var terminalBuffers: [String: String] = [:] + private(set) var terminalBufferUpdatedAt: [String: Date] = [:] private(set) var chatEventEnvelopesBySession: [String: [AgentChatEventEnvelope]] = [:] private(set) var chatEventRevisionsBySession: [String: Int] = [:] /// Latest known chat summary keyed by session id. Populated by the Work @@ -1047,7 +1106,7 @@ final class SyncService: ObservableObject { private var activeSessionsObservationTask: Task? /// Backing storage for `attentionDrawer` + the Combine subscriptions it - /// uses to observe `activeSessions` / `localStateRevision`. Lazily + /// uses to observe `activeSessions` / workspace snapshot writes. Lazily /// initialised on first access so tests + previews that never touch the /// drawer don't allocate an extra `ObservableObject`. private var attentionDrawerStorage: AttentionDrawerModel? @@ -1228,16 +1287,8 @@ final class SyncService: ObservableObject { mergedById[activeProjectId] = existing } } - let sortedProjects = mergedById.values.sorted { left, right in - let leftActive = activeProjectId != nil && left.id == activeProjectId - let rightActive = activeProjectId != nil && right.id == activeProjectId - if leftActive != rightActive { return leftActive } - let leftOpen = left.isOpen ?? true - let rightOpen = right.isOpen ?? true - if leftOpen != rightOpen { return leftOpen } - return (left.lastOpenedAt ?? "") > (right.lastOpenedAt ?? "") - } - projects = preferRemoteSelection ? deduplicateProjectListByRoot(sortedProjects) : sortedProjects + let sortedProjects = sortedProjectList(Array(mergedById.values)) + projects = deduplicateProjectListByRoot(sortedProjects) if preferRemoteSelection { preferActiveProjectFromRemoteCatalogIfNeeded() } @@ -1306,6 +1357,18 @@ final class SyncService: ObservableObject { } } + private func sortedProjectList(_ candidates: [MobileProjectSummary]) -> [MobileProjectSummary] { + candidates.sorted { left, right in + let leftActive = activeProjectId != nil && left.id == activeProjectId + let rightActive = activeProjectId != nil && right.id == activeProjectId + if leftActive != rightActive { return leftActive } + let leftOpen = left.isOpen ?? true + let rightOpen = right.isOpen ?? true + if leftOpen != rightOpen { return leftOpen } + return (left.lastOpenedAt ?? "") > (right.lastOpenedAt ?? "") + } + } + private func activeProjectCatalogEntryForHydration() -> MobileProjectSummary? { guard let activeProjectId else { return nil } let candidates = projects + remoteProjectCatalog @@ -1601,11 +1664,28 @@ final class SyncService: ObservableObject { private func normalizeActiveProjectSelection(allowSingleProjectFallback: Bool) { let projectIds = Set(projects.map(\.id)) + + // Keep a still-valid active project before considering any remote-catalog + // remap. If the cached row is in `projects`, we must not override it with + // a different id from `remoteProjectCatalog` — `refreshProjectCatalog` + // intentionally preserves the existing selection when it keeps a cached + // row for the same root. if let activeProjectId, projectIds.contains(activeProjectId) { database.setActiveProjectId(activeProjectId) return } + if let activeProjectId, + let activeProjectRootPath, + let remoteProject = deduplicatedRemoteProjectCatalog().first(where: { project in + project.id != activeProjectId + && normalizedProjectRoot(project.rootPath) == activeProjectRootPath + }), + projectIds.contains(remoteProject.id) { + setActiveProjectId(remoteProject.id, rootPath: remoteProject.rootPath) + return + } + if let activeProjectId, let activeProjectRootPath, let matchingProject = projects.first(where: { normalizedProjectRoot($0.rootPath) == activeProjectRootPath }) { @@ -1678,7 +1758,7 @@ final class SyncService: ObservableObject { activeProjectRootPath = normalizedProjectRoot(UserDefaults.standard.string(forKey: activeProjectRootPathKey)) activeProjectHostIdentity = UserDefaults.standard.string(forKey: activeProjectHostIdentityKey) database.setActiveProjectId(activeProjectId) - projects = database.listMobileProjects() + projects = deduplicateProjectListByRoot(sortedProjectList(database.listMobileProjects())) outboundLocalDbVersion = loadOutboundCursorVersionForActiveProject(defaultVersion: database.currentDbVersion()) normalizeActiveProjectSelection(allowSingleProjectFallback: false) pendingOperationCount = loadPendingOperations().count @@ -2730,6 +2810,7 @@ final class SyncService: ObservableObject { let raw = try await sendCommand(action: "prs.refresh", args: args) let payload = try decodeHydrationPayload(raw, as: PullRequestRefreshPayload.self, domainLabel: "pull request", decoder: decoder) try database.replacePullRequestHydration(payload) + scheduleWorkspaceSnapshotWrite() setDomainStatus([.prs], phase: .ready) } catch { let friendlyMessage = SyncUserFacingError.message(for: error) @@ -2751,7 +2832,10 @@ final class SyncService: ObservableObject { } func refreshLaneDetail(laneId: String) async throws -> LaneDetailPayload { - setDomainStatus([.lanes], phase: .hydrating) + let laneStatus = status(for: .lanes) + if laneStatus.phase != .ready { + setDomainStatus([.lanes], phase: .hydrating) + } do { let detail = try await sendDecodableCommand(action: "lanes.getDetail", args: ["laneId": laneId], as: LaneDetailPayload.self) try database.replaceLaneDetail(detail) @@ -2831,13 +2915,19 @@ final class SyncService: ObservableObject { } func setSessionPinned(sessionId: String, pinned: Bool) async throws { + try database.setSessionPinned(sessionId: sessionId, pinned: pinned) if supportsRemoteAction("work.updateSessionMeta") { - _ = try await sendCommand(action: "work.updateSessionMeta", args: [ - "sessionId": sessionId, - "pinned": pinned, - ]) + do { + _ = try await sendCommand(action: "work.updateSessionMeta", args: [ + "sessionId": sessionId, + "pinned": pinned, + ]) + } catch { + syncConnectLog.info( + "work setSessionPinned deferred session=\(sessionId, privacy: .public) error=\(String(describing: error), privacy: .public)" + ) + } } - try database.setSessionPinned(sessionId: sessionId, pinned: pinned) } func updateSessionMeta( @@ -2856,14 +2946,20 @@ final class SyncService: ObservableObject { if let manuallyNamed { args["manuallyNamed"] = manuallyNamed } + try database.updateSessionMeta( + sessionId: sessionId, + title: title, + pinned: pinned, + manuallyNamed: manuallyNamed + ) if supportsRemoteAction("work.updateSessionMeta") { - _ = try await sendCommand(action: "work.updateSessionMeta", args: args) - } - if let title { - try database.updateSessionTitle(sessionId: sessionId, title: title) - } - if let pinned { - try database.setSessionPinned(sessionId: sessionId, pinned: pinned) + do { + _ = try await sendCommand(action: "work.updateSessionMeta", args: args) + } catch { + syncConnectLog.info( + "work updateSessionMeta deferred session=\(sessionId, privacy: .public) error=\(String(describing: error), privacy: .public)" + ) + } } } @@ -3114,17 +3210,23 @@ final class SyncService: ObservableObject { return } subscribedTerminalSessionIds.insert(trimmedSessionId) + try await refreshTerminalSnapshot(sessionId: trimmedSessionId) + } + + func refreshTerminalSnapshot(sessionId: String, maxBytes: Int = syncTerminalSubscriptionMaxBytes) async throws { + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { return } + subscribedTerminalSessionIds.insert(trimmedSessionId) let requestId = makeRequestId() let raw = try await awaitResponse(requestId: requestId) { self.sendEnvelope(type: "terminal_subscribe", requestId: requestId, payload: [ "sessionId": trimmedSessionId, - "maxBytes": syncTerminalSubscriptionMaxBytes, + "maxBytes": max(1_024, min(syncTerminalSubscriptionMaxBytes, maxBytes)), ]) } let snapshot = try decode(raw, as: TerminalSnapshot.self) guard subscribedTerminalSessionIds.contains(trimmedSessionId) else { return } - terminalBuffers[trimmedSessionId] = trimmedTerminalBuffer(snapshot.transcript) - markTerminalBufferChanged(immediate: true) + updateTerminalBuffer(sessionId: trimmedSessionId, transcript: snapshot.transcript, immediate: true) } func unsubscribeTerminal(sessionId: String) async throws { @@ -3170,17 +3272,23 @@ final class SyncService: ObservableObject { ]) } - func subscribeToChatEvents(sessionId: String) async throws { + func subscribeToChatEvents(sessionId: String, requestSnapshot: Bool = false, maxBytes: Int? = nil) async throws { let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedSessionId.isEmpty else { return } - guard !subscribedChatSessionIds.contains(trimmedSessionId) else { return } - subscribedChatSessionIds.insert(trimmedSessionId) - localStateRevision += 1 - if canSendLiveRequests() && supportsChatStreaming { - sendEnvelope(type: "chat_subscribe", requestId: nil, payload: chatSubscriptionPayload(sessionId: trimmedSessionId)) + let wasSubscribed = subscribedChatSessionIds.contains(trimmedSessionId) + if !wasSubscribed { + subscribedChatSessionIds.insert(trimmedSessionId) + localStateRevision += 1 + } + if canSendLiveRequests() && supportsChatStreaming && (!wasSubscribed || requestSnapshot) { + sendEnvelope(type: "chat_subscribe", requestId: nil, payload: chatSubscriptionPayload(sessionId: trimmedSessionId, maxBytes: maxBytes)) } } + func requestFullChatEventSnapshot(sessionId: String) async throws { + try await subscribeToChatEvents(sessionId: sessionId, requestSnapshot: true, maxBytes: syncChatSubscriptionMaxBytes) + } + func unsubscribeFromChatEvents(sessionId: String) async throws { let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedSessionId.isEmpty else { return } @@ -3231,6 +3339,8 @@ final class SyncService: ObservableObject { permissionMode: String? = nil, title: String? = nil, initialInput: String? = nil, + modelId: String? = nil, + reasoningEffort: String? = nil, cols: Int? = nil, rows: Int? = nil ) async throws -> StartCliSessionResult { @@ -3247,6 +3357,12 @@ final class SyncService: ObservableObject { if let initialInput, !initialInput.isEmpty { args["initialInput"] = initialInput } + if let modelId, !modelId.isEmpty { + args["modelId"] = modelId + } + if let reasoningEffort, !reasoningEffort.isEmpty { + args["reasoningEffort"] = reasoningEffort + } if let cols, cols > 0 { args["cols"] = cols } @@ -3627,11 +3743,14 @@ final class SyncService: ObservableObject { let task = Task { @MainActor [weak self] in guard let self else { throw CancellationError() } - return try await self.sendDecodableCommand( + let response = try await self.sendCommand( action: "chat.models", args: ["provider": normalizedProvider], - as: [AgentChatModelInfo].self + disconnectOnTimeout: false, + timeoutMessage: "Model list is still loading from the machine.", + timeoutNanoseconds: SyncRequestTimeout.modelCatalogTimeoutNanoseconds ) + return try self.decode(response, as: [AgentChatModelInfo].self) } chatModelsInFlight[cacheKey] = task @@ -3649,12 +3768,30 @@ final class SyncService: ObservableObject { } } - @MainActor - func cachedChatModelCatalog() -> AgentChatModelCatalog? { - guard let cached = chatModelCatalogCache.values.sorted(by: { $0.fetchedAt > $1.fetchedAt }).first else { return nil } - guard Date().timeIntervalSince(cached.fetchedAt) < chatModelsCacheTTL else { return nil } - return cached.catalog - } + @MainActor + func cachedChatModelCatalog() -> AgentChatModelCatalog? { + // Scope the fallback lookup to the current host/project. `chatModelsCacheKey` + // mixes host + project root into every key it writes, so returning the newest + // entry from *any* cache row could leak a different host's catalog into the + // active session. Match the key prefix instead and only consider entries that + // belong to the same host/project scope. + let scopePrefix = chatModelsCacheKey(provider: "") + let scopedEntries = chatModelCatalogCache + .filter { key, _ in + // Strip the provider component from the stored key and compare scopes. + // Both `scopePrefix` and the stored key use `\u{1f}` separators between + // provider, host, and project root; everything after the first separator + // is the host+project portion we care about. + guard let sepIndex = scopePrefix.firstIndex(of: "\u{1f}"), + let storedSepIndex = key.firstIndex(of: "\u{1f}") + else { return false } + return scopePrefix[sepIndex...] == key[storedSepIndex...] + } + .values + guard let cached = scopedEntries.sorted(by: { $0.fetchedAt > $1.fetchedAt }).first else { return nil } + guard Date().timeIntervalSince(cached.fetchedAt) < chatModelsCacheTTL else { return nil } + return cached.catalog + } @MainActor func getChatModelCatalog( @@ -3685,16 +3822,19 @@ final class SyncService: ObservableObject { } let task = Task { @MainActor [weak self] in - guard let self else { throw CancellationError() } - var args: [String: Any] = ["mode": mode] - if let refreshProvider { - args["refreshProvider"] = refreshProvider - } - return try await self.sendDecodableCommand( - action: "chat.modelCatalog", - args: args, - as: AgentChatModelCatalog.self - ) + guard let self else { throw CancellationError() } + var args: [String: Any] = ["mode": mode] + if let refreshProvider { + args["refreshProvider"] = refreshProvider + } + let response = try await self.sendCommand( + action: "chat.modelCatalog", + args: args, + disconnectOnTimeout: false, + timeoutMessage: "Model catalog is still loading from the machine.", + timeoutNanoseconds: SyncRequestTimeout.modelCatalogTimeoutNanoseconds + ) + return try self.decode(response, as: AgentChatModelCatalog.self) } chatModelCatalogInFlight[cacheKey] = task @@ -3789,7 +3929,11 @@ final class SyncService: ObservableObject { } func listChatSessions(laneId: String) async throws -> [AgentChatSessionSummary] { - try await sendDecodableCommand(action: "chat.listSessions", args: ["laneId": laneId, "includeAutomation": true], as: [AgentChatSessionSummary].self) + try await sendDecodableCommand( + action: "chat.listSessions", + args: ["laneId": laneId, "includeAutomation": true, "includeArchived": true], + as: [AgentChatSessionSummary].self + ) } func createChatSession( @@ -3873,7 +4017,7 @@ final class SyncService: ObservableObject { try await sendDecodableCommand(action: "chat.getSummary", args: ["sessionId": sessionId], as: AgentChatSessionSummary.self) } - func fetchChatTranscriptResponse(sessionId: String, limit: Int = 200, maxChars: Int = 32_000) async throws -> AgentChatTranscriptResponse { + func fetchChatTranscriptResponse(sessionId: String, limit: Int = 200, maxChars: Int = 120_000) async throws -> AgentChatTranscriptResponse { try await sendDecodableCommand( action: "chat.getTranscript", args: ["sessionId": sessionId, "limit": limit, "maxChars": maxChars], @@ -3881,7 +4025,7 @@ final class SyncService: ObservableObject { ) } - func fetchChatTranscript(sessionId: String, limit: Int = 200, maxChars: Int = 32_000) async throws -> [AgentChatTranscriptEntry] { + func fetchChatTranscript(sessionId: String, limit: Int = 200, maxChars: Int = 120_000) async throws -> [AgentChatTranscriptEntry] { let response = try await fetchChatTranscriptResponse(sessionId: sessionId, limit: limit, maxChars: maxChars) return response.entries } @@ -3895,18 +4039,17 @@ final class SyncService: ObservableObject { timeoutMessage: SyncRequestTimeout.chatSendMessage, timeoutNanoseconds: SyncRequestTimeout.chatSendTimeoutNanoseconds ) - if let response = response as? [String: Any], response["queued"] as? Bool == true { - return .queued - } - return .sent + return syncChatMessageDelivery(from: response) } func interruptChatSession(sessionId: String) async throws { _ = try await sendChatCommand(action: "chat.interrupt", payload: AgentChatInterruptRequest(sessionId: sessionId)) } - func steerChatSession(sessionId: String, text: String) async throws { - _ = try await sendChatCommand(action: "chat.steer", payload: AgentChatSteerRequest(sessionId: sessionId, text: text)) + @discardableResult + func steerChatSession(sessionId: String, text: String) async throws -> SyncChatMessageDelivery { + let response = try await sendChatCommand(action: "chat.steer", payload: AgentChatSteerRequest(sessionId: sessionId, text: text)) + return syncChatMessageDelivery(from: response) } func cancelChatSteer(sessionId: String, steerId: String) async throws { @@ -4173,18 +4316,30 @@ final class SyncService: ObservableObject { @discardableResult func startPrAiResolution(prId: String, model: String? = nil, reasoningEffort: String? = nil) async throws -> AiResolutionState { - var args: [String: Any] = ["prId": prId] - if let model, !model.isEmpty { - args["model"] = model - } - if let reasoningEffort, !reasoningEffort.isEmpty { - args["reasoningEffort"] = reasoningEffort - } - return try await sendDecodableCommand(action: "prs.aiResolutionStart", args: args, as: AiResolutionState.self) + let result = try await startPathToMerge( + prId: prId, + modelId: model, + reasoning: reasoningEffort, + scope: "both" + ) + if let blocker = result.blockedBy { + throw NSError(domain: "ADE", code: 31, userInfo: [NSLocalizedDescriptionKey: blocker.message]) + } + let runtime = result.runtime + return AiResolutionState( + prId: prId, + status: result.scheduled ? "running" : runtime.status, + sessionId: runtime.activeSessionId, + model: model, + reasoningEffort: reasoningEffort, + startedAt: runtime.lastStartedAt ?? runtime.createdAt, + updatedAt: runtime.updatedAt, + lastError: runtime.errorMessage + ) } func stopPrAiResolution(prId: String) async throws { - _ = try await sendCommand(action: "prs.aiResolutionStop", args: ["prId": prId]) + _ = try await stopPathToMerge(prId: prId, reason: "AI resolver stopped from mobile.") } func resolveReviewThread(prId: String, threadId: String, resolved: Bool) async throws { @@ -4330,7 +4485,7 @@ final class SyncService: ObservableObject { conflictStrategy: "pause", forceFinalizeMode: "off", forceFinalizeRequireNoCiFailures: true, - atCapPolicy: "stop", + atCapPolicy: "ci_retry_once", atCapWaitMinutes: 30, atCapCiRetryMax: 3, forceMergeRequiresConfirmation: true, @@ -5492,11 +5647,15 @@ final class SyncService: ObservableObject { } } let identifiedHosts = Array(mergedByIdentity.values) - let filteredNoIdentity = noIdentity.filter { host in + let anonymousHosts = mergeAnonymousDiscoveredHosts(noIdentity) + let filteredNoIdentity = anonymousHosts.filter { host in !shouldSuppressAnonymousTailnetHost(host, identifiedHosts: identifiedHosts) } let merged = identifiedHosts + filteredNoIdentity - discoveredHosts = merged.sorted { $0.hostName.localizedCaseInsensitiveCompare($1.hostName) == .orderedAscending } + let nextDiscoveredHosts = merged.sorted { $0.hostName.localizedCaseInsensitiveCompare($1.hostName) == .orderedAscending } + if !syncDiscoveredHostListsEqualForPresentation(discoveredHosts, nextDiscoveredHosts) { + discoveredHosts = nextDiscoveredHosts + } refreshSavedProfilesFromDiscovery() guard let profile = activeHostProfile else { return } let matching = discoveredHosts.filter { discovered in @@ -5522,6 +5681,63 @@ final class SyncService: ObservableObject { } } + private func mergeAnonymousDiscoveredHosts(_ hosts: [DiscoveredSyncHost]) -> [DiscoveredSyncHost] { + var mergedByKey: [String: DiscoveredSyncHost] = [:] + var orderedKeys: [String] = [] + + for host in hosts { + let key = anonymousDiscoveryKey(for: host) + if let existing = mergedByKey[key] { + mergedByKey[key] = mergeAnonymousDiscoveredHost(existing, with: host) + } else { + mergedByKey[key] = host + orderedKeys.append(key) + } + } + + return orderedKeys.compactMap { mergedByKey[$0] } + } + + private func anonymousDiscoveryKey(for host: DiscoveredSyncHost) -> String { + let routes = host.addresses + (host.tailscaleAddress.map { [$0] } ?? []) + if let route = routes + .map(syncNormalizedRouteHost) + .map({ $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }) + .first(where: { !$0.isEmpty }) { + return "route:\(route):\(host.port)" + } + + let hostName = host.hostName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if !hostName.isEmpty { + return "name:\(hostName):\(host.port)" + } + + return "id:\(host.id)" + } + + private func mergeAnonymousDiscoveredHost( + _ left: DiscoveredSyncHost, + with right: DiscoveredSyncHost + ) -> DiscoveredSyncHost { + let preferred = right.lastResolvedAt >= left.lastResolvedAt ? right : left + let fallback = right.lastResolvedAt >= left.lastResolvedAt ? left : right + return DiscoveredSyncHost( + id: preferred.id, + serviceName: preferred.serviceName.isEmpty ? fallback.serviceName : preferred.serviceName, + hostName: preferred.hostName.isEmpty ? fallback.hostName : preferred.hostName, + hostIdentity: nil, + port: preferred.port > 0 ? preferred.port : fallback.port, + addresses: deduplicatedAddresses(preferred.addresses + fallback.addresses), + tailscaleAddress: preferred.tailscaleAddress ?? fallback.tailscaleAddress, + runtimeKind: preferred.runtimeKind ?? fallback.runtimeKind, + runtimeVersion: preferred.runtimeVersion ?? fallback.runtimeVersion, + projectIds: deduplicatedStrings(preferred.projectIds + fallback.projectIds), + projectNames: deduplicatedStrings(preferred.projectNames + fallback.projectNames), + projectCount: preferred.projectCount ?? fallback.projectCount, + lastResolvedAt: max(preferred.lastResolvedAt, fallback.lastResolvedAt) + ) + } + private func refreshSavedProfilesFromDiscovery() { var profiles = loadSavedProfilesRaw() guard !profiles.isEmpty, !discoveredHosts.isEmpty else { return } @@ -5711,6 +5927,7 @@ final class SyncService: ObservableObject { func seedTerminalBufferForTesting(sessionId: String, transcript: String) { subscribedTerminalSessionIds.insert(sessionId) terminalBuffers[sessionId] = transcript + terminalBufferUpdatedAt[sessionId] = Date() terminalBufferRevision += 1 } @@ -5857,6 +6074,7 @@ final class SyncService: ObservableObject { lastError = nil lastSyncAt = Date() saveRemoteCommandDescriptors(commandDescriptors) + uploadSavedNotificationPreferences() let matchingDiscovery = discoveredHosts.first { discovered in discovered.hostIdentity == remoteHostIdentity @@ -6108,6 +6326,10 @@ final class SyncService: ObservableObject { } case "command_result", "file_response", "terminal_snapshot": resolve(requestId: requestId, result: .success(payload)) + case "in_app_notification": + if let dict = payload as? [String: Any] { + presentInAppNotification(dict) + } case "chat_subscribe": if supportsChatStreaming, let dict = payload as? [String: Any], @@ -6129,6 +6351,7 @@ final class SyncService: ObservableObject { if let dict = payload as? [String: Any], let sessionId = dict["sessionId"] as? String, let chunk = dict["data"] as? String { guard subscribedTerminalSessionIds.contains(sessionId) else { break } terminalBuffers[sessionId] = trimmedTerminalBuffer((terminalBuffers[sessionId] ?? "") + chunk) + terminalBufferUpdatedAt[sessionId] = Date() markTerminalBufferChanged() } case "terminal_exit": @@ -6136,6 +6359,7 @@ final class SyncService: ObservableObject { guard subscribedTerminalSessionIds.contains(sessionId) else { break } let exitCode = dict["exitCode"] as? Int terminalBuffers[sessionId] = trimmedTerminalBuffer((terminalBuffers[sessionId] ?? "") + "\n\n[process exited\(exitCode.map { " with \($0)" } ?? "")]") + terminalBufferUpdatedAt[sessionId] = Date() markTerminalBufferChanged(immediate: true) } default: @@ -6395,6 +6619,50 @@ final class SyncService: ObservableObject { } } + private func presentInAppNotification(_ payload: [String: Any]) { + guard let title = (payload["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !title.isEmpty, + let body = (payload["body"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !body.isEmpty else { + return + } + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.categoryIdentifier = notificationCategoryIdentifier(for: payload["category"] as? String) + if let metadata = payload["metadata"] as? [String: Any] { + content.userInfo = metadata + } + if let deepLink = payload["deepLink"] as? String, !deepLink.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + var next = content.userInfo + next["deepLink"] = deepLink + content.userInfo = next + } + let collapseId = (payload["collapseId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let requestId: String + if let collapseId, !collapseId.isEmpty { + requestId = "ade.in-app.\(collapseId)" + } else { + requestId = "ade.in-app.\(UUID().uuidString)" + } + let request = UNNotificationRequest(identifier: requestId, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + + private func notificationCategoryIdentifier(for category: String?) -> String { + switch category { + case "chat": + return NotificationCategories.Identifier.chatAwaitingInput + case "cto": + return NotificationCategories.Identifier.ctoMissionPhase + case "pr": + return NotificationCategories.Identifier.prReviewRequested + default: + return NotificationCategories.Identifier.systemAlert + } + } + private func awaitSocketOpen(_ task: URLSessionWebSocketTask) async throws { try await withCheckedThrowingContinuation { continuation in let taskIdentifier = task.taskIdentifier @@ -6764,10 +7032,11 @@ final class SyncService: ObservableObject { return ["queued": true] } - private func chatSubscriptionPayload(sessionId: String) -> [String: Any] { - let maxBytes = canSendLiveRequests() && prefersReducedSyncLoad + private func chatSubscriptionPayload(sessionId: String, maxBytes requestedMaxBytes: Int? = nil) -> [String: Any] { + let defaultMaxBytes = canSendLiveRequests() && prefersReducedSyncLoad ? syncReducedLoadChatSubscriptionMaxBytes : syncChatSubscriptionMaxBytes + let maxBytes = max(1_024, min(syncChatSubscriptionMaxBytes, requestedMaxBytes ?? defaultMaxBytes)) return [ "sessionId": sessionId, "maxBytes": maxBytes, @@ -6823,7 +7092,9 @@ final class SyncService: ObservableObject { } func replaceChatEventHistory(sessionId: String, events: [AgentChatEventEnvelope]) { - chatEventEnvelopesBySession[sessionId] = deduplicatedChatEventHistory(events) + let next = deduplicatedChatEventHistory(events) + guard chatEventEnvelopesBySession[sessionId] != next else { return } + chatEventEnvelopesBySession[sessionId] = next chatEventRevisionsBySession[sessionId, default: 0] += 1 lastSyncAt = Date() markChatEventsChanged(immediate: true) @@ -6831,7 +7102,9 @@ final class SyncService: ObservableObject { func mergeChatEventHistory(sessionId: String, events: [AgentChatEventEnvelope]) { let current = chatEventEnvelopesBySession[sessionId] ?? [] - chatEventEnvelopesBySession[sessionId] = deduplicatedChatEventHistory(current + events) + let next = deduplicatedChatEventHistory(current + events) + guard current != next else { return } + chatEventEnvelopesBySession[sessionId] = next chatEventRevisionsBySession[sessionId, default: 0] += 1 lastSyncAt = Date() markChatEventsChanged(immediate: true) @@ -6877,6 +7150,17 @@ final class SyncService: ObservableObject { return String(buffer.suffix(syncTerminalBufferMaxCharacters)) } + private func updateTerminalBuffer(sessionId: String, transcript: String, immediate: Bool = false) { + let nextBuffer = trimmedTerminalBuffer(transcript) + guard terminalBuffers[sessionId] != nextBuffer else { + terminalBufferUpdatedAt[sessionId] = Date() + return + } + terminalBuffers[sessionId] = nextBuffer + terminalBufferUpdatedAt[sessionId] = Date() + markTerminalBufferChanged(immediate: immediate) + } + private func markTerminalBufferChanged(immediate: Bool = false) { if immediate { terminalBufferRevisionTask?.cancel() @@ -6904,7 +7188,7 @@ final class SyncService: ObservableObject { guard chatEventRevisionTask == nil else { return } chatEventRevisionTask = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 120_000_000) + try? await Task.sleep(nanoseconds: chatEventNotificationCoalesceNanoseconds) guard let self, !Task.isCancelled else { return } self.chatEventNotificationRevision += 1 self.chatEventRevisionTask = nil @@ -6925,6 +7209,7 @@ final class SyncService: ObservableObject { if clearHistory { subscribedTerminalSessionIds.removeAll() terminalBuffers.removeAll() + terminalBufferUpdatedAt.removeAll() } terminalBufferRevision += 1 } @@ -7132,11 +7417,49 @@ extension SyncService { sendEnvelope(type: "notification_prefs", requestId: nil, payload: ["prefs": nested]) } + private func uploadSavedNotificationPreferences() { + uploadNotificationPrefs(NotificationPreferences.load(from: ADESharedContainer.defaults)) + } + /// Ask the host to deliver a test push to this device. The desktop decides /// which token kind (alert vs activity) to target based on what it last saw /// from us. - func sendTestPush() { - sendEnvelope(type: "send_test_push", requestId: nil, payload: ["kind": "alert"]) + func sendTestPush() async -> SyncSendTestPushResult { + // Fail fast when the socket is offline. Without this guard, `sendEnvelope` + // silently drops the frame and `awaitResponse` would sit until timeout, + // making the test-push button look unresponsive. + guard canSendLiveRequests() else { + return SyncSendTestPushResult(ok: false, message: "The paired machine is offline.") + } + let requestId = makeRequestId() + do { + let raw = try await awaitResponse( + requestId: requestId, + disconnectOnTimeout: false, + timeoutMessage: "The paired machine did not respond to the test push request." + ) { + self.sendEnvelope(type: "send_test_push", requestId: requestId, payload: ["kind": "alert"]) + } + guard let dict = raw as? [String: Any] else { + return SyncSendTestPushResult(ok: false, message: "The paired machine returned an unreadable test push response.") + } + if dict["ok"] as? Bool == true { + let result = dict["result"] as? [String: Any] + let message = (result?["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let message, !message.isEmpty { + return SyncSendTestPushResult(ok: true, message: message) + } + return SyncSendTestPushResult(ok: true, message: "Test push sent.") + } + let error = dict["error"] as? [String: Any] + let message = (error?["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let message, !message.isEmpty { + return SyncSendTestPushResult(ok: false, message: message) + } + return SyncSendTestPushResult(ok: false, message: "The paired machine could not send a test push.") + } catch { + return SyncSendTestPushResult(ok: false, message: error.localizedDescription) + } } /// Dispatch a remote command over the existing sync WebSocket. Used by: @@ -7269,12 +7592,12 @@ extension SyncService { let sessions = database.fetchSessions() let now = Date() - // `activeSessions` holds every live chat session — running, awaiting-input, - // and idle. The Live Activity / widget filter to running-only at render - // time so the user-facing roster never lists multi-hour-old zombies, while - // the in-app Attention Drawer still gets its full set. Non-chat (shell / - // CLI) sessions are excluded entirely. Completed / failed / ended sessions - // are dropped since they're terminal. + // `activeSessions` holds every relevant chat session — running, + // awaiting-input, idle, and failed. The Live Activity / widget filter to + // running-only at render time so the user-facing roster never lists + // multi-hour-old zombies, while the in-app Attention Drawer still gets its + // full set. Non-chat (shell / CLI) sessions are excluded entirely. + // Completed / ended sessions are dropped since they're terminal. var allAgents: [AgentSnapshot] = [] var runningAgents: [AgentSnapshot] = [] var awaitingInputCount = 0 @@ -7289,9 +7612,11 @@ extension SyncService { for session in sessions { let isChat = (session.toolType?.contains("chat") == true) guard isChat else { continue } + guard session.archivedAt == nil else { continue } let status = session.status.lowercased() - guard status != "completed" && status != "failed" && status != "ended" else { + let isFailedStatus = status == "failed" || status == "error" + guard status != "completed" && status != "ended" || isFailedStatus else { continue } @@ -7299,9 +7624,9 @@ extension SyncService { let isAwaiting = runtime == "waiting-input" || status == "awaiting_input" let isRunningRuntime = runtime == "running" let isIdleRuntime = runtime == "idle" - let isEndedRuntime = runtime == "exited" + let isEndedRuntime = runtime == "exited" || runtime == "killed" || runtime == "stopped" - if isEndedRuntime { continue } + if isEndedRuntime && !isFailedStatus && !isAwaiting { continue } let started = Self.parseIso8601(session.startedAt) ?? now // For active sessions there is no `endedAt`. Use the chat summary's @@ -7315,6 +7640,7 @@ extension SyncService { let summary = chatSummaryCache[session.id] let lastActivity = Self.parseIso8601(summary?.lastActivityAt ?? "") + ?? Self.parseIso8601(session.endedAt ?? "") ?? Self.parseIso8601(session.chatIdleSinceAt ?? "") ?? (isRunningRuntime ? now : started) let elapsed = Int(max(0, lastActivity.timeIntervalSince(started))) @@ -7330,11 +7656,12 @@ extension SyncService { modelId: resolvedModelId, laneName: session.laneName.isEmpty ? nil : session.laneName, title: session.title.isEmpty ? session.goal : session.title, - status: isRunningRuntime ? "running" : (isIdleRuntime ? "idle" : status), + status: isFailedStatus ? "failed" : (isRunningRuntime ? "running" : (isIdleRuntime ? "idle" : status)), awaitingInput: isAwaiting, lastActivityAt: lastActivity, elapsedSeconds: elapsed, preview: session.lastOutputPreview, + pendingInputItemId: isAwaiting ? (session.pendingInputItemId ?? pendingInputItemIdForSnapshot(sessionId: session.id)) : nil, progress: nil, phase: nil, toolCalls: 0 @@ -7342,6 +7669,10 @@ extension SyncService { allAgents.append(snap) + if isFailedStatus { + continue + } + // LA roster: ONLY truly streaming chats (runtime == running and seen // recently). Idle / awaiting / stale-running drop out entirely so the // lock screen never shows a session that isn't producing output now. @@ -7392,6 +7723,13 @@ extension SyncService { scheduleWorkspaceSnapshotWrite() } + private func pendingInputItemIdForSnapshot(sessionId: String) -> String? { + let events = chatEventEnvelopesBySession[sessionId] ?? [] + guard !events.isEmpty else { return nil } + let transcript = makeWorkChatTranscript(from: events) + return derivePendingWorkInputs(from: transcript).last?.itemId + } + /// Debounced writer for the App Group `WorkspaceSnapshot`. Bounces for 2s /// so bursty sync traffic collapses into a single widget-timeline reload. private func scheduleWorkspaceSnapshotWrite() { @@ -7434,6 +7772,7 @@ extension SyncService { ) if ADESharedContainer.writeWorkspaceSnapshot(snapshot) { + workspaceSnapshotRevision += 1 WidgetReloadBridge.reloadAllTimelines() } } @@ -7482,8 +7821,11 @@ extension SyncService { ] } - if !prefs.perSessionOverrides.isEmpty { - dict["perSessionOverrides"] = prefs.perSessionOverrides.mapValues { override in + let activeOverrides = prefs.perSessionOverrides.filter { _, override in + override.muted || override.awaitingInputOnly + } + if !activeOverrides.isEmpty { + dict["perSessionOverrides"] = activeOverrides.mapValues { override in [ "muted": override.muted, "awaitingInputOnly": override.awaitingInputOnly, diff --git a/apps/ios/ADE/Shared/ADESharedContainer.swift b/apps/ios/ADE/Shared/ADESharedContainer.swift index bc8318563..acd9fd777 100644 --- a/apps/ios/ADE/Shared/ADESharedContainer.swift +++ b/apps/ios/ADE/Shared/ADESharedContainer.swift @@ -64,6 +64,7 @@ public enum ADESharedContainer { encoder.dateEncodingStrategy = .iso8601 guard let data = try? encoder.encode(snapshot) else { return false } defaults.set(data, forKey: workspaceSnapshotKey) + defaults.synchronize() return true } diff --git a/apps/ios/ADE/Shared/ADESharedModels.swift b/apps/ios/ADE/Shared/ADESharedModels.swift index 71f95b4b4..58de4367c 100644 --- a/apps/ios/ADE/Shared/ADESharedModels.swift +++ b/apps/ios/ADE/Shared/ADESharedModels.swift @@ -30,6 +30,9 @@ public struct AgentSnapshot: Codable, Hashable, Identifiable, Sendable { public let elapsedSeconds: Int /// Truncated last-output preview. Always <= ~120 chars. public let preview: String? + /// Current pending input / approval item id when `awaitingInput == true`. + /// Optional so older snapshots from previous app versions decode cleanly. + public let pendingInputItemId: String? /// 0...1 when derivable; nil when the phase is open-ended. public let progress: Double? /// "planning" | "development" | "testing" | "validation" | "pr" | ... @@ -47,6 +50,7 @@ public struct AgentSnapshot: Codable, Hashable, Identifiable, Sendable { lastActivityAt: Date, elapsedSeconds: Int, preview: String?, + pendingInputItemId: String? = nil, progress: Double?, phase: String?, toolCalls: Int @@ -61,6 +65,7 @@ public struct AgentSnapshot: Codable, Hashable, Identifiable, Sendable { self.lastActivityAt = lastActivityAt self.elapsedSeconds = elapsedSeconds self.preview = preview + self.pendingInputItemId = pendingInputItemId self.progress = progress self.phase = phase self.toolCalls = toolCalls @@ -69,7 +74,7 @@ public struct AgentSnapshot: Codable, Hashable, Identifiable, Sendable { private enum CodingKeys: String, CodingKey { case sessionId, provider, modelId, laneName, title, status, awaitingInput, lastActivityAt, elapsedSeconds, preview, - progress, phase, toolCalls + pendingInputItemId, progress, phase, toolCalls } public init(from decoder: Decoder) throws { @@ -84,6 +89,7 @@ public struct AgentSnapshot: Codable, Hashable, Identifiable, Sendable { self.lastActivityAt = try c.decode(Date.self, forKey: .lastActivityAt) self.elapsedSeconds = try c.decode(Int.self, forKey: .elapsedSeconds) self.preview = try c.decodeIfPresent(String.self, forKey: .preview) + self.pendingInputItemId = try c.decodeIfPresent(String.self, forKey: .pendingInputItemId) self.progress = try c.decodeIfPresent(Double.self, forKey: .progress) self.phase = try c.decodeIfPresent(String.self, forKey: .phase) self.toolCalls = try c.decode(Int.self, forKey: .toolCalls) diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift index d9756be68..dc5904d56 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift +++ b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift @@ -81,6 +81,7 @@ public final class AttentionDrawerModel: ObservableObject { @Published public private(set) var unreadCount: Int = 0 public static let lastSeenAtKey = "ade.attention.lastSeenAt" + public static let dismissedItemIDsKey = "ade.attention.dismissedItemIDs" private var lastSeenAt: Date { didSet { @@ -93,6 +94,7 @@ public final class AttentionDrawerModel: ObservableObject { } private let defaults: UserDefaults + private var dismissedItemIDs: Set public init(defaults: UserDefaults = ADESharedContainer.defaults) { self.defaults = defaults @@ -100,6 +102,7 @@ public final class AttentionDrawerModel: ObservableObject { self.lastSeenAt = stored > 0 ? Date(timeIntervalSince1970: stored) : .distantPast + self.dismissedItemIDs = Set(defaults.stringArray(forKey: Self.dismissedItemIDsKey) ?? []) } // MARK: - Reducer @@ -122,6 +125,7 @@ public final class AttentionDrawerModel: ObservableObject { subtitle: "Approval needed", providerSlug: agent.provider, sessionId: agent.sessionId, + itemId: agent.pendingInputItemId, deepLink: URL(string: "ade://session/\(agent.sessionId)"), timestamp: agent.lastActivityAt ) @@ -187,6 +191,9 @@ public final class AttentionDrawerModel: ObservableObject { } } + pruneDismissedItems(activeIDs: Set(result.map(\.id))) + result.removeAll { dismissedItemIDs.contains($0.id) } + result.sort { lhs, rhs in let lp = Self.kindPriority(lhs.kind) let rp = Self.kindPriority(rhs.kind) @@ -205,6 +212,21 @@ public final class AttentionDrawerModel: ObservableObject { lastSeenAt = Date() } + /// Clear the currently visible attention cards from the drawer. The + /// dismissal is scoped to the active attention IDs and is pruned once the + /// backing state clears, so a future CI/review/agent regression reappears. + public func clearVisibleItems() { + guard !items.isEmpty else { + markAllSeen() + return + } + + dismissedItemIDs.formUnion(items.map(\.id)) + persistDismissedItems() + items.removeAll() + markAllSeen() + } + // MARK: - Bell affordance /// Count label for the drawer badge. Returns `nil` at zero, `"9+"` for @@ -220,6 +242,17 @@ public final class AttentionDrawerModel: ObservableObject { unreadCount = items.filter { $0.timestamp > lastSeenAt }.count } + private func pruneDismissedItems(activeIDs: Set) { + let pruned = dismissedItemIDs.intersection(activeIDs) + guard pruned != dismissedItemIDs else { return } + dismissedItemIDs = pruned + persistDismissedItems() + } + + private func persistDismissedItems() { + defaults.set(Array(dismissedItemIDs).sorted(), forKey: Self.dismissedItemIDsKey) + } + private static func kindPriority(_ kind: AttentionKind) -> Int { switch kind { case .awaitingInput: return 0 @@ -249,7 +282,7 @@ public final class AttentionDrawerModel: ObservableObject { @available(iOS 17.0, *) extension AttentionDrawerModel { /// Wire the drawer model up to a live `SyncService`: rebuild whenever - /// the service's `activeSessions` or `localStateRevision` change. The + /// the service's `activeSessions` or App Group workspace snapshot changes. The /// workspace snapshot is read from the App Group since `SyncService` /// already writes the authoritative blob there — no separate transport. /// @@ -280,6 +313,11 @@ extension AttentionDrawerModel { .sink { _ in refresh() } .store(in: &bag) + syncService.$workspaceSnapshotRevision + .receive(on: DispatchQueue.main) + .sink { _ in refresh() } + .store(in: &bag) + refresh() return bag } diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift index 6c9488e18..2e3f9cb88 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift +++ b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift @@ -1,3 +1,4 @@ +import AppIntents import SwiftUI /// Presented as a `medium`/`large` sheet from any root screen when the user @@ -33,9 +34,9 @@ struct AttentionDrawerSheet: View { } ToolbarItem(placement: .topBarTrailing) { Button("Clear all") { - drawer.markAllSeen() + drawer.clearVisibleItems() } - .disabled(drawer.unreadCount == 0) + .disabled(drawer.items.isEmpty) } } .adeScreenBackground() @@ -194,8 +195,8 @@ private struct AttentionDrawerCard: View { var body: some View { let tint = AttentionIcon.tint(for: item.kind) - Button(action: onTap) { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 10) { + Button(action: onTap) { HStack(alignment: .top, spacing: 12) { AttentionBadge(kind: item.kind, size: 30, pulse: item.kind == .awaitingInput) @@ -218,59 +219,208 @@ private struct AttentionDrawerCard: View { .padding(.top, 4) } } - - AttentionActionRow(attention: item.attentionPayload) } - .padding(14) - .background( - ZStack { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.ultraThinMaterial) - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(tint.opacity(0.16)) - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill( - RadialGradient( - colors: [tint.opacity(0.22), tint.opacity(0.0)], - center: .topLeading, - startRadius: 0, - endRadius: 200 - ) - ) - } - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill( - LinearGradient( - colors: [Color.white.opacity(0.08), .clear], - startPoint: .top, - endPoint: .center - ) + .buttonStyle(.plain) + .accessibilityLabel("\(item.title). \(item.subtitle)") + .accessibilityHint(item.deepLink == nil ? "" : "Opens the related surface.") + + AttentionDrawerActionRow(item: item, open: onTap) + } + .padding(14) + .background(cardBackground(tint: tint)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill( + LinearGradient( + colors: [Color.white.opacity(0.06), .clear], + startPoint: .top, + endPoint: .center ) - .allowsHitTesting(false) - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder( - LinearGradient( - colors: [ - tint.opacity(0.55), - tint.opacity(0.15), - ], - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.75 + ) + .allowsHitTesting(false) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [ + tint.opacity(0.32), + tint.opacity(0.10), + ], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.75 + ) + ) + .shadow(color: Color.black.opacity(0.18), radius: 3, x: 0, y: 1) + .accessibilityElement(children: .contain) + } + + private func cardBackground(tint: Color) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(ADEColor.cardBackground.opacity(0.98)) + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(tint.opacity(0.045)) + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill( + RadialGradient( + colors: [tint.opacity(0.06), tint.opacity(0.0)], + center: .topLeading, + startRadius: 0, + endRadius: 180 ) - ) - .shadow(color: tint.opacity(0.35), radius: 8, x: 0, y: 4) - .shadow(color: Color.black.opacity(0.25), radius: 5, x: 0, y: 2) + ) } - .buttonStyle(.plain) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(item.title). \(item.subtitle)") - .accessibilityHint(item.deepLink == nil ? "" : "Opens the related surface.") + } +} + +@available(iOS 17.0, *) +private struct AttentionDrawerActionRow: View { + let item: AttentionItem + let open: () -> Void + + var body: some View { + HStack(spacing: 8) { + switch item.kind { + case .awaitingInput: + let canAnswerInline = !(item.itemId ?? "").isEmpty + if canAnswerInline { + Button(intent: ApproveSessionIntent(sessionId: item.sessionId ?? "", itemId: item.itemId ?? "")) { + AttentionDrawerActionLabel("Approve", systemImage: "checkmark", variant: .primary(ADEColor.success)) + } + .buttonStyle(.plain) + + Button(intent: DenySessionIntent(sessionId: item.sessionId ?? "", itemId: item.itemId ?? "")) { + AttentionDrawerActionLabel("Deny", systemImage: "xmark", variant: .danger) + } + .buttonStyle(.plain) + } + + Button(action: open) { + AttentionDrawerActionLabel(canAnswerInline ? "Reply" : "Open session", systemImage: "text.bubble", variant: .secondary) + } + .buttonStyle(.plain) + + case .failed: + Button(action: open) { + AttentionDrawerActionLabel("Open agent", systemImage: "arrow.right", variant: .primary(ADEColor.accent)) + } + .buttonStyle(.plain) + + Button(intent: RestartSessionIntent(sessionId: item.sessionId ?? "")) { + AttentionDrawerActionLabel("Restart", systemImage: "arrow.uturn.backward", variant: .secondary) + } + .buttonStyle(.plain) + + case .ciFailing: + Button(action: open) { + AttentionDrawerActionLabel(prLabel("Open"), systemImage: "arrow.triangle.branch", variant: .primary(ADEColor.accent)) + } + .buttonStyle(.plain) + + Button(intent: RetryCheckIntent(prNumber: item.prNumber ?? 0, prId: item.prId ?? "")) { + AttentionDrawerActionLabel("Rerun CI", systemImage: "arrow.uturn.backward", variant: .secondary) + } + .buttonStyle(.plain) + + case .reviewRequested: + Button(action: open) { + AttentionDrawerActionLabel(prLabel("Review"), systemImage: "eye", variant: .primary(ADEColor.accent)) + } + .buttonStyle(.plain) + + case .mergeReady: + Button(action: open) { + AttentionDrawerActionLabel(prLabel("Merge"), systemImage: "checkmark.seal", variant: .primary(ADEColor.success)) + } + .buttonStyle(.plain) + + Button(action: open) { + AttentionDrawerActionLabel("View", systemImage: "arrow.right", variant: .secondary) + } + .buttonStyle(.plain) + } + } + } + + private func prLabel(_ verb: String) -> String { + if let number = item.prNumber, number > 0 { + return "\(verb) #\(number)" + } + return "\(verb) PR" + } +} + +@available(iOS 17.0, *) +private enum AttentionDrawerActionVariant { + case primary(Color) + case secondary + case danger + + var foreground: Color { + switch self { + case .primary(let tint): return tint + case .secondary: return ADEColor.textPrimary + case .danger: return ADEColor.danger + } + } + + var background: Color { + switch self { + case .primary(let tint): return tint.opacity(0.18) + case .secondary: return ADEColor.surfaceBackground.opacity(0.72) + case .danger: return ADEColor.danger.opacity(0.14) + } + } + + var stroke: Color { + switch self { + case .primary(let tint): return tint.opacity(0.32) + case .secondary: return ADEColor.glassBorder + case .danger: return ADEColor.danger.opacity(0.30) + } + } +} + +@available(iOS 17.0, *) +private struct AttentionDrawerActionLabel: View { + let title: String + let systemImage: String? + let variant: AttentionDrawerActionVariant + + init( + _ title: String, + systemImage: String? = nil, + variant: AttentionDrawerActionVariant + ) { + self.title = title + self.systemImage = systemImage + self.variant = variant + } + + var body: some View { + HStack(spacing: 5) { + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 10, weight: .bold)) + } + Text(title) + .font(.system(size: 12, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.82) + } + .foregroundStyle(variant.foreground) + .frame(maxWidth: .infinity) + .padding(.vertical, 7) + .padding(.horizontal, 10) + .background(variant.background, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(variant.stroke, lineWidth: 0.6) + ) + .contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) } } diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 548c80a85..1d6ca8806 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -212,14 +212,16 @@ enum ADEColor { /// Per-model reasoning tiers mirroring desktop's `reasoningTiers` field in /// `apps/desktop/src/shared/modelRegistry.ts`. Keys cover both the registry /// id ("anthropic/claude-opus-4-7") and shortId ("opus") so lookups work - /// against either form of `chatSummary.modelId`. Models missing from this - /// map (e.g. Haiku) don't support effort tiers; callers should hide the - /// effort picker entirely in that case. + /// against either form of `chatSummary.modelId`. Keep this aligned with + /// `apps/desktop/src/shared/modelRegistry.ts` so the mobile composer exposes + /// the same effort actions as desktop and the ADE TUI. private static let modelReasoningTiers: [String: [String]] = [ // Claude - "anthropic/claude-opus-4-7": ["low", "medium", "high", "max"], - "opus": ["low", "medium", "high", "max"], + "anthropic/claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"], + "opus": ["low", "medium", "high", "xhigh", "max"], "anthropic/claude-opus-4-7-1m": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-7-1m": ["low", "medium", "high", "xhigh", "max"], "opus-1m": ["low", "medium", "high", "xhigh", "max"], "opus[1m]": ["low", "medium", "high", "xhigh", "max"], "claude-opus-4-7[1m]": ["low", "medium", "high", "xhigh", "max"], @@ -235,6 +237,13 @@ enum ADEColor { "gpt-5.4-mini": ["low", "medium", "high", "xhigh"], "openai/gpt-5.3-codex": ["low", "medium", "high", "xhigh"], "gpt-5.3-codex": ["low", "medium", "high", "xhigh"], + "openai/gpt-5.3-codex-spark": ["low", "medium", "high", "xhigh"], + "gpt-5.3-codex-spark": ["low", "medium", "high", "xhigh"], + "gpt-5.3-spark": ["low", "medium", "high", "xhigh"], + "codex-spark": ["low", "medium", "high", "xhigh"], + "spark": ["low", "medium", "high", "xhigh"], + "openai/gpt-5.2": ["low", "medium", "high", "xhigh"], + "gpt-5.2": ["low", "medium", "high", "xhigh"], ] /// Return the reasoning tiers supported by a model, or nil when the model @@ -324,6 +333,27 @@ enum ADEMotion { } } +enum ADEUIKitAppearance { + @MainActor + static func configureTabBar() { + let appearance = UITabBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundEffect = nil + appearance.backgroundColor = UIColor { traits in + traits.userInterfaceStyle == .dark ? hex(0x16141e) : hex(0xfaf8f5) + } + appearance.shadowColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? hex(0xffffff, alpha: 0.10) + : hex(0x1a1a1e, alpha: 0.10) + } + + let tabBar = UITabBar.appearance() + tabBar.standardAppearance = appearance + tabBar.scrollEdgeAppearance = appearance + } +} + final class ADEImageCache { static let shared = ADEImageCache() @@ -689,7 +719,7 @@ struct ADERootToolbarControls: View { toolbarIconButton( icon: "square.grid.2x2.fill", tint: PrsGlass.accentTop, - isAlive: true, + isAlive: false, accessibilityLabel: "Projects", action: { syncService.showProjectHome() } ) @@ -707,12 +737,12 @@ struct ADERootToolbarControls: View { if hasUnread { Circle() - .fill(PrsGlass.glowPink) + .fill(ADEColor.warning) .frame(width: 7, height: 7) .overlay( Circle().stroke(PrsGlass.ink, lineWidth: 1.25) ) - .shadow(color: PrsGlass.glowPink.opacity(0.85), radius: 5, x: 0, y: 0) + .shadow(color: ADEColor.warning.opacity(0.45), radius: 3, x: 0, y: 0) .offset(x: -7, y: 6) .transition(.scale.combined(with: .opacity)) .accessibilityHidden(true) @@ -723,7 +753,7 @@ struct ADERootToolbarControls: View { .padding(.vertical, 4) .background { RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.ultraThinMaterial) + .fill(ADEColor.glassBackground) } .overlay { // Soft vertical highlight (white 0.10 → 0). @@ -755,7 +785,7 @@ struct ADERootToolbarControls: View { .allowsHitTesting(false) } .compositingGroup() - .shadow(color: Color.black.opacity(0.45), radius: 24, x: 0, y: 8) + .shadow(color: Color.black.opacity(0.28), radius: 12, x: 0, y: 5) .fixedSize(horizontal: true, vertical: false) } @@ -778,14 +808,14 @@ struct ADERootToolbarControls: View { ZStack { if isAlive { Circle() - .fill(tint.opacity(0.45)) - .frame(width: 26, height: 26) - .blur(radius: 8) + .fill(tint.opacity(0.18)) + .frame(width: 24, height: 24) + .blur(radius: 3) } Image(systemName: icon) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(tint) - .shadow(color: isAlive ? tint.opacity(0.6) : .clear, radius: 6, x: 0, y: 0) + .shadow(color: isAlive ? tint.opacity(0.28) : .clear, radius: 2, x: 0, y: 0) } .frame(width: 38, height: 34) .contentShape(Rectangle()) @@ -857,6 +887,20 @@ struct ADERootTopBar: View { .padding(.horizontal, 16) .padding(.top, 2) .frame(height: 60) + .background { + LinearGradient( + colors: [ + ADEColor.pageBackground, + ADEColor.pageBackground.opacity(0.98), + ADEColor.pageBackground.opacity(0.88), + ADEColor.pageBackground.opacity(0) + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) + } } } @@ -1011,6 +1055,14 @@ struct ADEGlassGroup: View { } } +struct ADERootTabBarHiddenPreferenceKey: PreferenceKey { + static var defaultValue = false + + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = value || nextValue() + } +} + private struct ADEGlassCardModifier: ViewModifier { let cornerRadius: CGFloat let padding: CGFloat @@ -1049,7 +1101,7 @@ private struct ADENavigationGlassModifier: ViewModifier { content .toolbarBackground(.clear, for: .navigationBar) .toolbarBackgroundVisibility(.visible, for: .navigationBar) - .toolbarBackground(.clear, for: .tabBar) + .toolbarBackground(ADEColor.surfaceBackground.opacity(0.96), for: .tabBar) .toolbarBackgroundVisibility(.visible, for: .tabBar) } } @@ -1131,4 +1183,8 @@ extension View { func adeNavigationZoomTransition(id: String?, in namespace: Namespace.ID?) -> some View { modifier(ADENavigationZoomTransitionModifier(id: id, namespace: namespace)) } + + func adeRootTabBarHidden(_ hidden: Bool = true) -> some View { + preference(key: ADERootTabBarHiddenPreferenceKey.self, value: hidden) + } } diff --git a/apps/ios/ADE/Views/Components/ADEMobilePrimitives.swift b/apps/ios/ADE/Views/Components/ADEMobilePrimitives.swift index f4e549b53..f78bb01e5 100644 --- a/apps/ios/ADE/Views/Components/ADEMobilePrimitives.swift +++ b/apps/ios/ADE/Views/Components/ADEMobilePrimitives.swift @@ -97,6 +97,62 @@ struct ADEGlassActionButton: View { } } +struct ADEOptionButton: View { + let title: String + var subtitle: String? = nil + var systemImage: String? = nil + let isSelected: Bool + var tint: Color = ADEColor.accent + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .center, spacing: 10) { + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(isSelected ? tint : ADEColor.textSecondary) + .frame(width: 20) + } + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) + .multilineTextAlignment(.leading) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + } + Spacer(minLength: 0) + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(tint) + } + } + .padding(12) + .frame(maxWidth: .infinity, minHeight: 46, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(isSelected ? tint.opacity(0.13) : ADEColor.surfaceBackground.opacity(0.10)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(isSelected ? tint.opacity(0.55) : ADEColor.glassBorder, lineWidth: isSelected ? 1 : 0.5) + ) + .glassEffect(in: .rect(cornerRadius: 12)) + } + .buttonStyle(ADEScaleButtonStyle()) + .accessibilityLabel(subtitle.map { "\(title). \($0)" } ?? title) + .accessibilityValue(isSelected ? "Selected" : "") + } +} + struct ADEGlassHoldActionButton: View { let title: String let symbol: String diff --git a/apps/ios/ADE/Views/Components/ADEStreamingShimmer.swift b/apps/ios/ADE/Views/Components/ADEStreamingShimmer.swift deleted file mode 100644 index bf2150e0e..000000000 --- a/apps/ios/ADE/Views/Components/ADEStreamingShimmer.swift +++ /dev/null @@ -1,76 +0,0 @@ -import SwiftUI - -/// A subtle one-direction gradient sweep used to mark a "this is live" card -/// (e.g. the assistant bubble during a streaming turn). The sweep is an -/// overlay — call sites do not need to know its geometry, just what shape -/// to mask against. Guarded against Reduce Motion: when `reduceMotion` is -/// true, the modifier becomes a no-op so the underlying glow alone signals -/// liveness. -/// -/// Port of the desktop `.ade-streaming-shimmer` treatment -/// (apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx:1228). -struct ADEStreamingShimmer: ViewModifier { - let isActive: Bool - let cornerRadius: CGFloat - let tint: Color - - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var sweepOffset: CGFloat = -1.1 - - init(isActive: Bool, cornerRadius: CGFloat = 18, tint: Color = ADEColor.accent) { - self.isActive = isActive - self.cornerRadius = cornerRadius - self.tint = tint - } - - func body(content: Content) -> some View { - content - .overlay { - if isActive && !reduceMotion { - GeometryReader { proxy in - LinearGradient( - colors: [ - .clear, - tint.opacity(0.22), - .clear, - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: proxy.size.width * 0.7) - .offset(x: proxy.size.width * sweepOffset) - .blendMode(.plusLighter) - .allowsHitTesting(false) - } - .mask( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(.white) - ) - .onAppear { - sweepOffset = -1.1 - withAnimation(.linear(duration: 2.1).repeatForever(autoreverses: false)) { - sweepOffset = 1.1 - } - } - } - } - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .stroke(isActive ? tint.opacity(0.35) : Color.clear, lineWidth: isActive ? 0.9 : 0) - ) - .shadow(color: isActive ? tint.opacity(0.22) : .clear, radius: isActive ? 12 : 0, y: 4) - } -} - -extension View { - /// Applies the ADE streaming shimmer + accent glow overlay to this view. - /// Intended for the active assistant bubble and the reasoning card's live - /// state. Pass `isActive: false` to get a zero-cost no-op. - func adeStreamingShimmer( - isActive: Bool, - cornerRadius: CGFloat = 18, - tint: Color = ADEColor.accent - ) -> some View { - modifier(ADEStreamingShimmer(isActive: isActive, cornerRadius: cornerRadius, tint: tint)) - } -} diff --git a/apps/ios/ADE/Views/Cto/CtoBriefEditor.swift b/apps/ios/ADE/Views/Cto/CtoBriefEditor.swift index c46285416..1833adf71 100644 --- a/apps/ios/ADE/Views/Cto/CtoBriefEditor.swift +++ b/apps/ios/ADE/Views/Cto/CtoBriefEditor.swift @@ -18,91 +18,110 @@ struct CtoBriefEditor: View { var body: some View { NavigationStack { - Form { - if let errorMessage { - Section { - Text(errorMessage) - .font(.subheadline) - .foregroundStyle(ADEColor.danger) + VStack(spacing: 0) { + editorHeader + + Form { + if let errorMessage { + Section { + Text(errorMessage) + .font(.subheadline) + .foregroundStyle(ADEColor.danger) + } } - } - Section { - TextEditor(text: $projectSummary) - .font(.system(.body)) - .frame(minHeight: 100) - } header: { - Text("Project summary") - } + Section { + TextEditor(text: $projectSummary) + .font(.system(.body)) + .frame(minHeight: 100) + } header: { + Text("Project summary") + } - Section { - TextEditor(text: $criticalConventions) - .font(.system(.callout, design: .monospaced)) - .frame(minHeight: 100) - } header: { - Text("Critical conventions") - } footer: { - Text("One per line.") - } + Section { + TextEditor(text: $criticalConventions) + .font(.system(.callout, design: .monospaced)) + .frame(minHeight: 100) + } header: { + Text("Critical conventions") + } footer: { + Text("One per line.") + } - Section { - TextEditor(text: $userPreferences) - .font(.system(.callout, design: .monospaced)) - .frame(minHeight: 100) - } header: { - Text("User preferences") - } footer: { - Text("One per line.") - } + Section { + TextEditor(text: $userPreferences) + .font(.system(.callout, design: .monospaced)) + .frame(minHeight: 100) + } header: { + Text("User preferences") + } footer: { + Text("One per line.") + } - Section { - TextEditor(text: $activeFocus) - .font(.system(.callout, design: .monospaced)) - .frame(minHeight: 80) - } header: { - Text("Active focus") - } footer: { - Text("One per line.") - } + Section { + TextEditor(text: $activeFocus) + .font(.system(.callout, design: .monospaced)) + .frame(minHeight: 80) + } header: { + Text("Active focus") + } footer: { + Text("One per line.") + } - Section { - TextEditor(text: $notes) - .font(.system(.callout, design: .monospaced)) - .frame(minHeight: 80) - } header: { - Text("Notes") - } footer: { - Text("One per line.") - } - } - .scrollContentBackground(.hidden) - .adeScreenBackground() - .navigationTitle("Edit brief") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { dismiss() } - .disabled(isSaving) - } - ToolbarItem(placement: .topBarTrailing) { - Button { - Task { await save() } - } label: { - if isSaving { - ProgressView().controlSize(.small) - } else { - Text("Save").fontWeight(.semibold) - } + Section { + TextEditor(text: $notes) + .font(.system(.callout, design: .monospaced)) + .frame(minHeight: 80) + } header: { + Text("Notes") + } footer: { + Text("One per line.") } - .disabled(isSaving) } + .scrollContentBackground(.hidden) } + .adeScreenBackground() + .navigationTitle("") + .toolbar(.hidden, for: .navigationBar) } .presentationDetents([.large]) .tint(ADEColor.accent) .onAppear(perform: hydrate) } + private var editorHeader: some View { + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.glass) + .disabled(isSaving) + .accessibilityLabel("Cancel edit brief") + + Spacer(minLength: 0) + + Text("Edit brief") + .font(.headline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + + Spacer(minLength: 0) + + Button { + Task { await save() } + } label: { + if isSaving { + ProgressView().controlSize(.small) + } else { + Text("Save").fontWeight(.semibold) + } + } + .buttonStyle(.glass) + .disabled(isSaving) + .accessibilityLabel("Save edit brief") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(ADEColor.pageBackground.opacity(0.98)) + } + private func hydrate() { guard let memory = snapshot?.coreMemory else { return } projectSummary = memory.projectSummary diff --git a/apps/ios/ADE/Views/Cto/CtoIdentityEditor.swift b/apps/ios/ADE/Views/Cto/CtoIdentityEditor.swift index 8d803a461..4e280b080 100644 --- a/apps/ios/ADE/Views/Cto/CtoIdentityEditor.swift +++ b/apps/ios/ADE/Views/Cto/CtoIdentityEditor.swift @@ -28,76 +28,137 @@ struct CtoIdentityEditor: View { var body: some View { NavigationStack { - Form { - if let errorMessage { - Section { - Text(errorMessage) - .font(.subheadline) - .foregroundStyle(ADEColor.danger) + VStack(spacing: 0) { + editorHeader + + Form { + if let errorMessage { + Section { + Text(errorMessage) + .font(.subheadline) + .foregroundStyle(ADEColor.danger) + } } - } - Section("Name") { - TextField("CTO", text: $localName) - .textInputAutocapitalization(.words) - .disableAutocorrection(false) - } + Section("Name") { + TextField("CTO", text: $localName) + .textInputAutocapitalization(.words) + .disableAutocorrection(false) + } - Section("Personality") { - Picker("Personality", selection: $localPersonality) { - ForEach(presets, id: \.id) { preset in - Text(preset.label).tag(preset.id) + Section("Personality") { + VStack(spacing: 8) { + ForEach(presets, id: \.id) { preset in + ADEOptionButton( + title: preset.label, + subtitle: personalityDescription(for: preset.id), + systemImage: personalityIcon(for: preset.id), + isSelected: localPersonality == preset.id, + tint: ADEColor.ctoAccent + ) { + localPersonality = preset.id + } + } } } - .pickerStyle(.inline) - .labelsHidden() - } - Section("Model") { - TextField("anthropic", text: $localProvider) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.body, design: .monospaced)) - TextField("claude-sonnet-4-6", text: $localModel) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.body, design: .monospaced)) - } + Section("Model") { + TextField("anthropic", text: $localProvider) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.body, design: .monospaced)) + TextField("claude-sonnet-4-6", text: $localModel) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.body, design: .monospaced)) + } - Section("System prompt extension") { - TextEditor(text: $localExtension) - .font(.system(.body)) - .frame(minHeight: 140) - } - } - .scrollContentBackground(.hidden) - .adeScreenBackground() - .navigationTitle("Edit identity") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { dismiss() } - .disabled(isSaving) - } - ToolbarItem(placement: .topBarTrailing) { - Button { - Task { await save() } - } label: { - if isSaving { - ProgressView().controlSize(.small) - } else { - Text("Save").fontWeight(.semibold) - } + Section { + TextEditor(text: $localExtension) + .font(.system(.body)) + .frame(minHeight: 140) + } header: { + Text("System prompt extension") } - .disabled(isSaving) } + .scrollContentBackground(.hidden) } + .adeScreenBackground() + .navigationTitle("") + .toolbar(.hidden, for: .navigationBar) } .presentationDetents([.large]) .tint(ADEColor.accent) .onAppear(perform: hydrate) } + private var editorHeader: some View { + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.glass) + .disabled(isSaving) + .accessibilityLabel("Cancel edit identity") + + Spacer(minLength: 0) + + Text("Edit identity") + .font(.headline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + + Spacer(minLength: 0) + + Button { + Task { await save() } + } label: { + if isSaving { + ProgressView().controlSize(.small) + } else { + Text("Save").fontWeight(.semibold) + } + } + .buttonStyle(.glass) + .disabled(isSaving) + .accessibilityLabel("Save edit identity") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(ADEColor.pageBackground.opacity(0.98)) + } + + private func personalityDescription(for id: String) -> String { + switch id { + case "professional": + return "Measured, direct planning." + case "strategic": + return "Long-range tradeoffs and sequencing." + case "hands_on": + return "Operational, implementation-first guidance." + case "casual": + return "Lower-formality check-ins." + case "minimal": + return "Brief status and next actions." + default: + return "Use the prompt extension below." + } + } + + private func personalityIcon(for id: String) -> String { + switch id { + case "strategic": + return "map" + case "hands_on": + return "hammer" + case "casual": + return "bubble.left.and.text.bubble.right" + case "minimal": + return "line.3.horizontal.decrease" + case "custom": + return "slider.horizontal.3" + default: + return "person.crop.circle" + } + } + private func hydrate() { guard let identity = snapshot?.identity else { return } localName = identity.name diff --git a/apps/ios/ADE/Views/Cto/CtoReloadHelpers.swift b/apps/ios/ADE/Views/Cto/CtoReloadHelpers.swift new file mode 100644 index 000000000..094b54f93 --- /dev/null +++ b/apps/ios/ADE/Views/Cto/CtoReloadHelpers.swift @@ -0,0 +1,12 @@ +import Foundation + +let ctoLiveReloadMinimumInterval: TimeInterval = 2 + +func shouldRunCtoLiveReload( + lastReloadAt: Date?, + now: Date, + minimumInterval: TimeInterval = ctoLiveReloadMinimumInterval +) -> Bool { + guard let lastReloadAt else { return true } + return now.timeIntervalSince(lastReloadAt) >= minimumInterval +} diff --git a/apps/ios/ADE/Views/Cto/CtoRootScreen.swift b/apps/ios/ADE/Views/Cto/CtoRootScreen.swift index 131ad236a..3aa708d1e 100644 --- a/apps/ios/ADE/Views/Cto/CtoRootScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoRootScreen.swift @@ -14,6 +14,7 @@ struct CtoRootScreen: View { @State private var snapshot: CtoSnapshot? @State private var isLoadingSnapshot = false @State private var snapshotLoadError: String? + @State private var lastLiveSnapshotReloadAt: Date? var body: some View { NavigationStack(path: $path) { @@ -51,6 +52,9 @@ struct CtoRootScreen: View { } .task(id: ctoLiveReloadKey) { guard ctoLiveReloadKey != nil else { return } + let now = Date() + guard shouldRunCtoLiveReload(lastReloadAt: lastLiveSnapshotReloadAt, now: now) else { return } + lastLiveSnapshotReloadAt = now await loadSnapshot() } .navigationDestination(for: CtoSessionRoute.self) { route in diff --git a/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift b/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift index 40dc3a394..fade87b43 100644 --- a/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift @@ -213,6 +213,7 @@ private extension View { .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .tabBar) + .adeRootTabBarHidden() case .embedded: self } diff --git a/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift b/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift index 9b19914ba..dc22a0698 100644 --- a/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift @@ -331,31 +331,32 @@ struct CtoSettingsScreen: View { // MARK: - Data loading - /// Fires all reads in parallel but tolerates partial failures — one endpoint - /// returning an error shouldn't blank the entire screen (e.g. Linear status - /// is optional and should never block the identity card from rendering). + /// Tolerates partial failures. Linear and budget status should never block + /// the identity card from rendering. private func reload() async { isLoading = true 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 snapshotTask + self.snapshot = try await syncService.fetchCtoState() } catch { if self.snapshot == nil { self.errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) } } - if let value = try? await budgetTask { + if let value = try? await syncService.fetchCtoBudget() { self.budget = value + } else { + // Drop stale budget rather than render outdated numbers under a fresh load. + self.budget = nil } - if let value = try? await linearTask { + if let value = try? await syncService.fetchLinearConnectionStatus() { self.linearStatus = value + } else { + // Same reasoning — a failed refresh should not preserve the previous integration state. + self.linearStatus = nil } } } diff --git a/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift b/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift index 695163b9e..a76e02d36 100644 --- a/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift @@ -16,6 +16,7 @@ struct CtoTeamScreen: View { @State private var showHireSheet = false @State private var pendingWakeup: Set = [] @State private var wakeupNotice: String? + @State private var lastLiveReloadAt: Date? var body: some View { ScrollView { @@ -92,6 +93,9 @@ struct CtoTeamScreen: View { .task { if displayAgents.isEmpty { await load(force: true) } } .task(id: ctoAgentsLiveReloadKey) { guard ctoAgentsLiveReloadKey != nil else { return } + let now = Date() + guard shouldRunCtoLiveReload(lastReloadAt: lastLiveReloadAt, now: now) else { return } + lastLiveReloadAt = now await load(force: true) } .sheet(isPresented: $showHireSheet) { @@ -330,6 +334,7 @@ struct CtoTeamScreen: View { if case .success(let snap) = budgetResult { budget = snap } + lastLiveReloadAt = Date() } private var displayAgents: [AgentIdentity] { diff --git a/apps/ios/ADE/Views/Cto/CtoWorkerDetailScreen.swift b/apps/ios/ADE/Views/Cto/CtoWorkerDetailScreen.swift index 36f0afc52..ec99e8656 100644 --- a/apps/ios/ADE/Views/Cto/CtoWorkerDetailScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoWorkerDetailScreen.swift @@ -4,6 +4,7 @@ import SwiftUI /// revisions. Mirrors screen-worker-detail.jsx. struct CtoWorkerDetailScreen: View { @EnvironmentObject private var syncService: SyncService + @Environment(\.dismiss) private var dismissView let agentId: String let displayName: String @@ -16,6 +17,7 @@ struct CtoWorkerDetailScreen: View { @State private var revisions: [AgentConfigRevision] = [] @State private var isLoading = false @State private var errorMessage: String? + @State private var memoryLoadError: String? @State private var pendingStatusMutation = false @State private var pendingWakeup = false @@ -69,15 +71,14 @@ struct CtoWorkerDetailScreen: View { } .presentationDetents([.medium, .large]) } - .confirmationDialog( + .alert( "Dismiss \(displayName)?", isPresented: $showDismissConfirm, - titleVisibility: .visible ) { + Button("Cancel", role: .cancel) {} Button("Dismiss", role: .destructive) { Task { await dismissWorker() } } - Button("Cancel", role: .cancel) {} } message: { Text("This will remove the worker from the org. Their sessions and revisions remain accessible but the agent will stop running.") } @@ -323,6 +324,17 @@ struct CtoWorkerDetailScreen: View { .padding(.horizontal, 20) VStack(alignment: .leading, spacing: 0) { + if let memoryLoadError { + Text("Core memory unavailable: \(memoryLoadError)") + .font(.caption) + .foregroundStyle(ADEColor.danger) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 11) + .padding(.horizontal, 14) + + Divider().opacity(0.08) + } + VStack(alignment: .leading, spacing: 4) { Text("Specialization") .font(.caption2.monospaced().weight(.semibold)) @@ -514,7 +526,16 @@ struct CtoWorkerDetailScreen: View { companyCapCents = snap.companyCapMonthlyCents } - if case .success(let mem) = memoryResult { coreMemory = mem } + switch memoryResult { + case .success(let mem): + coreMemory = mem + memoryLoadError = nil + case .failure(let err): + // Clear the previous snapshot so the unavailable error renders alone instead of + // showing stale memory content under a fresh failure banner. + coreMemory = nil + memoryLoadError = err.localizedDescription + } if case .success(let fetched) = runsResult { runs = fetched } if case .success(let fetched) = revisionsResult { revisions = fetched } } @@ -564,6 +585,7 @@ struct CtoWorkerDetailScreen: View { flashNotice("Worker dismissed.") // Pop after a short delay to let the notice show. try? await Task.sleep(nanoseconds: 1_200_000_000) + dismissView() } catch { flashNotice("Dismiss failed: \(error.localizedDescription)") } @@ -899,13 +921,19 @@ struct CtoWorkerQuickEditSheet: View { NavigationStack { Form { Section("Status") { - Picker("Status", selection: $selectedStatus) { + VStack(spacing: 8) { ForEach(statusOptions, id: \.0) { value, label in - Text(label).tag(value) + ADEOptionButton( + title: label, + subtitle: statusDescription(for: value), + systemImage: statusIcon(for: value), + isSelected: selectedStatus == value, + tint: ADEColor.ctoAccent + ) { + selectedStatus = value + } } } - .pickerStyle(.inline) - .labelsHidden() } Section { @@ -947,4 +975,26 @@ struct CtoWorkerQuickEditSheet: View { selectedStatus = agent?.status.lowercased() ?? "active" } } + + private func statusDescription(for value: String) -> String { + switch value { + case "paused": + return "Keep the worker assigned but stop active work." + case "idle": + return "Mark the worker available without active work." + default: + return "Let the worker run assigned tasks." + } + } + + private func statusIcon(for value: String) -> String { + switch value { + case "paused": + return "pause.circle" + case "idle": + return "circle.dashed" + default: + return "play.circle" + } + } } diff --git a/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift b/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift index 76c20d4dd..8c9491f42 100644 --- a/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift @@ -253,17 +253,25 @@ struct CtoWorkflowsScreen: View { errorMessage = nil defer { isLoading = false } - async let connR = CtoWorkflowsResult { try await syncService.fetchLinearConnectionStatus() } - async let dashR = CtoWorkflowsResult { try await syncService.fetchLinearSyncDashboard() } - async let policyR = CtoWorkflowsResult { try await syncService.fetchFlowPolicy() } - async let eventsR = CtoWorkflowsResult { try await syncService.listLinearIngressEvents(limit: 20) } + let connResult = await CtoWorkflowsResult { try await syncService.fetchLinearConnectionStatus() } + if case .success(let value) = connResult { + self.connection = value + } - let (connResult, dashResult, policyResult, eventsResult) = await (connR, dashR, policyR, eventsR) + let dashResult = await CtoWorkflowsResult { try await syncService.fetchLinearSyncDashboard() } + if case .success(let value) = dashResult { + self.dashboard = value + } - if case .success(let value) = connResult { self.connection = value } - if case .success(let value) = dashResult { self.dashboard = value } - if case .success(let value) = policyResult { self.policy = value } - if case .success(let value) = eventsResult { self.events = value } + let policyResult = await CtoWorkflowsResult { try await syncService.fetchFlowPolicy() } + if case .success(let value) = policyResult { + self.policy = value + } + + let eventsResult = await CtoWorkflowsResult { try await syncService.listLinearIngressEvents(limit: 20) } + if case .success(let value) = eventsResult { + self.events = value + } // Only surface a top-level error if the connection fetch itself failed — // once we know Linear isn't connected, dashboard/policy failures are diff --git a/apps/ios/ADE/Views/Files/FilesDetailComponents.swift b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift index 6430fd920..1b3c1a352 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailComponents.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift @@ -5,9 +5,10 @@ struct FilesHeaderStrip: View { @EnvironmentObject private var syncService: SyncService let relativePath: String - let language: FilesLanguage + let fileKindLabel: String let fileSize: Int let transitionNamespace: Namespace.ID? + var onShowDetails: (() -> Void)? private var filesBrowserStatusSuffix: String? { let phase = syncService.status(for: .files).phase @@ -43,7 +44,7 @@ struct FilesHeaderStrip: View { .adeMatchedGeometry(id: transitionNamespace == nil ? nil : "files-title-\(relativePath)", in: transitionNamespace) HStack(spacing: 6) { - Text(language.displayName.uppercased()) + Text(fileKindLabel.uppercased()) .font(.caption2.monospaced().weight(.semibold)) .foregroundStyle(ADEColor.accent) Text("·").foregroundStyle(ADEColor.textMuted) @@ -64,8 +65,17 @@ struct FilesHeaderStrip: View { } Spacer(minLength: 0) + + if let onShowDetails { + Button(action: onShowDetails) { + Image(systemName: "info.circle") + .font(.system(size: 18, weight: .semibold)) + .frame(width: 34, height: 34) + } + .buttonStyle(.glass) + .accessibilityLabel("File details") + } } - .accessibilityElement(children: .combine) } } diff --git a/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift b/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift index 4f41120a0..257dd5a70 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift @@ -2,7 +2,24 @@ import SwiftUI extension FilesDetailScreen { @MainActor - func load(refreshDiff: Bool = false) async { + func refreshForLocalStateRevision(_ revision: Int) async { + guard lastHandledFilesDetailRevision != revision || blob == nil else { return } + let now = Date() + if let delay = filesDetailRefreshDelay( + hasLoadedBlob: blob != nil, + elapsedSinceLastLoad: now.timeIntervalSince(lastFilesDetailReload) + ) { + try? await Task.sleep(for: .milliseconds(max(1, Int(delay * 1_000)))) + guard !Task.isCancelled, syncService.localStateRevision == revision else { return } + } + lastFilesDetailReload = Date() + await load(refreshDiff: mode == .diff, preservesVisibleHistory: true) + guard !Task.isCancelled, syncService.localStateRevision == revision else { return } + lastHandledFilesDetailRevision = revision + } + + @MainActor + func load(refreshDiff: Bool = false, preservesVisibleHistory: Bool = false) async { var cachedImageBlob: SyncFileBlob? if isImagePreviewable, let cachedData = ADEImageCache.shared.cachedData(for: imageCacheKey) { @@ -17,7 +34,7 @@ extension FilesDetailScreen { ) cachedImageBlob = cachedBlob blob = cachedBlob - await loadHistoryAndMetadata(from: cachedBlob) + await loadHistoryAndMetadata(from: cachedBlob, preservesVisibleHistory: preservesVisibleHistory) if refreshDiff { await loadDiff() } @@ -30,7 +47,7 @@ extension FilesDetailScreen { if loaded.isBinary, isImagePreviewable, let data = imageData { ADEImageCache.shared.store(data, for: imageCacheKey) } - await loadHistoryAndMetadata(from: loaded) + await loadHistoryAndMetadata(from: loaded, preservesVisibleHistory: preservesVisibleHistory) if refreshDiff { await loadDiff() } @@ -45,23 +62,33 @@ extension FilesDetailScreen { } @MainActor - func loadHistoryAndMetadata(from blob: SyncFileBlob) async { - hasLoadedHistory = false - historyErrorMessage = nil - historyEntries = [] + func loadHistoryAndMetadata(from blob: SyncFileBlob, preservesVisibleHistory: Bool = false) async { + let shouldPreserveHistory = preservesVisibleHistory && hasLoadedHistory + if !shouldPreserveHistory { + hasLoadedHistory = false + historyErrorMessage = nil + historyEntries = [] + } + var nextEntries: [GitFileHistoryEntry] = [] + var nextErrorMessage: String? if let laneId = workspace.laneId { do { - historyEntries = try await syncService.fetchFileHistory(workspaceId: workspace.id, laneId: laneId, path: relativePath, limit: 10) + nextEntries = try await syncService.fetchFileHistory(workspaceId: workspace.id, laneId: laneId, path: relativePath, limit: 10) } catch { - historyErrorMessage = error.localizedDescription + nextErrorMessage = error.localizedDescription } } - let latest = historyEntries.first + if nextErrorMessage == nil || !shouldPreserveHistory { + historyEntries = nextEntries + historyErrorMessage = nextErrorMessage + } + + let latest = (nextErrorMessage == nil ? nextEntries.first : nil) ?? historyEntries.first metadata = FilesFileMetadata( sizeText: formattedFileSize(blob.size), - languageLabel: language.displayName, + languageLabel: fileKindLabel(for: blob), lastCommitTitle: latest?.subject, lastCommitDateText: relativeDateDescription(from: latest?.authoredAt) ) diff --git a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift index 69193ca55..ee3687e22 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift @@ -23,6 +23,8 @@ struct FilesDetailScreen: View { @State var hasLoadedDiff = false @State var isDetailsSheetPresented = false @State var codeLayoutMode: FilesCodeLayoutMode = .wrap + @State var lastHandledFilesDetailRevision: Int? + @State var lastFilesDetailReload = Date.distantPast var language: FilesLanguage { FilesLanguage.detect(languageId: blob?.languageId, filePath: relativePath) @@ -87,19 +89,9 @@ struct FilesDetailScreen: View { } .adeScreenBackground() .adeNavigationGlass() + .adeRootTabBarHidden() .navigationTitle(lastPathComponent(relativePath)) .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - isDetailsSheetPresented = true - } label: { - Image(systemName: "info.circle") - } - .accessibilityLabel("File details") - .disabled(blob == nil) - } - } .adeNavigationZoomTransition(id: transitionNamespace == nil ? nil : "files-container-\(relativePath)", in: transitionNamespace) .sheet(isPresented: $isDetailsSheetPresented) { FilesDetailsSheet( @@ -117,7 +109,7 @@ struct FilesDetailScreen: View { .environmentObject(syncService) } .task(id: syncService.localStateRevision) { - await load(refreshDiff: mode == .diff) + await refreshForLocalStateRevision(syncService.localStateRevision) } .task(id: mode) { if mode == .diff { @@ -159,9 +151,10 @@ struct FilesDetailScreen: View { if let blob { FilesHeaderStrip( relativePath: relativePath, - language: language, + fileKindLabel: fileKindLabel(for: blob), fileSize: blob.size, - transitionNamespace: transitionNamespace + transitionNamespace: transitionNamespace, + onShowDetails: { isDetailsSheetPresented = true } ) if editorModes.count > 1 { @@ -180,33 +173,32 @@ struct FilesDetailScreen: View { @ViewBuilder private var filesModeControl: some View { VStack(alignment: .leading, spacing: 8) { - Picker("Mode", selection: $mode) { - ForEach(editorModes) { editorMode in - Text(editorMode.title).tag(editorMode) - } - } - .pickerStyle(.segmented) + FilesSegmentedControl( + title: "Mode", + items: editorModes, + selection: $mode, + label: { $0.title } + ) if mode == .diff, workspace.laneId != nil { - Picker("Diff", selection: $diffMode) { - ForEach(FilesDiffMode.allCases) { item in - Text(item.title).tag(item) - } - } - .pickerStyle(.segmented) + FilesSegmentedControl( + title: "Diff", + items: FilesDiffMode.allCases, + selection: $diffMode, + label: { $0.title } + ) } } } @ViewBuilder private var filesCodeLayoutControl: some View { - Picker("Code layout", selection: $codeLayoutMode) { - ForEach(FilesCodeLayoutMode.allCases) { layoutMode in - Text(layoutMode.title).tag(layoutMode) - } - } - .pickerStyle(.segmented) - .accessibilityLabel("Code layout") + FilesSegmentedControl( + title: "Code layout", + items: FilesCodeLayoutMode.allCases, + selection: $codeLayoutMode, + label: { $0.title } + ) } private func showsCodeLayoutControl(blob: SyncFileBlob) -> Bool { @@ -218,6 +210,16 @@ struct FilesDetailScreen: View { } } + func fileKindLabel(for blob: SyncFileBlob) -> String { + if isImagePreviewable { + return "Image" + } + if blob.isBinary { + return "Binary" + } + return language.displayName + } + @ViewBuilder private func filesContentHero(blob: SyncFileBlob) -> some View { switch mode { @@ -316,3 +318,55 @@ struct FilesDetailScreen: View { } } } + +private struct FilesSegmentedControl: View { + let title: String + let items: [Item] + @Binding var selection: Item + let label: (Item) -> String + + var body: some View { + HStack(spacing: 3) { + ForEach(items) { item in + let isSelected = selection == item + Button { + guard !isSelected else { return } + withAnimation(.snappy(duration: 0.16)) { + selection = item + } + } label: { + Text(label(item)) + .font(.caption.weight(.semibold)) + .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity, minHeight: 34) + .padding(.horizontal, 8) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(ADEColor.accent.opacity(0.18)) + } + } + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(ADEColor.accent.opacity(0.35), lineWidth: 0.75) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(title): \(label(item))") + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } + } + .padding(3) + .background(ADEColor.recessedBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.5) + } + } +} diff --git a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView+Actions.swift b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView+Actions.swift index b54a336d2..05478d676 100644 --- a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView+Actions.swift +++ b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView+Actions.swift @@ -5,7 +5,9 @@ extension FilesDirectoryContentsView { @MainActor func reload() async { do { - isLoading = true + if nodes.isEmpty { + isLoading = true + } nodes = try await syncService.listTree(workspaceId: workspace.id, parentPath: parentPath, includeIgnored: showHidden) errorMessage = nil } catch { @@ -29,14 +31,22 @@ extension FilesDirectoryContentsView { } Button("Copy Path") { - UIPasteboard.general.string = absolutePath(for: node.path) + copyAbsolutePath(for: node) } Button("Copy Relative Path") { - UIPasteboard.general.string = node.path + copyRelativePath(for: node) } } + func copyAbsolutePath(for node: FileTreeNode) { + UIPasteboard.general.string = absolutePath(for: node.path) + } + + func copyRelativePath(for node: FileTreeNode) { + UIPasteboard.general.string = node.path + } + func absolutePath(for relativePath: String) -> String { guard !relativePath.isEmpty else { return workspace.rootPath } return (workspace.rootPath as NSString).appendingPathComponent(relativePath) @@ -48,7 +58,6 @@ extension FilesDirectoryContentsView { let includeHidden: Bool let live: Bool let active: Bool - let revision: Int let manualReloadToken: Int } } diff --git a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift index f0a97f6d7..b8f79055f 100644 --- a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift +++ b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift @@ -43,19 +43,34 @@ struct FilesDirectoryContentsView: View { ) } else { ForEach(filesSortedNodes(nodes)) { node in - Button { - open(node) - } label: { - FilesTreeNodeRow( - node: node, - transitionNamespace: transitionNamespace, - isSelectedTransitionSource: selectedFilePath == node.path - ) - } - .buttonStyle(.plain) + FilesTreeNodeRow( + node: node, + transitionNamespace: transitionNamespace, + isSelectedTransitionSource: selectedFilePath == node.path, + onOpen: { open(node) }, + onCopyPath: { copyAbsolutePath(for: node) }, + onCopyRelativePath: { copyRelativePath(for: node) } + ) .contextMenu { contextMenu(for: node) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button("Copy Path") { + copyAbsolutePath(for: node) + } + .tint(ADEColor.accent) + + Button("Copy Relative Path") { + copyRelativePath(for: node) + } + .tint(ADEColor.info) + } + .accessibilityAction(named: Text("Copy Path")) { + copyAbsolutePath(for: node) + } + .accessibilityAction(named: Text("Copy Relative Path")) { + copyRelativePath(for: node) + } } } } @@ -65,7 +80,6 @@ struct FilesDirectoryContentsView: View { includeHidden: showHidden, live: isLive, active: isTabActive, - revision: syncService.localStateRevision, manualReloadToken: manualReloadToken )) { guard isTabActive else { return } diff --git a/apps/ios/ADE/Views/Files/FilesModels.swift b/apps/ios/ADE/Views/Files/FilesModels.swift index 49ed6f326..5fff90600 100644 --- a/apps/ios/ADE/Views/Files/FilesModels.swift +++ b/apps/ios/ADE/Views/Files/FilesModels.swift @@ -108,6 +108,17 @@ struct FilesPreviewLimit: Equatable { let message: String } +let filesDetailRefreshMinimumInterval: TimeInterval = 0.75 + +func filesDetailRefreshDelay( + hasLoadedBlob: Bool, + elapsedSinceLastLoad: TimeInterval, + minimumInterval: TimeInterval = filesDetailRefreshMinimumInterval +) -> TimeInterval? { + guard hasLoadedBlob, elapsedSinceLastLoad < minimumInterval else { return nil } + return max(0, minimumInterval - elapsedSinceLastLoad) +} + private let filesTextPreviewByteLimit = 300 * 1024 private let filesTextPreviewLineLimit = 4_000 private let filesDiffPreviewByteLimit = 400 * 1024 @@ -126,6 +137,13 @@ func filesTextPreviewLimit(blob: SyncFileBlob) -> FilesPreviewLimit? { } func filesDiffPreviewLimit(diff: FileDiff) -> FilesPreviewLimit? { + if diff.original.isTruncated == true || diff.modified.isTruncated == true { + return FilesPreviewLimit( + title: "Diff preview paused", + message: "This diff is too large to compare fully on iPhone. Open the file from ADE on your machine or inspect a smaller diff before rendering it on iPhone." + ) + } + let combinedText = "\(diff.original.text)\n\(diff.modified.text)" return filesTextLimit( byteCount: combinedText.utf8.count, @@ -138,7 +156,13 @@ func filesDiffPreviewLimit(diff: FileDiff) -> FilesPreviewLimit? { } func filesDiffHasChanges(_ diff: FileDiff) -> Bool { - diff.original.exists != diff.modified.exists || diff.original.text != diff.modified.text + if diff.original.exists != diff.modified.exists || diff.original.text != diff.modified.text { + return true + } + if let originalSize = diff.original.size, let modifiedSize = diff.modified.size, originalSize != modifiedSize { + return true + } + return diff.original.isTruncated == true || diff.modified.isTruncated == true } private func filesTextLimit(byteCount: Int, lineCount: Int, lineLimit: Int, byteLimit: Int, title: String, action: String) -> FilesPreviewLimit? { diff --git a/apps/ios/ADE/Views/Files/FilesRootComponents.swift b/apps/ios/ADE/Views/Files/FilesRootComponents.swift index aecaba7fa..17b6dae45 100644 --- a/apps/ios/ADE/Views/Files/FilesRootComponents.swift +++ b/apps/ios/ADE/Views/Files/FilesRootComponents.swift @@ -8,22 +8,45 @@ struct FilesWorkspaceHeader: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Workspace") - .font(.headline) - .foregroundStyle(ADEColor.textPrimary) - } - - Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 10) { + Text("Workspace") + .font(.headline) + .foregroundStyle(ADEColor.textPrimary) - Picker("Workspace", selection: $selectedWorkspaceId) { - ForEach(workspaces) { workspace in - Text(workspace.name).tag(workspace.id) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(workspaces) { workspace in + Button { + selectedWorkspaceId = workspace.id + } label: { + HStack(spacing: 6) { + Text(workspace.name) + .font(.caption.weight(.semibold)) + .lineLimit(1) + if workspace.id == selectedWorkspaceId { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 11, weight: .semibold)) + } + } + .foregroundStyle(workspace.id == selectedWorkspaceId ? ADEColor.accent : ADEColor.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + (workspace.id == selectedWorkspaceId ? ADEColor.accent.opacity(0.14) : ADEColor.surfaceBackground.opacity(0.55)), + in: Capsule() + ) + .overlay( + Capsule() + .stroke(workspace.id == selectedWorkspaceId ? ADEColor.accent.opacity(0.45) : ADEColor.border.opacity(0.16), lineWidth: 0.5) + ) + .glassEffect() + } + .buttonStyle(.plain) + .accessibilityLabel("Workspace \(workspace.name)") + .accessibilityValue(workspace.id == selectedWorkspaceId ? "Selected" : "") + } } } - .pickerStyle(.menu) - .labelsHidden() } Text(selectedWorkspace.rootPath) @@ -154,24 +177,23 @@ struct FilesProofArtifactRow: View { Spacer(minLength: 8) - Menu { - Button { - onOpen() - } label: { - Label("Open proof", systemImage: "eye") + HStack(spacing: 6) { + Button(action: onOpen) { + Image(systemName: "eye") + .font(.system(size: 15, weight: .semibold)) + .frame(width: 32, height: 32) } - Button { - onCopyReference() - } label: { - Label("Copy reference", systemImage: "doc.on.doc") + .buttonStyle(.glass) + .accessibilityLabel("Open proof \(artifact.title)") + + Button(action: onCopyReference) { + Image(systemName: "doc.on.doc") + .font(.system(size: 15, weight: .semibold)) + .frame(width: 32, height: 32) } - } label: { - Image(systemName: "ellipsis.circle") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(ADEColor.textSecondary) - .frame(width: 32, height: 32) + .buttonStyle(.glass) + .accessibilityLabel("Copy reference for \(artifact.title)") } - .accessibilityLabel("Actions for \(artifact.title)") } .padding(12) .adeInsetField(cornerRadius: 14, padding: 0) @@ -230,9 +252,11 @@ struct FilesQueryCard: View { } } .adeInsetField() - Text(emptyMessage) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) + if !emptyMessage.isEmpty { + Text(emptyMessage) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } } .adeGlassCard(cornerRadius: 18) } @@ -242,56 +266,89 @@ struct FilesTreeNodeRow: View { let node: FileTreeNode let transitionNamespace: Namespace.ID? let isSelectedTransitionSource: Bool + let onOpen: () -> Void + let onCopyPath: () -> Void + let onCopyRelativePath: () -> Void var body: some View { HStack(spacing: 12) { - Image(systemName: node.type == "directory" ? "folder.fill" : fileIcon(for: node.name)) - .font(.headline) - .foregroundStyle(node.type == "directory" ? ADEColor.accent : fileTint(for: node.name)) - .frame(width: 22) - .adeMatchedGeometry(id: canTransition ? "files-icon-\(node.path)" : nil, in: transitionNamespace) + Button(action: onOpen) { + HStack(spacing: 12) { + Image(systemName: node.type == "directory" ? "folder.fill" : fileIcon(for: node.name)) + .font(.headline) + .foregroundStyle(node.type == "directory" ? ADEColor.accent : fileTint(for: node.name)) + .frame(width: 22) + .adeMatchedGeometry(id: canTransition ? "files-icon-\(node.path)" : nil, in: transitionNamespace) - VStack(alignment: .leading, spacing: 4) { - Text(node.name) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - .adeMatchedGeometry(id: canTransition ? "files-title-\(node.path)" : nil, in: transitionNamespace) - Text(node.path.isEmpty ? (node.type == "directory" ? "Folder" : "File") : node.path) - .font(.caption.monospaced()) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - } + VStack(alignment: .leading, spacing: 4) { + Text(node.name) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .adeMatchedGeometry(id: canTransition ? "files-title-\(node.path)" : nil, in: transitionNamespace) + HStack(spacing: 6) { + Text(node.path.isEmpty ? (node.type == "directory" ? "Folder" : "File") : node.path) + .font(.caption.monospaced()) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + + if let changeStatus = node.changeStatus { + ADEStatusPill(text: changeStatus.uppercased(), tint: changeStatusTint(changeStatus)) + .fixedSize(horizontal: true, vertical: false) + } + } + } + .layoutPriority(1) - Spacer(minLength: 8) + Spacer(minLength: 8) - if let size = node.size, node.type == "file" { - Text(formattedFileSize(size)) - .font(.caption2.monospaced()) - .foregroundStyle(ADEColor.textMuted) - } + if let size = node.size, node.type == "file" { + Text(formattedFileSize(size)) + .font(.caption2.monospaced()) + .foregroundStyle(ADEColor.textMuted) + .fixedSize(horizontal: true, vertical: false) + } - if let changeStatus = node.changeStatus { - ADEStatusPill(text: changeStatus.uppercased(), tint: changeStatusTint(changeStatus)) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + } + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(node.type == "directory" ? "Opens folder" : "Opens file") + .adeInspectable( + "Files.Directory.NodeRow", + metadata: [ + "label": accessibilityLabel, + "path": node.path, + "type": node.type, + "role": "row" + ] + ) + + HStack(spacing: 6) { + Button(action: onCopyPath) { + Image(systemName: "link") + .font(.system(size: 14, weight: .semibold)) + .frame(width: 32, height: 32) + } + .buttonStyle(.glass) + .accessibilityLabel("Copy path for \(node.name)") - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) + Button(action: onCopyRelativePath) { + Image(systemName: "doc.on.doc") + .font(.system(size: 14, weight: .semibold)) + .frame(width: 32, height: 32) + } + .buttonStyle(.glass) + .accessibilityLabel("Copy relative path for \(node.name)") + } } .adeListCard(cornerRadius: 16) .adeMatchedTransitionSource(id: canTransition ? "files-container-\(node.path)" : nil, in: transitionNamespace) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityLabel) - .adeInspectable( - "Files.Directory.NodeRow", - metadata: [ - "label": accessibilityLabel, - "path": node.path, - "type": node.type, - "role": "row" - ] - ) } private var canTransition: Bool { diff --git a/apps/ios/ADE/Views/Files/FilesRootScreen.swift b/apps/ios/ADE/Views/Files/FilesRootScreen.swift index 5d4786ccb..6808cccbf 100644 --- a/apps/ios/ADE/Views/Files/FilesRootScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesRootScreen.swift @@ -162,7 +162,7 @@ struct FilesRootScreen: View { prompt: "Search files", query: $quickOpenQuery, disabled: !canUseLiveFileActions, - emptyMessage: quickOpenEmptyMessage, + emptyMessage: quickOpenResults.isEmpty ? quickOpenEmptyMessage : "", scopeText: workspace.rootPath ) @@ -193,7 +193,7 @@ struct FilesRootScreen: View { prompt: "Search text", query: $textSearchQuery, disabled: !canUseLiveFileActions, - emptyMessage: textSearchEmptyMessage, + emptyMessage: textSearchResults.isEmpty ? textSearchEmptyMessage : "", scopeText: workspace.rootPath ) diff --git a/apps/ios/ADE/Views/Lanes/LaneBatchManageSheet.swift b/apps/ios/ADE/Views/Lanes/LaneBatchManageSheet.swift index 15d6d177b..174702ff8 100644 --- a/apps/ios/ADE/Views/Lanes/LaneBatchManageSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneBatchManageSheet.swift @@ -103,12 +103,19 @@ struct LaneBatchManageSheet: View { GlassSection(title: "Delete") { VStack(alignment: .leading, spacing: 12) { - Picker("Delete mode", selection: $deleteMode) { + LazyVStack(spacing: 8) { ForEach(LaneDeleteMode.allCases) { mode in - Text(mode.title).tag(mode) + LaneOptionButton( + title: mode.title, + subtitle: mode.detail, + systemImage: mode.symbol, + isSelected: deleteMode == mode, + tint: ADEColor.danger + ) { + deleteMode = mode + } } } - .pickerStyle(.menu) if deleteMode == .remoteBranch { LaneTextField("Remote name", text: $deleteRemoteName) diff --git a/apps/ios/ADE/Views/Lanes/LaneBranchPickerSheet.swift b/apps/ios/ADE/Views/Lanes/LaneBranchPickerSheet.swift index cdc4f3b12..755cdf4a6 100644 --- a/apps/ios/ADE/Views/Lanes/LaneBranchPickerSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneBranchPickerSheet.swift @@ -257,24 +257,29 @@ struct LaneBranchPickerSheet: View { private func startPointPickerRow(title: String, subtitle: String, selection: Binding) -> some View { VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 10) { - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textSecondary) - Spacer(minLength: 8) - Picker(title, selection: selection) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + Text(subtitle) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .fixedSize(horizontal: false, vertical: true) + ScrollView { + LazyVStack(spacing: 8) { ForEach(startPointOptions) { option in - Text(option.label).tag(option.value) + LaneOptionButton( + title: option.label, + isSelected: option.value == selection.wrappedValue, + tint: ADEColor.accent + ) { + selection.wrappedValue = option.value + } } } - .pickerStyle(.menu) - .tint(ADEColor.textPrimary) - .labelsHidden() - .frame(maxWidth: 220, alignment: .trailing) } - Text(subtitle) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) + .frame(maxHeight: startPointOptions.count > 4 ? 220 : nil) + .accessibilityLabel(title) + .accessibilityValue(startPointDisplayLabel(for: selection.wrappedValue)) } .padding(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)) .background(ADEColor.surfaceBackground.opacity(0.22), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) @@ -286,24 +291,29 @@ struct LaneBranchPickerSheet: View { private func baseRefPickerRow(title: String, subtitle: String, selection: Binding) -> some View { VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 10) { - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textSecondary) - Spacer(minLength: 8) - Picker(title, selection: selection) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + Text(subtitle) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .fixedSize(horizontal: false, vertical: true) + ScrollView { + LazyVStack(spacing: 8) { ForEach(baseRefOptions, id: \.self) { name in - Text(name).tag(name) + LaneOptionButton( + title: name, + isSelected: name == selection.wrappedValue, + tint: ADEColor.accent + ) { + selection.wrappedValue = name + } } } - .pickerStyle(.menu) - .tint(ADEColor.textPrimary) - .labelsHidden() - .frame(maxWidth: 220, alignment: .trailing) } - Text(subtitle) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) + .frame(maxHeight: baseRefOptions.count > 4 ? 220 : nil) + .accessibilityLabel(title) + .accessibilityValue(selection.wrappedValue) } .padding(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)) .background(ADEColor.surfaceBackground.opacity(0.22), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) @@ -313,6 +323,28 @@ struct LaneBranchPickerSheet: View { ) } + private func branchMenuLabel(_ value: String) -> some View { + HStack(spacing: 8) { + Text(value) + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + .accessibilityHidden(true) + } + .padding(EdgeInsets(top: 9, leading: 10, bottom: 9, trailing: 10)) + .frame(maxWidth: .infinity, minHeight: 40) + .background(ADEColor.recessedBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.5) + ) + } + @ViewBuilder private var branchSections: some View { let filteredLocal = filtered(branches.filter { isLocal($0) }) @@ -542,6 +574,10 @@ struct LaneBranchPickerSheet: View { return branchRef } + private func startPointDisplayLabel(for value: String) -> String { + startPointOptions.first(where: { $0.value == value })?.label ?? value + } + private var selectedCreateBaseRef: String { if !createBaseRef.isEmpty { return createBaseRef } return branchRef diff --git a/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift b/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift index f8c33547a..3f108c195 100644 --- a/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift @@ -38,11 +38,24 @@ struct LaneChatLaunchSheet: View { VStack(spacing: 14) { GlassSection(title: "Provider") { VStack(alignment: .leading, spacing: 12) { - Picker("Provider", selection: $provider) { - Text("Codex").tag("codex") - Text("Claude").tag("claude") + HStack(spacing: 8) { + LaneOptionButton( + title: "Codex", + subtitle: "OpenAI CLI runtime", + systemImage: "sparkle", + isSelected: provider == "codex" + ) { + provider = "codex" + } + LaneOptionButton( + title: "Claude", + subtitle: "Claude Code runtime", + systemImage: "brain.head.profile", + isSelected: provider == "claude" + ) { + provider = "claude" + } } - .pickerStyle(.segmented) Text("Session stays lane-scoped.") .font(.caption) @@ -78,15 +91,15 @@ struct LaneChatLaunchSheet: View { if !models.isEmpty { GlassSection(title: "Model") { VStack(alignment: .leading, spacing: 12) { - Picker("Model", selection: $selectedModelId) { - ForEach(models) { model in - Text(model.displayName).tag(model.id) + if models.count > 5 { + ScrollView { + modelOptionStack } + .frame(maxHeight: 340) + .scrollBounceBehavior(.basedOnSize) + } else { + modelOptionStack } - .pickerStyle(.menu) - .padding(EdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10)) - .frame(maxWidth: .infinity, alignment: .leading) - .glassEffect(in: .rect(cornerRadius: 10)) if let selectedModel { VStack(alignment: .leading, spacing: 8) { @@ -115,13 +128,26 @@ struct LaneChatLaunchSheet: View { if let reasoningEfforts = selectedModel?.reasoningEfforts, !reasoningEfforts.isEmpty { GlassSection(title: "Reasoning") { VStack(alignment: .leading, spacing: 12) { - Picker("Reasoning", selection: $selectedReasoningEffort) { - Text("Default").tag("") + LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 8)], alignment: .leading, spacing: 8) { + LaneOptionButton( + title: "Default", + subtitle: "Runtime default", + systemImage: "circle.dashed", + isSelected: selectedReasoningEffort.isEmpty + ) { + selectedReasoningEffort = "" + } ForEach(reasoningEfforts) { effort in - Text(effort.effort.capitalized).tag(effort.effort) + LaneOptionButton( + title: effort.effort.capitalized, + subtitle: effort.description, + systemImage: "brain", + isSelected: selectedReasoningEffort == effort.effort + ) { + selectedReasoningEffort = effort.effort + } } } - .pickerStyle(.segmented) if let effort = reasoningEfforts.first(where: { $0.effort == selectedReasoningEffort }) { Text(effort.description) @@ -193,6 +219,21 @@ struct LaneChatLaunchSheet: View { } } + private var modelOptionStack: some View { + LazyVStack(spacing: 8) { + ForEach(models) { model in + LaneOptionButton( + title: model.displayName, + subtitle: model.description, + systemImage: model.supportsReasoning == true ? "brain" : "circle.grid.2x2.fill", + isSelected: selectedModelId == model.id + ) { + selectedModelId = model.id + } + } + } + } + @MainActor private func loadModels(resetSelection: Bool) async { let requestedProvider = provider diff --git a/apps/ios/ADE/Views/Lanes/LaneCommitHistoryScreen.swift b/apps/ios/ADE/Views/Lanes/LaneCommitHistoryScreen.swift index 6a4ebb513..ba8f957b7 100644 --- a/apps/ios/ADE/Views/Lanes/LaneCommitHistoryScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneCommitHistoryScreen.swift @@ -87,23 +87,14 @@ struct LaneCommitHistoryScreen: View { } .disabled(!canRunLiveActions) Spacer(minLength: 0) - Menu { - Button(role: .destructive) { - pendingConfirmation = CommitHistoryConfirmation(kind: .revert, commit: commit) - } label: { - Label("Revert commit", systemImage: "arrow.uturn.backward") - } - .disabled(!canRunLiveActions) - - Button { - pendingConfirmation = CommitHistoryConfirmation(kind: .cherryPick, commit: commit) - } label: { - Label("Cherry-pick commit", systemImage: "arrow.triangle.merge") - } - .disabled(!canRunLiveActions) - } label: { - LaneMenuLabel(title: "More") + LaneActionButton(title: "Revert", symbol: "arrow.uturn.backward") { + pendingConfirmation = CommitHistoryConfirmation(kind: .revert, commit: commit) } + .disabled(!canRunLiveActions) + LaneActionButton(title: "Pick", symbol: "arrow.triangle.merge") { + pendingConfirmation = CommitHistoryConfirmation(kind: .cherryPick, commit: commit) + } + .disabled(!canRunLiveActions) } } } diff --git a/apps/ios/ADE/Views/Lanes/LaneCommitSheet.swift b/apps/ios/ADE/Views/Lanes/LaneCommitSheet.swift index 89d96157b..b14ac8134 100644 --- a/apps/ios/ADE/Views/Lanes/LaneCommitSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneCommitSheet.swift @@ -1,5 +1,13 @@ import SwiftUI +private struct LaneCommitPendingDestructiveAction: Identifiable { + let id = UUID() + let title: String + let message: String + let confirmTitle: String + let perform: () -> Void +} + struct LaneCommitSheet: View { @Binding var commitMessage: String @Binding var amendCommit: Bool @@ -33,6 +41,7 @@ struct LaneCommitSheet: View { /// and show the user how to enable it. @State private var aiSetupHint: String? @State private var aiTransientError: String? + @State private var pendingDestructiveAction: LaneCommitPendingDestructiveAction? var body: some View { NavigationStack { @@ -83,6 +92,19 @@ struct LaneCommitSheet: View { // user can start typing immediately without an extra tap. messageFieldFocused = true } + .alert(item: $pendingDestructiveAction) { action in + Alert( + title: Text(action.title), + message: Text(action.message), + primaryButton: .destructive(Text(action.confirmTitle)) { + action.perform() + pendingDestructiveAction = nil + }, + secondaryButton: .cancel { + pendingDestructiveAction = nil + } + ) + } } } @@ -280,12 +302,18 @@ struct LaneCommitSheet: View { symbol: "trash", tint: ADEColor.danger, isDestructive: true - ) { onDiscardAllUnstaged() } + ) { + requestDestructiveConfirmation(.discardAllUnstaged(unstagedFiles), perform: onDiscardAllUnstaged) + } ], onBulkAction: onStageAll, onDiff: { file in onOpenDiff(file, false) }, onPrimaryAction: onStageFile, - onSecondaryAction: onDiscardFile, + onSecondaryAction: { file in + requestDestructiveConfirmation(.discardUnstaged(file)) { + onDiscardFile(file) + } + }, onOpenFiles: onOpenFiles ) } @@ -313,16 +341,31 @@ struct LaneCommitSheet: View { symbol: "trash", tint: ADEColor.danger, isDestructive: true - ) { onRestoreAllStaged() } + ) { + requestDestructiveConfirmation(.restoreAllStaged(stagedFiles), perform: onRestoreAllStaged) + } ], onBulkAction: onUnstageAll, onDiff: { file in onOpenDiff(file, true) }, onPrimaryAction: onUnstageFile, - onSecondaryAction: onRestoreStaged, + onSecondaryAction: { file in + requestDestructiveConfirmation(.restoreStaged(file)) { + onRestoreStaged(file) + } + }, onOpenFiles: onOpenFiles ) } + private func requestDestructiveConfirmation(_ confirmation: LaneFileConfirmation, perform: @escaping () -> Void) { + pendingDestructiveAction = LaneCommitPendingDestructiveAction( + title: confirmation.title, + message: confirmation.message, + confirmTitle: confirmation.confirmTitle, + perform: perform + ) + } + // MARK: - Message + amend + commit private var messageField: some View { diff --git a/apps/ios/ADE/Views/Lanes/LaneComponents.swift b/apps/ios/ADE/Views/Lanes/LaneComponents.swift index 3dec45cc4..c82cba08d 100644 --- a/apps/ios/ADE/Views/Lanes/LaneComponents.swift +++ b/apps/ios/ADE/Views/Lanes/LaneComponents.swift @@ -110,6 +110,28 @@ struct LaneLaunchTile: View { } } +// MARK: - Option button + +struct LaneOptionButton: View { + let title: String + var subtitle: String? = nil + var systemImage: String? = nil + let isSelected: Bool + var tint: Color = ADEColor.accent + let action: () -> Void + + var body: some View { + ADEOptionButton( + title: title, + subtitle: subtitle, + systemImage: systemImage, + isSelected: isSelected, + tint: tint, + action: action + ) + } +} + // MARK: - Session card struct LaneSessionCard: View { @@ -211,10 +233,22 @@ struct LaneTextField: View { } var body: some View { - TextField(title, text: $text, axis: .vertical) + TextField(title, text: $text) .textFieldStyle(.plain) .foregroundStyle(ADEColor.textPrimary) - .adeInsetField() + .textInputAutocapitalization(title.localizedCaseInsensitiveContains("path") ? .never : .sentences) + .autocorrectionDisabled(title.localizedCaseInsensitiveContains("path")) + .submitLabel(.done) + .padding(12) + .frame(minHeight: 44, maxHeight: 56, alignment: .center) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ADEColor.recessedBackground.opacity(0.78), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.5) + ) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .accessibilityLabel(title) } } diff --git a/apps/ios/ADE/Views/Lanes/LaneCreateSheet.swift b/apps/ios/ADE/Views/Lanes/LaneCreateSheet.swift index eab3e5b9c..325b33bd9 100644 --- a/apps/ios/ADE/Views/Lanes/LaneCreateSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneCreateSheet.swift @@ -124,76 +124,63 @@ struct LaneCreateSheet: View { GlassSection(title: showsModePicker ? "Mode" : modeSectionTitle) { VStack(alignment: .leading, spacing: 12) { if showsModePicker { - Picker("Create mode", selection: $createMode) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 132), spacing: 8)], alignment: .leading, spacing: 8) { ForEach(LaneCreateMode.allCases) { mode in - Text(mode.title) - .tag(mode) - .accessibilityLabel(mode.fullTitle) + LaneOptionButton( + title: mode.title, + subtitle: modeSubtitle(mode), + systemImage: modeSymbol(mode), + isSelected: createMode == mode + ) { + createMode = mode + } } } - .pickerStyle(.segmented) } switch createMode { case .primary: VStack(alignment: .leading, spacing: 12) { - Picker("Base branch", selection: $selectedBaseBranch) { - ForEach(branches.filter { !$0.isRemote }) { branch in - Text(branch.isCurrent ? "\(branch.name) (current)" : branch.name).tag(branch.name) - } - } - .pickerStyle(.menu) - if branches.filter({ !$0.isRemote }).isEmpty { - Text("No local branches found.") - .font(.caption) - .foregroundStyle(ADEColor.textMuted) - } + branchOptionList( + branches: branches.filter { !$0.isRemote }, + emptyText: "No local branches found.", + selection: $selectedBaseBranch + ) } case .child: VStack(alignment: .leading, spacing: 12) { - Picker("Parent lane", selection: $selectedParentLaneId) { - Text("Select parent lane…").tag("") - ForEach(lanes.filter { $0.archivedAt == nil }) { lane in - Text("\(lane.name) (\(lane.branchRef))").tag(lane.id) - } - } - .pickerStyle(.menu) + laneOptionList( + lanes: lanes.filter { $0.archivedAt == nil }, + emptyText: "No lanes available.", + selection: $selectedParentLaneId + ) } case .importBranch: VStack(alignment: .leading, spacing: 12) { - Picker("Existing branch", selection: $selectedImportBranch) { - Text("Select a branch…").tag("") - ForEach(branches) { branch in - Text(branch.isRemote ? "\(branch.name) (remote)" : branch.name).tag(branch.name) - } - } - .pickerStyle(.menu) - if branches.isEmpty { - Text("No branches found.") - .font(.caption) - .foregroundStyle(ADEColor.textMuted) - } - Picker("Base branch", selection: $selectedBaseBranch) { - ForEach(branches.filter { !$0.isRemote }) { branch in - Text(branch.isCurrent ? "\(branch.name) (current)" : branch.name).tag(branch.name) - } - } - .pickerStyle(.menu) + Text("Existing branch") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + branchOptionList( + branches: branches, + emptyText: "No branches found.", + selection: $selectedImportBranch + ) + Text("Base branch") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + branchOptionList( + branches: branches.filter { !$0.isRemote }, + emptyText: "No local base branches found.", + selection: $selectedBaseBranch + ) } case .rescueUnstaged: VStack(alignment: .leading, spacing: 12) { - Picker("Source lane", selection: $selectedRescueLaneId) { - Text("Select lane").tag("") - ForEach(lanes.filter { $0.archivedAt == nil && $0.status.dirty }) { lane in - Text("\(lane.name) (\(lane.branchRef))").tag(lane.id) - } - } - .pickerStyle(.menu) - if lanes.filter({ $0.archivedAt == nil && $0.status.dirty }).isEmpty { - Text("No lanes with unstaged changes.") - .font(.caption) - .foregroundStyle(ADEColor.textMuted) - } + laneOptionList( + lanes: lanes.filter { $0.archivedAt == nil && $0.status.dirty }, + emptyText: "No lanes with unstaged changes.", + selection: $selectedRescueLaneId + ) } } } @@ -277,6 +264,105 @@ struct LaneCreateSheet: View { } } + @MainActor + private func modeSubtitle(_ mode: LaneCreateMode) -> String { + switch mode { + case .primary: return "Start from a base branch" + case .child: return "Stack under a parent lane" + case .importBranch: return "Adopt an existing branch" + case .rescueUnstaged: return "Move dirty changes" + } + } + + private func modeSymbol(_ mode: LaneCreateMode) -> String { + switch mode { + case .primary: return "plus.square.on.square" + case .child: return "square.stack.3d.up" + case .importBranch: return "arrow.triangle.branch" + case .rescueUnstaged: return "cross.case" + } + } + + @ViewBuilder + private func branchOptionList( + branches: [GitBranchSummary], + emptyText: String, + selection: Binding + ) -> some View { + if branches.isEmpty { + Text(emptyText) + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + } else if branches.count > 4 { + ScrollView { + branchOptionStack(branches: branches, selection: selection) + } + .frame(maxHeight: 320) + .scrollBounceBehavior(.basedOnSize) + } else { + branchOptionStack(branches: branches, selection: selection) + } + } + + @ViewBuilder + private func branchOptionStack( + branches: [GitBranchSummary], + selection: Binding + ) -> some View { + LazyVStack(spacing: 8) { + ForEach(branches) { branch in + LaneOptionButton( + title: branch.name, + subtitle: branch.isRemote ? "Remote branch" : (branch.isCurrent ? "Current local branch" : "Local branch"), + systemImage: branch.isRemote ? "cloud" : "arrow.triangle.branch", + isSelected: selection.wrappedValue == branch.name + ) { + selection.wrappedValue = branch.name + } + } + } + } + + @ViewBuilder + private func laneOptionList( + lanes: [LaneSummary], + emptyText: String, + selection: Binding + ) -> some View { + if lanes.isEmpty { + Text(emptyText) + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + } else if lanes.count > 4 { + ScrollView { + laneOptionStack(lanes: lanes, selection: selection) + } + .frame(maxHeight: 320) + .scrollBounceBehavior(.basedOnSize) + } else { + laneOptionStack(lanes: lanes, selection: selection) + } + } + + @ViewBuilder + private func laneOptionStack( + lanes: [LaneSummary], + selection: Binding + ) -> some View { + LazyVStack(spacing: 8) { + ForEach(lanes) { lane in + LaneOptionButton( + title: lane.name, + subtitle: lane.branchRef, + systemImage: lane.laneType == "primary" ? "house.fill" : "arrow.triangle.branch", + isSelected: selection.wrappedValue == lane.id + ) { + selection.wrappedValue = lane.id + } + } + } + } + @MainActor private func loadOptions() async { do { diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailContentSections.swift b/apps/ios/ADE/Views/Lanes/LaneDetailContentSections.swift index d7a5732ce..998699587 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailContentSections.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailContentSections.swift @@ -37,7 +37,6 @@ struct LaneDetailHeaderCard: View { } .adeGlassCard(cornerRadius: 18, padding: 16) .accessibilityElement(children: .contain) - .accessibilityLabel(headerAccessibilityLabel) } private var headerTopRow: some View { @@ -65,15 +64,22 @@ struct LaneDetailHeaderCard: View { /// this is the everyday "I'm done, push it" affordance. @ViewBuilder private var syncActionsRow: some View { - let ahead = snapshot.lane.status.ahead - let behind = snapshot.lane.status.behind - let summary = syncSummaryText(ahead: ahead, behind: behind) + let syncStatus = detail?.syncStatus + let remoteAhead = syncStatus?.ahead ?? 0 + let remoteBehind = syncStatus?.behind ?? 0 + let hasUpstream = syncStatus?.hasUpstream ?? true + let diverged = syncStatus?.diverged ?? false + let shouldPull = hasUpstream && remoteBehind > 0 && !diverged + // While detail is still loading (syncStatus nil) we don't know the ahead count yet, so + // keep Push enabled instead of disabling an already-ahead lane until the fetch lands. + let syncStatusLoaded = syncStatus != nil + let shouldPush = !syncStatusLoaded || !hasUpstream || remoteAhead > 0 HStack(spacing: 8) { Image(systemName: "arrow.triangle.2.circlepath") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(ADEColor.textSecondary) - Text(summary) + Text(compactSyncSummary(syncStatus)) .font(.caption) .foregroundStyle(ADEColor.textSecondary) .lineLimit(1) @@ -86,37 +92,31 @@ struct LaneDetailHeaderCard: View { // and Fetch (when we just want to refresh remote state). The label // and accessibility text must follow the action so VoiceOver users // hear what the button is actually about to do. - label: behind > 0 ? "Pull" : "Fetch", - tint: behind > 0 ? ADEColor.warning : ADEColor.textPrimary, - emphasize: behind > 0, - action: behind > 0 ? onPull : onFetch + label: shouldPull ? "Pull" : "Fetch", + tint: shouldPull ? ADEColor.warning : ADEColor.textPrimary, + emphasize: shouldPull, + isEnabled: canRunLiveActions, + action: shouldPull ? onPull : onFetch ) syncActionButton( symbol: "arrow.up.to.line.compact", label: "Push", - tint: ahead > 0 ? ADEColor.success : ADEColor.textPrimary, - emphasize: ahead > 0, + tint: shouldPush ? ADEColor.success : ADEColor.textPrimary, + emphasize: shouldPush, + isEnabled: canRunLiveActions && shouldPush && !diverged, action: onPush ) } .padding(.top, 2) } - private func syncSummaryText(ahead: Int, behind: Int) -> String { - switch (ahead, behind) { - case (0, 0): return "In sync with remote" - case (let a, 0): return "\(a) ahead" - case (0, let b): return "\(b) behind" - case (let a, let b): return "\(a) ahead · \(b) behind" - } - } - @ViewBuilder private func syncActionButton( symbol: String, label: String, tint: Color, emphasize: Bool, + isEnabled: Bool, action: @escaping () -> Void ) -> some View { Button(action: action) { @@ -138,8 +138,8 @@ struct LaneDetailHeaderCard: View { ) } .buttonStyle(.plain) - .disabled(!canRunLiveActions) - .opacity(canRunLiveActions ? 1 : 0.5) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.5) .accessibilityLabel(label) } @@ -267,32 +267,26 @@ struct LaneDetailHeaderCard: View { .buttonStyle(.plain) .accessibilityLabel("Open linked pull request") } else if linkedPullRequests.count > 1 { - Menu { - ForEach(Array(linkedPullRequests.enumerated()), id: \.offset) { _, pr in - Button(pr.title.isEmpty ? "PR #\(pr.githubPrNumber)" : pr.title) { - onOpenLinkedPullRequest(pr) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(Array(linkedPullRequests.enumerated()), id: \.offset) { _, pr in + Button { + onOpenLinkedPullRequest(pr) + } label: { + LaneTypeBadge( + text: "#\(pr.githubPrNumber)", + tint: lanePullRequestTint(pr.state) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(pr.title.isEmpty ? "Open PR \(pr.githubPrNumber)" : "Open \(pr.title)") } } - } label: { - LaneTypeBadge( - text: "\(linkedPullRequests.count) PRs", - tint: lanePullRequestTint(linkedPullRequests.first?.state ?? "open") - ) } .accessibilityLabel("\(linkedPullRequests.count) linked pull requests") } } - private var headerAccessibilityLabel: String { - var pieces = [snapshot.lane.name, snapshot.lane.branchRef] - if snapshot.lane.status.dirty { pieces.append("dirty") } else { pieces.append("clean") } - if snapshot.lane.status.ahead > 0 { pieces.append("\(snapshot.lane.status.ahead) ahead") } - if snapshot.lane.status.behind > 0 { pieces.append("\(snapshot.lane.status.behind) behind") } - if snapshot.lane.childCount > 0 { pieces.append("\(snapshot.lane.childCount) child\(snapshot.lane.childCount == 1 ? "" : "ren")") } - if !linkedPullRequests.isEmpty { pieces.append("\(linkedPullRequests.count) linked pull request\(linkedPullRequests.count == 1 ? "" : "s")") } - return pieces.joined(separator: ", ") - } - private var headerSummaryText: String? { guard let detail else { return nil } if let conflictStatus = detail.conflictStatus { diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift index 807e1d067..5a6eae3bb 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift @@ -113,13 +113,15 @@ struct LaneDetailScreen: View { .adeNavigationZoomTransition(id: transitionNamespace == nil ? nil : "lane-container-\(laneId)", in: transitionNamespace) .task { syncService.announceLaneOpen(laneId: laneId) - await loadDetail(refreshRemote: false) + await loadDetail(refreshRemote: canRunLiveActions) + } + .task(id: syncService.localStateRevision) { + guard busyAction == nil else { return } if detail == nil, canRunLiveActions { await loadDetail(refreshRemote: true) + return } - } - .task(id: syncService.localStateRevision) { - guard busyAction == nil, detail != nil else { return } + guard detail != nil else { return } let now = Date() guard now.timeIntervalSince(lastLaneDetailLocalReload) >= 0.35 else { return } lastLaneDetailLocalReload = now @@ -199,10 +201,10 @@ struct LaneDetailScreen: View { Task { await performAction("unstage file") { try await syncService.unstageFile(laneId: laneId, path: file.path) } } }, onDiscardFile: { file in - pendingFileConfirmation = .discardUnstaged(file) + Task { await performConfirmedFileAction(.discardUnstaged(file)) } }, onRestoreStaged: { file in - pendingFileConfirmation = .restoreStaged(file) + Task { await performConfirmedFileAction(.restoreStaged(file)) } }, onStageAll: { let paths = (detail?.diffChanges?.unstaged ?? []).map(\.path) @@ -217,12 +219,12 @@ struct LaneDetailScreen: View { onDiscardAllUnstaged: { let files = detail?.diffChanges?.unstaged ?? [] guard !files.isEmpty else { return } - pendingFileConfirmation = .discardAllUnstaged(files) + Task { await performConfirmedFileAction(.discardAllUnstaged(files)) } }, onRestoreAllStaged: { let files = detail?.diffChanges?.staged ?? [] guard !files.isEmpty else { return } - pendingFileConfirmation = .restoreAllStaged(files) + Task { await performConfirmedFileAction(.restoreAllStaged(files)) } }, onOpenDiff: { file, isStaged in selectedDiffRequest = LaneDiffRequest( @@ -351,16 +353,13 @@ struct LaneDetailScreen: View { @MainActor func loadDetail(refreshRemote: Bool) async { do { - async let cachedDetailTask = syncService.fetchLaneDetail(laneId: laneId) - async let pullRequestsTask = syncService.fetchPullRequestListItems(laneId: laneId) - - if let cachedDetail = try await cachedDetailTask { + if let cachedDetail = try await syncService.fetchLaneDetail(laneId: laneId) { if detail != cachedDetail { detail = cachedDetail } } - let cachedPullRequests = try await pullRequestsTask + let cachedPullRequests = try await syncService.fetchPullRequestListItems(laneId: laneId) if lanePullRequests != cachedPullRequests { lanePullRequests = cachedPullRequests } diff --git a/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift index 44a114f14..6d56b034e 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift @@ -55,12 +55,24 @@ struct LaneDiffScreen: View { } if diff != nil { - Picker("Side", selection: $side) { - Text("Original").tag("original") - Text("Modified").tag("modified") + HStack(spacing: 8) { + LaneOptionButton( + title: "Original", + subtitle: "Base content", + systemImage: "doc.text", + isSelected: side == "original" + ) { + side = "original" + } + LaneOptionButton( + title: "Modified", + subtitle: request.mode == "unstaged" ? "Editable content" : "Compared content", + systemImage: "square.and.pencil", + isSelected: side == "modified" + ) { + side = "modified" + } } - .pickerStyle(.segmented) - .padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4)) } } .padding(16) diff --git a/apps/ios/ADE/Views/Lanes/LaneFileTreeComponents.swift b/apps/ios/ADE/Views/Lanes/LaneFileTreeComponents.swift index 45bd8208e..0b38cbeb2 100644 --- a/apps/ios/ADE/Views/Lanes/LaneFileTreeComponents.swift +++ b/apps/ios/ADE/Views/Lanes/LaneFileTreeComponents.swift @@ -307,27 +307,29 @@ private struct LaneFileRow: View { } Spacer() } - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - LaneActionButton(title: "Diff", symbol: "doc.text.magnifyingglass") { onDiff() } - .disabled(!allowsDiffInspection) - if let onOpenFiles { - LaneActionButton(title: "Open in Files", symbol: "folder") { onOpenFiles() } - } - LaneActionButton(title: primaryActionTitle, symbol: primaryActionSymbol, tint: primaryActionTint) { - onPrimaryAction() - } - .disabled(!allowsLiveActions) - LaneActionButton(title: secondaryActionTitle, symbol: secondaryActionSymbol, tint: secondaryActionTint) { - onSecondaryAction() - } - .disabled(!allowsLiveActions) + LazyVGrid(columns: actionColumns, alignment: .leading, spacing: 6) { + LaneActionButton(title: "Diff", symbol: "doc.text.magnifyingglass") { onDiff() } + .disabled(!allowsDiffInspection) + if let onOpenFiles { + LaneActionButton(title: "Open in Files", symbol: "folder") { onOpenFiles() } + } + LaneActionButton(title: primaryActionTitle, symbol: primaryActionSymbol, tint: primaryActionTint) { + onPrimaryAction() } + .disabled(!allowsLiveActions) + LaneActionButton(title: secondaryActionTitle, symbol: secondaryActionSymbol, tint: secondaryActionTint) { + onSecondaryAction() + } + .disabled(!allowsLiveActions) } } .adeGlassCard(cornerRadius: 10, padding: 10) } + private var actionColumns: [GridItem] { + [GridItem(.adaptive(minimum: 92), spacing: 6, alignment: .leading)] + } + private func fileKindTint(_ kind: String) -> Color { switch kind.lowercased() { case "added", "created": diff --git a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift index 2f9edd6f4..d146fc6cc 100644 --- a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift +++ b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift @@ -329,6 +329,16 @@ func syncSummary(_ status: GitUpstreamSyncStatus) -> String { return "In sync with remote." } +func compactSyncSummary(_ status: GitUpstreamSyncStatus?) -> String { + guard let status else { return "Checking remote" } + if !status.hasUpstream { return "No upstream" } + if status.diverged { return "Diverged" } + if status.ahead > 0 && status.behind == 0 { return "\(status.ahead) ahead remote" } + if status.behind > 0 && status.ahead == 0 { return "\(status.behind) behind remote" } + if status.ahead > 0 && status.behind > 0 { return "\(status.ahead) ahead · \(status.behind) behind remote" } + return "In sync with remote" +} + func conflictSummary(_ status: ConflictStatus) -> String { switch status.status { case "conflict-active": diff --git a/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift b/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift index a417dcf91..daf3d0ef9 100644 --- a/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift +++ b/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift @@ -444,13 +444,19 @@ extension LanesTabView { func handleRequestedLaneNavigation() async { guard let request = syncService.requestedLaneNavigation else { return } + // Let the context menu dismiss and the TabView finish presenting Lanes before + // attaching a sheet. Without this hop, cross-tab "Go to lane" can switch + // tabs while SwiftUI silently drops the detail presentation. + try? await Task.sleep(for: .milliseconds(650)) + guard syncService.requestedLaneNavigation?.id == request.id else { return } + var snapshot = laneSnapshots.first(where: { $0.lane.id == request.laneId }) if snapshot == nil { await reload(refreshRemote: canRunLiveActions) snapshot = laneSnapshots.first(where: { $0.lane.id == request.laneId }) } - guard let snapshot else { + guard let resolvedSnapshot = snapshot else { errorMessage = "The requested lane is not cached on this phone yet. Refresh Lanes and try again." syncService.requestedLaneNavigation = nil return @@ -462,7 +468,7 @@ extension LanesTabView { selectedLaneTransitionId = request.laneId detailSheetTarget = LaneDetailSheetTarget( laneId: request.laneId, - snapshot: snapshot, + snapshot: resolvedSnapshot, initialSection: .git ) syncService.requestedLaneNavigation = nil diff --git a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift index 4f48773cf..30f11b725 100644 --- a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift @@ -259,20 +259,18 @@ struct LaneManageSheet: View { } } - Picker("Parent lane", selection: $selectedParentLaneId) { - if reparentCandidates.isEmpty { - Text("No valid parent").tag("") - } else { - ForEach(reparentCandidates) { lane in - Text(lane.laneType == "primary" ? "\(lane.name) (primary)" : "\(lane.name) (\(lane.branchRef))").tag(lane.id) - } + if reparentCandidates.isEmpty { + Text("No valid parent") + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + } else if reparentCandidates.count > 4 { + ScrollView { + reparentCandidateStack } - } - .pickerStyle(.menu) - .onChange(of: selectedParentLaneId) { _, _ in - // Clear the override so the new parent's branch is used as - // the default — matches desktop behavior on parent change. - baseBranchOverride = "" + .frame(maxHeight: 320) + .scrollBounceBehavior(.basedOnSize) + } else { + reparentCandidateStack } VStack(alignment: .leading, spacing: 4) { @@ -325,12 +323,19 @@ struct LaneManageSheet: View { if snapshot.lane.laneType != "primary" { GlassSection(title: "Danger zone") { VStack(alignment: .leading, spacing: 12) { - Picker("Delete mode", selection: $deleteMode) { + LazyVStack(spacing: 8) { ForEach(LaneDeleteMode.allCases) { mode in - Text(mode.title).tag(mode) + LaneOptionButton( + title: mode.title, + subtitle: mode.detail, + systemImage: mode.symbol, + isSelected: deleteMode == mode, + tint: ADEColor.danger + ) { + deleteMode = mode + } } } - .pickerStyle(.menu) if deleteMode == .remoteBranch { LaneTextField("Remote name", text: $deleteRemoteName) @@ -401,6 +406,24 @@ struct LaneManageSheet: View { } } + private var reparentCandidateStack: some View { + LazyVStack(spacing: 8) { + ForEach(reparentCandidates) { lane in + LaneOptionButton( + title: lane.name, + subtitle: lane.laneType == "primary" ? "Primary root · \(lane.branchRef)" : lane.branchRef, + systemImage: lane.laneType == "primary" ? "house.fill" : "arrow.triangle.branch", + isSelected: selectedParentLaneId == lane.id + ) { + selectedParentLaneId = lane.id + // Clear the override so the new parent's branch is used as + // the default — matches desktop behavior on parent change. + baseBranchOverride = "" + } + } + } + } + @MainActor private func performAction(_ label: String, operation: () async throws -> Void) async { guard canRunLiveActions else { diff --git a/apps/ios/ADE/Views/Lanes/LaneTypes.swift b/apps/ios/ADE/Views/Lanes/LaneTypes.swift index 8d048175c..7f0e6ef16 100644 --- a/apps/ios/ADE/Views/Lanes/LaneTypes.swift +++ b/apps/ios/ADE/Views/Lanes/LaneTypes.swift @@ -233,6 +233,22 @@ enum LaneDeleteMode: String, CaseIterable, Identifiable { case .remoteBranch: return "Worktree + local + remote" } } + + var detail: String { + switch self { + case .worktree: return "Remove the ADE lane and worktree" + case .localBranch: return "Also delete the local branch" + case .remoteBranch: return "Also delete the remote branch" + } + } + + var symbol: String { + switch self { + case .worktree: return "folder.badge.minus" + case .localBranch: return "arrow.triangle.branch" + case .remoteBranch: return "cloud.slash" + } + } } // MARK: - Model structs diff --git a/apps/ios/ADE/Views/LanesTabView.swift b/apps/ios/ADE/Views/LanesTabView.swift index a5fb240d2..27c3bb8a6 100644 --- a/apps/ios/ADE/Views/LanesTabView.swift +++ b/apps/ios/ADE/Views/LanesTabView.swift @@ -171,6 +171,18 @@ struct LanesTabView: View { guard laneNavigationRequestKey != nil else { return } await handleRequestedLaneNavigation() } + .onAppear { + guard isActive, syncService.requestedLaneNavigation != nil else { return } + Task { await handleRequestedLaneNavigation() } + } + .onChange(of: isActive) { _, active in + guard active, syncService.requestedLaneNavigation != nil else { return } + Task { await handleRequestedLaneNavigation() } + } + .onChange(of: syncService.requestedLaneNavigation?.id) { _, requestId in + guard isActive, requestId != nil else { return } + Task { await handleRequestedLaneNavigation() } + } .onChange(of: syncService.connectionState) { oldValue, newValue in guard isActive else { return } let wasOnline = oldValue == .connected || oldValue == .syncing diff --git a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift index 191b8b850..6477756ec 100644 --- a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift +++ b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift @@ -84,12 +84,21 @@ struct CreatePrWizardView: View { // Cached eligible lane options — recomputed only when the source-of-truth // (capabilities / lanes) shifts, not on every keystroke. @State private var cachedLaneOptions: [CreatePrLaneOption] = [] + @State private var cachedBlockedLaneOptions: [PrCreateLaneEligibility] = [] + @State private var didCacheLaneOptions = false + @State private var showAllBlockedLanes = false + + private static let collapsedBlockedLaneLimit = 3 private var fallbackCreateLanes: [LaneSummary] { lanes.filter { $0.archivedAt == nil && $0.laneType != "primary" } } private var eligibleLaneOptions: [CreatePrLaneOption] { + didCacheLaneOptions ? cachedLaneOptions : sourceEligibleLaneOptions + } + + private var sourceEligibleLaneOptions: [CreatePrLaneOption] { if let capabilities = createCapabilities { return capabilities.lanes .filter { Self.canOpenPr(from: $0) } @@ -117,10 +126,29 @@ struct CreatePrWizardView: View { } private var blockedLaneOptions: [PrCreateLaneEligibility] { + didCacheLaneOptions ? cachedBlockedLaneOptions : sourceBlockedLaneOptions + } + + private var sourceBlockedLaneOptions: [PrCreateLaneEligibility] { guard let capabilities = createCapabilities else { return [] } return capabilities.lanes.filter { !Self.canOpenPr(from: $0) } } + private var visibleBlockedLaneOptions: [PrCreateLaneEligibility] { + guard !showAllBlockedLanes else { return blockedLaneOptions } + return Array(blockedLaneOptions.prefix(Self.collapsedBlockedLaneLimit)) + } + + private var canToggleBlockedLanes: Bool { + blockedLaneOptions.count > Self.collapsedBlockedLaneLimit + } + + private var blockedLaneToggleTitle: String { + showAllBlockedLanes + ? "Show fewer" + : "Show \(blockedLaneOptions.count - Self.collapsedBlockedLaneLimit) more" + } + private var selectedOption: CreatePrLaneOption? { eligibleLaneOptions.first(where: { $0.id == selectedLaneId }) ?? eligibleLaneOptions.first } @@ -388,7 +416,7 @@ struct CreatePrWizardView: View { labelsSection integrationReviewSection } - Color.clear.frame(height: 40) + Color.clear.frame(height: 72) } } .scrollIndicators(.hidden) @@ -522,7 +550,7 @@ struct CreatePrWizardView: View { .font(.caption2.weight(.semibold)) .foregroundStyle(ADEColor.textSecondary) } - ForEach(blockedLaneOptions) { entry in + ForEach(visibleBlockedLaneOptions) { entry in HStack(alignment: .firstTextBaseline, spacing: 8) { Text(entry.laneName) .font(.caption.weight(.semibold)) @@ -536,6 +564,23 @@ struct CreatePrWizardView: View { Spacer(minLength: 0) } } + if canToggleBlockedLanes { + Button { + showAllBlockedLanes.toggle() + } label: { + HStack(spacing: 5) { + Text(blockedLaneToggleTitle) + Image(systemName: showAllBlockedLanes ? "chevron.up" : "chevron.down") + .font(.system(size: 9, weight: .bold)) + } + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.top, 2) + } } .padding(.horizontal, 22) .padding(.bottom, 12) @@ -1108,6 +1153,7 @@ struct CreatePrWizardView: View { let laneIds = orderedSelectedLaneIds let trimmedName = queueName.trimmingCharacters(in: .whitespacesAndNewlines) let baseTrim = baseBranch.trimmingCharacters(in: .whitespacesAndNewlines) + let targetBranch = baseTrim.isEmpty ? defaultTargetBranch : baseTrim onCreateQueue( CreateQueuePrsRequest( laneIds: laneIds, @@ -1117,7 +1163,7 @@ struct CreatePrWizardView: View { ciGating: ciGating, // v2 TODO: per-lane title overrides. titles: nil, - baseBranch: baseTrim.isEmpty ? nil : baseTrim + baseBranch: targetBranch ) ) case .integration: @@ -1125,6 +1171,7 @@ struct CreatePrWizardView: View { let trimmedName = integrationLaneName.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) let baseTrim = baseBranch.trimmingCharacters(in: .whitespacesAndNewlines) + let targetBranch = baseTrim.isEmpty ? defaultTargetBranch : baseTrim onCreateIntegration( CreateIntegrationRequest( sourceLaneIds: laneIds, @@ -1132,7 +1179,7 @@ struct CreatePrWizardView: View { title: trimmedTitle, body: bodyText, draft: draft, - baseBranch: baseTrim.isEmpty ? nil : baseTrim + baseBranch: targetBranch ) ) } @@ -1150,7 +1197,12 @@ struct CreatePrWizardView: View { } private func refreshCachedLaneOptions() { - cachedLaneOptions = eligibleLaneOptions + cachedLaneOptions = sourceEligibleLaneOptions + cachedBlockedLaneOptions = sourceBlockedLaneOptions + didCacheLaneOptions = true + if sourceBlockedLaneOptions.count <= Self.collapsedBlockedLaneLimit { + showAllBlockedLanes = false + } } // MARK: - Draft generation diff --git a/apps/ios/ADE/Views/PRs/PrAiResolverCtaCard.swift b/apps/ios/ADE/Views/PRs/PrAiResolverCtaCard.swift index de9661396..af051fb8e 100644 --- a/apps/ios/ADE/Views/PRs/PrAiResolverCtaCard.swift +++ b/apps/ios/ADE/Views/PRs/PrAiResolverCtaCard.swift @@ -221,12 +221,7 @@ struct PrAiResolverSheet: View { } Section("Reasoning effort") { - Picker("Effort", selection: $reasoningEffort) { - ForEach(efforts, id: \.0) { pair in - Text(pair.1).tag(pair.0) - } - } - .pickerStyle(.segmented) + PrAiResolverEffortPicker(efforts: efforts, selection: $reasoningEffort) } Section("Model (optional)") { @@ -260,3 +255,36 @@ struct PrAiResolverSheet: View { } } } + +private struct PrAiResolverEffortPicker: View { + let efforts: [(String, String)] + @Binding var selection: String + + var body: some View { + HStack(spacing: 6) { + ForEach(efforts, id: \.0) { value, label in + let selected = value == selection + Button { + selection = value + } label: { + Text(label) + .font(.system(size: 13, weight: selected ? .semibold : .medium)) + .foregroundStyle(selected ? Color.white : ADEColor.textSecondary) + .frame(maxWidth: .infinity, minHeight: 38) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(selected ? PrGlassPalette.purpleDeep.opacity(0.9) : Color.white.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder(selected ? PrGlassPalette.purpleBright.opacity(0.45) : Color.white.opacity(0.08), lineWidth: 0.75) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Reasoning effort: \(label)") + .accessibilityValue(selected ? "selected" : "not selected") + } + } + .padding(.vertical, 4) + } +} diff --git a/apps/ios/ADE/Views/PRs/PrDetailActivityTab.swift b/apps/ios/ADE/Views/PRs/PrDetailActivityTab.swift index 759db6a01..e4eb0d97b 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailActivityTab.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailActivityTab.swift @@ -250,6 +250,10 @@ struct PrActivityTab: View { .font(.caption) .foregroundStyle(ADEColor.textSecondary) } + + Color.clear + .frame(height: 88) + .accessibilityHidden(true) } .background(timelineRail, alignment: .topLeading) } diff --git a/apps/ios/ADE/Views/PRs/PrDetailChecksTab.swift b/apps/ios/ADE/Views/PRs/PrDetailChecksTab.swift index 57fa154c7..863168f1a 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailChecksTab.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailChecksTab.swift @@ -1,7 +1,73 @@ import SwiftUI +struct PrChecksSummaryStats: Equatable { + let fail: Int + let pending: Int + let pass: Int + let total: Int +} + +func prChecksSummaryStats(checks: [PrCheck], overallChecksStatus: String?) -> PrChecksSummaryStats { + var fail = 0, pending = 0, pass = 0 + for check in checks { + switch prCheckConclusionKind(check) { + case .success: pass += 1 + case .failure: fail += 1 + case .pending: pending += 1 + // Neutral/skipped checks are non-failing outcomes; bucket them with pass so the + // stat strip's total always equals the sum of pass + fail + pending. + case .neutral: pass += 1 + } + } + if !checks.isEmpty { + return .init(fail: fail, pending: pending, pass: pass, total: checks.count) + } + + switch overallChecksStatus?.lowercased() { + case "failing", "failure", "failed": + return .init(fail: 1, pending: 0, pass: 0, total: 1) + case "pending", "running", "in_progress": + return .init(fail: 0, pending: 1, pass: 0, total: 1) + case "passing", "success", "passed": + return .init(fail: 0, pending: 0, pass: 1, total: 1) + default: + return .init(fail: 0, pending: 0, pass: 0, total: 0) + } +} + +func prChecksHasFailedSignal(checks: [PrCheck], overallChecksStatus: String?) -> Bool { + checks.contains { prCheckConclusionKind($0) == .failure } + || prChecksSummaryStats(checks: checks, overallChecksStatus: overallChecksStatus).fail > 0 +} + +func prChecksEmptyStateCopy(overallChecksStatus: String?) -> (title: String, message: String) { + switch overallChecksStatus?.lowercased() { + case "failing", "failure", "failed": + return ( + "Checks failing", + "The PR summary reports failing checks, but individual check runs have not synced yet." + ) + case "pending", "running", "in_progress": + return ( + "Checks pending", + "The PR summary reports pending checks, but individual check runs have not synced yet." + ) + case "passing", "success", "passed": + return ( + "Checks passing", + "The PR summary reports passing checks, but individual check runs have not synced yet." + ) + default: + return ( + "No CI checks", + "No check runs were synced for this PR yet." + ) + } +} + struct PrChecksTab: View { let checks: [PrCheck] + let overallChecksStatus: String? let actionRuns: [PrActionRun] let deployments: [PrDeployment] let canRerunChecks: Bool @@ -14,6 +80,7 @@ struct PrChecksTab: View { init( checks: [PrCheck], + overallChecksStatus: String? = nil, actionRuns: [PrActionRun], deployments: [PrDeployment] = [], canRerunChecks: Bool, @@ -25,6 +92,7 @@ struct PrChecksTab: View { onStopAiResolver: @escaping () -> Void = {} ) { self.checks = checks + self.overallChecksStatus = overallChecksStatus self.actionRuns = actionRuns self.deployments = deployments self.canRerunChecks = canRerunChecks @@ -36,17 +104,8 @@ struct PrChecksTab: View { self.onStopAiResolver = onStopAiResolver } - private var stats: PrChecksStatStrip.Stats { - var fail = 0, pending = 0, pass = 0 - for check in checks { - switch prCheckConclusionKind(check) { - case .success: pass += 1 - case .failure: fail += 1 - case .pending: pending += 1 - case .neutral: break - } - } - return .init(fail: fail, pending: pending, pass: pass, total: checks.count) + private var stats: PrChecksSummaryStats { + prChecksSummaryStats(checks: checks, overallChecksStatus: overallChecksStatus) } private var groups: [PrCheckGroup] { @@ -54,7 +113,11 @@ struct PrChecksTab: View { } private var hasFailedChecks: Bool { - checks.contains { prCheckConclusionKind($0) == .failure } + prChecksHasFailedSignal(checks: checks, overallChecksStatus: overallChecksStatus) + } + + private var emptyStateCopy: (title: String, message: String) { + prChecksEmptyStateCopy(overallChecksStatus: overallChecksStatus) } private var aiResolverRunning: Bool { @@ -64,7 +127,7 @@ struct PrChecksTab: View { var body: some View { VStack(alignment: .leading, spacing: 14) { - if !checks.isEmpty { + if stats.total > 0 { PrChecksProgressBar(stats: stats) } PrChecksStatStrip(stats: stats) @@ -72,8 +135,8 @@ struct PrChecksTab: View { if checks.isEmpty { ADEEmptyStateView( symbol: "checklist", - title: "No CI checks", - message: "No check runs were synced for this PR yet." + title: emptyStateCopy.title, + message: emptyStateCopy.message ) } else { ForEach(groups, id: \.kind) { group in @@ -135,7 +198,7 @@ struct PrChecksTab: View { /// segment width is proportional to its share of the run set; the layout uses /// GeometryReader so it tracks the parent width even on rotation. private struct PrChecksProgressBar: View { - let stats: PrChecksStatStrip.Stats + let stats: PrChecksSummaryStats private var passSummary: String { let total = stats.total @@ -182,14 +245,7 @@ private struct PrChecksProgressBar: View { // MARK: - Stat strip private struct PrChecksStatStrip: View { - struct Stats { - let fail: Int - let pending: Int - let pass: Int - let total: Int - } - - let stats: Stats + let stats: PrChecksSummaryStats var body: some View { HStack(spacing: 8) { diff --git a/apps/ios/ADE/Views/PRs/PrDetailFilesTab.swift b/apps/ios/ADE/Views/PRs/PrDetailFilesTab.swift index 4bec1d65b..4ef15da8a 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailFilesTab.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailFilesTab.swift @@ -350,32 +350,16 @@ private struct PrFileRowLabel: View { statusChip(for: file.status) - Menu { - Button { + HStack(spacing: 4) { + prFileInlineAction(symbol: "folder", label: "Open \(file.filename) in Files") { onOpenFile(file) - } label: { - Label("Open in Files", systemImage: "folder") } .disabled(!canOpenFiles) - Button { + prFileInlineAction(symbol: "doc.on.doc", label: "Copy path for \(file.filename)") { onCopyPath(file) - } label: { - Label("Copy path", systemImage: "doc.on.doc") - } - } label: { - ZStack { - Circle() - .fill(Color.white.opacity(0.06)) - Circle() - .strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5) - Image(systemName: "ellipsis") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(ADEColor.textSecondary) } - .frame(width: 24, height: 24) } - .accessibilityLabel("File actions for \(file.filename)") Image(systemName: "chevron.right") .font(.system(size: 10, weight: .semibold)) @@ -410,6 +394,27 @@ private struct PrFileRowLabel: View { EmptyView() } } + + private func prFileInlineAction(symbol: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + // Visual chip stays compact (24pt circle) but the outer frame expands the tap target + // to Apple's HIG-minimum 44pt so these inline actions don't punish thumb taps in the row. + ZStack { + Circle() + .fill(Color.white.opacity(0.06)) + Circle() + .strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5) + Image(systemName: symbol) + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(ADEColor.textSecondary) + } + .frame(width: 24, height: 24) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + } } struct PrUnifiedDiffView: View { diff --git a/apps/ios/ADE/Views/PRs/PrDetailOverviewTab.swift b/apps/ios/ADE/Views/PRs/PrDetailOverviewTab.swift index 5ca888d2f..f417fe0b5 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailOverviewTab.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailOverviewTab.swift @@ -661,7 +661,7 @@ struct PrPathToMergeTab: View { conflictStrategy: "pause", forceFinalizeMode: "off", forceFinalizeRequireNoCiFailures: true, - atCapPolicy: "stop", + atCapPolicy: "ci_retry_once", atCapWaitMinutes: 30, atCapCiRetryMax: 3, forceMergeRequiresConfirmation: true, @@ -674,7 +674,7 @@ struct PrPathToMergeTab: View { } private var resolvedAtCapPolicy: String { - resolvedPipelineSettings.atCapPolicy ?? "stop" + resolvedPipelineSettings.atCapPolicy ?? "ci_retry_once" } private var resolvedAtCapWaitMinutes: Int { @@ -697,6 +697,38 @@ struct PrPathToMergeTab: View { resolvedPipelineSettings.earlyMergeOnGreen ?? true } + private var mergeMethodOptions: [PrPipelineOption] { + [PrPipelineOption(id: "repo_default", label: "Repository default")] + + PrMergeMethodOption.allCases.map { PrPipelineOption(id: $0.rawValue, label: $0.shortTitle) } + } + + private var conflictStrategyOptions: [PrPipelineOption] { + [ + PrPipelineOption(id: "pause", label: "Pause"), + PrPipelineOption(id: "rebase", label: "Rebase"), + PrPipelineOption(id: "merge", label: "Merge commit"), + PrPipelineOption(id: "auto", label: "Auto agent") + ] + } + + private var atCapPolicyOptions: [PrPipelineOption] { + [ + PrPipelineOption(id: "stop", label: "Stop"), + PrPipelineOption(id: "wait_for_ci", label: "Wait for CI"), + PrPipelineOption(id: "ci_retry_once", label: "Retry CI once"), + PrPipelineOption(id: "ci_retry_loop", label: "Retry CI fixes"), + PrPipelineOption(id: "force_merge", label: "Force merge") + ] + } + + private func selectAtCapPolicy(_ policy: String) { + if policy == "force_merge", resolvedForceMergeRequiresConfirmation { + confirmForceMergeAtCap = true + } else { + onSetAtCapPolicy(policy) + } + } + private var failedChecks: [PrCheck] { (snapshot?.checks ?? []).filter { check in check.status == "completed" && check.conclusion != nil && check.conclusion != "success" && check.conclusion != "neutral" && check.conclusion != "skipped" @@ -1220,27 +1252,17 @@ struct PrPathToMergeTab: View { Divider().overlay(ADEColor.textMuted.opacity(0.15)) - HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 8) { Text("Merge method") .font(.system(size: 13, weight: .medium)) .foregroundStyle(ADEColor.textPrimary) - Spacer(minLength: 0) - Menu { - Button("Repository default") { onSetPipelineMergeMethod("repo_default") } - ForEach(PrMergeMethodOption.allCases) { option in - Button(option.title) { onSetPipelineMergeMethod(option.rawValue) } - } - } label: { - HStack(spacing: 4) { - Text(pipelineMergeMethodLabel(settings.mergeMethod).lowercased()) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) - } - } - .disabled(!isLive) + PrPipelineOptionGrid( + options: mergeMethodOptions, + selectedId: settings.mergeMethod, + isEnabled: isLive, + accessibilityPrefix: "Merge method", + onSelect: onSetPipelineMergeMethod + ) } .padding(.vertical, 10) @@ -1286,27 +1308,17 @@ struct PrPathToMergeTab: View { Divider().overlay(ADEColor.textMuted.opacity(0.15)) - HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 8) { Text("Conflict strategy") .font(.system(size: 13, weight: .medium)) .foregroundStyle(ADEColor.textPrimary) - Spacer(minLength: 0) - Menu { - Button("Pause") { onSetPipelineConflictStrategy("pause") } - Button("Rebase") { onSetPipelineConflictStrategy("rebase") } - Button("Merge commit") { onSetPipelineConflictStrategy("merge") } - Button("Auto agent") { onSetPipelineConflictStrategy("auto") } - } label: { - HStack(spacing: 4) { - Text(pipelineConflictStrategyLabel(resolvedConflictStrategy).lowercased()) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) - } - } - .disabled(!isLive) + PrPipelineOptionGrid( + options: conflictStrategyOptions, + selectedId: resolvedConflictStrategy, + isEnabled: isLive, + accessibilityPrefix: "Conflict strategy", + onSelect: onSetPipelineConflictStrategy + ) } .padding(.vertical, 10) @@ -1329,33 +1341,17 @@ struct PrPathToMergeTab: View { Divider().overlay(ADEColor.textMuted.opacity(0.15)) - HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 8) { Text("At-cap policy") .font(.system(size: 13, weight: .medium)) .foregroundStyle(ADEColor.textPrimary) - Spacer(minLength: 0) - Menu { - Button("Stop") { onSetAtCapPolicy("stop") } - Button("Wait for CI") { onSetAtCapPolicy("wait_for_ci") } - Button("Retry CI fixes") { onSetAtCapPolicy("ci_retry_loop") } - Button("Force merge") { - if resolvedForceMergeRequiresConfirmation { - confirmForceMergeAtCap = true - } else { - onSetAtCapPolicy("force_merge") - } - } - } label: { - HStack(spacing: 4) { - Text(pipelineAtCapPolicyLabel(resolvedAtCapPolicy).lowercased()) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) - } - } - .disabled(!isLive) + PrPipelineOptionGrid( + options: atCapPolicyOptions, + selectedId: resolvedAtCapPolicy, + isEnabled: isLive, + accessibilityPrefix: "At-cap policy", + onSelect: selectAtCapPolicy + ) } .padding(.vertical, 10) @@ -1675,21 +1671,33 @@ struct PrPathReviewCommentRow: View { .background(Circle().fill(sourceTint.opacity(0.16))) .overlay(Circle().strokeBorder(sourceTint.opacity(0.32), lineWidth: 0.5)) - Menu { - Button("Mark fixed", systemImage: "checkmark") { onMarkFixed() } - Button("Dismiss", systemImage: "xmark") { onDismiss() } - Button("Escalate", systemImage: "exclamationmark.triangle") { onEscalate() } - } label: { - Image(systemName: "ellipsis") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) - .frame(width: 24, height: 24) + HStack(spacing: 4) { + prInlineAction(symbol: "checkmark", label: "Mark fixed", action: onMarkFixed) + prInlineAction(symbol: "xmark", label: "Dismiss", action: onDismiss) + prInlineAction(symbol: "exclamationmark.triangle", label: "Escalate", action: onEscalate) } .disabled(!isLive) } .padding(.horizontal, 12) .padding(.vertical, 10) } + + private func prInlineAction(symbol: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + // Visual chip stays compact (26pt circle), but the outer frame + contentShape + // expands the tap target to Apple's HIG-minimum 44pt. + Image(systemName: symbol) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(ADEColor.textSecondary) + .frame(width: 26, height: 26) + .background(Color.white.opacity(0.05), in: Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.10), lineWidth: 0.5)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + } } /// Small numbered stat chip used on the Path tab header row. @@ -1720,6 +1728,57 @@ private struct PrPathStatChip: View { } } +private struct PrPipelineOption: Identifiable { + let id: String + let label: String +} + +private struct PrPipelineOptionGrid: View { + let options: [PrPipelineOption] + let selectedId: String + let isEnabled: Bool + let accessibilityPrefix: String + let onSelect: (String) -> Void + + private let columns = [ + GridItem(.flexible(minimum: 0), spacing: 6), + GridItem(.flexible(minimum: 0), spacing: 6) + ] + + var body: some View { + LazyVGrid(columns: columns, spacing: 6) { + ForEach(options) { option in + let selected = option.id == selectedId + Button { + onSelect(option.id) + } label: { + Text(option.label) + .font(.system(size: 11.5, weight: selected ? .semibold : .medium)) + .foregroundStyle(selected ? Color.white : ADEColor.textSecondary) + .lineLimit(2) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.78) + .frame(maxWidth: .infinity, minHeight: 36) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(selected ? PrGlassPalette.purpleDeep.opacity(0.9) : Color.white.opacity(0.05)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(selected ? PrGlassPalette.purpleBright.opacity(0.45) : Color.white.opacity(0.08), lineWidth: 0.75) + ) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.45) + .accessibilityLabel("\(accessibilityPrefix): \(option.label)") + .accessibilityValue(selected ? "selected" : "not selected") + } + } + } +} + private func pipelineMergeMethodLabel(_ method: String) -> String { switch method { case "repo_default": return "Repository default" @@ -1752,6 +1811,7 @@ private func pipelineAtCapPolicyLabel(_ policy: String) -> String { switch policy { case "stop": return "Stop" case "wait_for_ci": return "Wait for CI" + case "ci_retry_once": return "Retry CI once" case "ci_retry_loop": return "Retry CI fixes" case "force_merge": return "Force merge" default: return policy.replacingOccurrences(of: "_", with: " ") @@ -1799,15 +1859,36 @@ struct PrIssueInventoryRow: View { ADEStatusPill(text: item.source.uppercased(), tint: ADEColor.accent) ADEStatusPill(text: item.state.replacingOccurrences(of: "_", with: " ").uppercased(), tint: tint) Spacer(minLength: 0) - Menu { - Button("Mark fixed") { onFixed() } - Button("Dismiss") { onDismiss() } - Button("Escalate") { onEscalate() } - } label: { - Image(systemName: "ellipsis.circle") - .frame(width: 32, height: 32) + // Inline 30pt glass chips with an expanded 44pt hit area so the row hits + // Apple's HIG minimum tap target without ballooning the visible buttons. + HStack(spacing: 6) { + Button(action: onFixed) { + Image(systemName: "checkmark") + .frame(width: 30, height: 30) + } + .buttonStyle(.glass) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + .accessibilityLabel("Mark fixed") + + Button(action: onDismiss) { + Image(systemName: "xmark") + .frame(width: 30, height: 30) + } + .buttonStyle(.glass) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + .accessibilityLabel("Dismiss") + + Button(action: onEscalate) { + Image(systemName: "exclamationmark.triangle") + .frame(width: 30, height: 30) + } + .buttonStyle(.glass) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + .accessibilityLabel("Escalate") } - .buttonStyle(.glass) .disabled(!isLive) } diff --git a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift index d8f3f916f..1e2ab66e7 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift @@ -2,6 +2,7 @@ import SwiftUI import UIKit struct PrDetailView: View { + @Environment(\.dismiss) private var dismiss @EnvironmentObject private var syncService: SyncService let prId: String let transitionNamespace: Namespace.ID? @@ -35,6 +36,8 @@ struct PrDetailView: View { @State private var aiResolution: AiResolutionState? @State private var isAiResolverBusy: Bool = false @State private var aiResolverSheetPresented: Bool = false + @State private var actionsSheetPresented: Bool = false + @State private var hasLoadedLiveSidecars = false /// True while a `prs.pathToMerge.start` or `prs.pathToMerge.stop` round-trip /// is in flight. Used to disable the convergence toggle and show a spinner. @State private var isPathToMergeBusy: Bool = false @@ -159,6 +162,7 @@ struct PrDetailView: View { prComputeMergeGate( status: snapshot?.status, checks: snapshot?.checks ?? [], + summaryChecksStatus: snapshot?.status?.checksStatus ?? currentPr.checksStatus, reviewThreadsUnresolved: unresolvedThreadCount, reviewsNeeded: reviewsNeeded, reviewsHave: reviewsHave, @@ -167,6 +171,13 @@ struct PrDetailView: View { ) } + private var showsStickyActionBar: Bool { + // The Activity tab has its own comment/reply composer at the end of the + // content. A global merge bar in the same bottom slot hides that composer + // on phone-sized screens. + selectedTab != .activity + } + private var behindBaseBy: Int { snapshot?.status?.behindBaseBy ?? 0 } @@ -344,6 +355,7 @@ struct PrDetailView: View { case .checks: PrChecksTab( checks: snapshot?.checks ?? [], + overallChecksStatus: snapshot?.status?.checksStatus ?? currentPr.checksStatus, actionRuns: actionRuns, deployments: deployments, canRerunChecks: canRerunChecks, @@ -386,73 +398,15 @@ struct PrDetailView: View { .scrollContentBackground(.hidden) .background(prLiquidGlassBackdrop().ignoresSafeArea()) .adeNavigationGlass() - .navigationTitle(currentPr.title) + .safeAreaInset(edge: .top, spacing: 0) { + detailNavigationHeader + } + .navigationTitle("") .navigationBarTitleDisplayMode(.inline) + .toolbar(.hidden, for: .navigationBar) .safeAreaInset(edge: .bottom) { - stickyActionBar - } - .toolbar { - ADERootToolbarLeadingItems() - ToolbarItem(placement: .topBarTrailing) { - Menu { - Button { - editorSheet = .title(currentPr.title) - } label: { - Label("Edit title", systemImage: "pencil") - } - .disabled(!canUpdateCurrentPrMetadata) - Button { - editorSheet = .body(snapshot?.detail?.body ?? "") - } label: { - Label("Edit description", systemImage: "text.alignleft") - } - .disabled(!canUpdateCurrentPrMetadata) - Button { - let labels = snapshot?.detail?.labels.map(\.name).joined(separator: ", ") ?? "" - editorSheet = .labels(labels) - } label: { - Label("Set labels", systemImage: "tag") - } - .disabled(!canUpdateCurrentPrMetadata) - Button { - editorSheet = .review - } label: { - Label("Submit review", systemImage: "checkmark.seal") - } - .disabled(!canRunPrActions) - if shouldShowCloseAction { - Button(role: .destructive, action: closeCurrentPr) { - Label("Close PR", systemImage: "xmark.circle") - } - .disabled(!canCloseCurrentPr) - } - if shouldShowReopenAction { - Button(action: reopenCurrentPr) { - Label("Reopen PR", systemImage: "arrow.counterclockwise") - } - .disabled(!canReopenCurrentPr) - } - Button(action: { openGitHub(urlString: currentPr.githubUrl) }) { - Label("Open in GitHub", systemImage: "arrow.up.right.square") - } - .disabled(!canOpenCurrentPrInGitHub) - Button { - UIPasteboard.general.string = currentPr.githubUrl - ADEHaptics.success() - actionMessage = "URL copied." - } label: { - Label("Copy URL", systemImage: "doc.on.doc") - } - .disabled(currentPr.githubUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - Button { - Task { await reload(refreshRemote: true) } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - } label: { - Label("Pull request actions", systemImage: "ellipsis.circle") - .labelStyle(.iconOnly) - } + if showsStickyActionBar { + stickyActionBar } } .refreshable { @@ -460,7 +414,10 @@ struct PrDetailView: View { } .adeNavigationZoomTransition(id: transitionNamespace == nil ? nil : "pr-container-\(prId)", in: transitionNamespace) .task(id: syncService.localStateRevision) { - await reload() + await reload(includeLiveSidecars: shouldFetchPrDetailLiveSidecars( + hasLoadedLiveSidecars: hasLoadedLiveSidecars, + refreshRemote: false + )) } .sheet(isPresented: $cleanupConfirmationPresented) { PrCleanupConfirmationSheet( @@ -509,6 +466,12 @@ struct PrDetailView: View { stopAiResolver() } } + .sheet(isPresented: $actionsSheetPresented) { + prActionsSheet + .presentationDetents([.height(560)]) + .presentationDragIndicator(.hidden) + .presentationBackground(.clear) + } .sheet(item: $editorSheet) { sheet in switch sheet { case .title(let title): @@ -565,6 +528,106 @@ struct PrDetailView: View { } } + private var detailNavigationHeader: some View { + HStack(spacing: 10) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .semibold)) + .frame(width: 38, height: 38) + } + .buttonStyle(.glass) + .accessibilityLabel("Back to PRs") + + VStack(alignment: .leading, spacing: 2) { + Text("#\(currentPr.githubPrNumber)") + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundStyle(prStateTint(currentPr.state)) + Text(currentPr.title) + .font(.headline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Pull request \(currentPr.githubPrNumber), \(currentPr.title)") + + Spacer(minLength: 0) + + Button { + actionsSheetPresented = true + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 17, weight: .semibold)) + .frame(width: 38, height: 38) + } + .buttonStyle(.glass) + .accessibilityLabel("Pull request actions") + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + .background { + ADEColor.pageBackground + .opacity(0.98) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) + } + } + + private var prActionsSheet: some View { + PrDetailActionsSheet( + canUpdateMetadata: canUpdateCurrentPrMetadata, + canRunActions: canRunPrActions, + shouldShowClose: shouldShowCloseAction, + shouldShowReopen: shouldShowReopenAction, + canClose: canCloseCurrentPr, + canReopen: canReopenCurrentPr, + canOpenGitHub: canOpenCurrentPrInGitHub, + hasGitHubUrl: !currentPr.githubUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + onDismiss: { actionsSheetPresented = false }, + onEditTitle: { + actionsSheetPresented = false + editorSheet = .title(currentPr.title) + }, + onEditDescription: { + actionsSheetPresented = false + editorSheet = .body(snapshot?.detail?.body ?? "") + }, + onSetLabels: { + let labels = snapshot?.detail?.labels.map(\.name).joined(separator: ", ") ?? "" + actionsSheetPresented = false + editorSheet = .labels(labels) + }, + onSubmitReview: { + actionsSheetPresented = false + editorSheet = .review + }, + onClose: { + actionsSheetPresented = false + closeCurrentPr() + }, + onReopen: { + actionsSheetPresented = false + reopenCurrentPr() + }, + onOpenGitHub: { + actionsSheetPresented = false + openGitHub(urlString: currentPr.githubUrl) + }, + onCopyUrl: { + actionsSheetPresented = false + UIPasteboard.general.string = currentPr.githubUrl + ADEHaptics.success() + actionMessage = "URL copied." + }, + onRefresh: { + actionsSheetPresented = false + Task { await reload(refreshRemote: true) } + } + ) + } + // MARK: - Hero private var heroCard: some View { @@ -857,11 +920,12 @@ struct PrDetailView: View { } } - // MARK: - Data loading (unchanged) + // MARK: - Data loading @MainActor - private func reload(refreshRemote: Bool = false) async { - let capabilitiesTask: Task? = isLive + private func reload(refreshRemote: Bool = false, includeLiveSidecars: Bool? = nil) async { + let shouldFetchLiveSidecars = isLive && (includeLiveSidecars ?? refreshRemote) + let capabilitiesTask: Task? = shouldFetchLiveSidecars ? Task { do { let mobileSnapshot = try await syncService.fetchPrMobileSnapshot() @@ -883,13 +947,13 @@ struct PrDetailView: View { } async let listItemsTask = syncService.fetchPullRequestListItems() async let snapshotTask = syncService.fetchPullRequestSnapshot(prId: prId) - let reviewThreadsTask = isLive ? Task { try? await syncService.fetchPullRequestReviewThreads(prId: prId) } : nil - let actionRunsTask = isLive ? Task { try? await syncService.fetchPullRequestActionRuns(prId: prId) } : nil - let activityTask = isLive ? Task { try? await syncService.fetchPullRequestActivity(prId: prId) } : nil - let deploymentsTask = isLive ? Task { try? await syncService.fetchPullRequestDeployments(prId: prId) } : nil - let aiSummaryTask = isLive ? Task { try? await syncService.fetchPullRequestAiSummary(prId: prId) } : nil - let issueInventoryTask = isLive ? Task { try? await syncService.fetchIssueInventory(prId: prId) } : nil - let pipelineSettingsTask = isLive ? Task { try? await syncService.fetchPipelineSettings(prId: prId) } : nil + let reviewThreadsTask = shouldFetchLiveSidecars ? Task { try? await syncService.fetchPullRequestReviewThreads(prId: prId) } : nil + let actionRunsTask = shouldFetchLiveSidecars ? Task { try? await syncService.fetchPullRequestActionRuns(prId: prId) } : nil + let activityTask = shouldFetchLiveSidecars ? Task { try? await syncService.fetchPullRequestActivity(prId: prId) } : nil + let deploymentsTask = shouldFetchLiveSidecars ? Task { try? await syncService.fetchPullRequestDeployments(prId: prId) } : nil + let aiSummaryTask = shouldFetchLiveSidecars ? Task { try? await syncService.fetchPullRequestAiSummary(prId: prId) } : nil + let issueInventoryTask = shouldFetchLiveSidecars ? Task { try? await syncService.fetchIssueInventory(prId: prId) } : nil + let pipelineSettingsTask = shouldFetchLiveSidecars ? Task { try? await syncService.fetchPipelineSettings(prId: prId) } : nil let listItems = try await listItemsTask pr = listItems.first(where: { $0.id == prId }) @@ -899,16 +963,24 @@ struct PrDetailView: View { // lane-PR list. This keeps the hero card from collapsing into // "Pull request / @unknown" placeholders without resurrecting legacy // cross-repo snapshot items. - if pr == nil && isLive { + if pr == nil && shouldFetchLiveSidecars { if let github = try? await syncService.fetchGitHubPullRequestSnapshot() { githubItem = repoScopedGitHubPullRequests(from: github) .first { $0.linkedPrId == prId || $0.id == prId } } } - reviewThreads = await reviewThreadsTask?.value ?? [] - actionRuns = await actionRunsTask?.value ?? [] - activityEvents = await activityTask?.value ?? [] - deployments = await deploymentsTask?.value ?? [] + if let reviewThreadsTask { + reviewThreads = await reviewThreadsTask.value ?? [] + } + if let actionRunsTask { + actionRuns = await actionRunsTask.value ?? [] + } + if let activityTask { + activityEvents = await activityTask.value ?? [] + } + if let deploymentsTask { + deployments = await deploymentsTask.value ?? [] + } if let summary = await aiSummaryTask?.value { aiSummary = summary } @@ -928,7 +1000,12 @@ struct PrDetailView: View { errorMessage = error.localizedDescription } - capabilities = await capabilitiesTask?.value + if shouldFetchLiveSidecars { + hasLoadedLiveSidecars = true + } + if let capabilitiesTask { + capabilities = await capabilitiesTask.value + } } @MainActor @@ -1152,7 +1229,7 @@ struct PrDetailView: View { conflictStrategy: "pause", forceFinalizeMode: "off", forceFinalizeRequireNoCiFailures: true, - atCapPolicy: "stop", + atCapPolicy: "ci_retry_once", atCapWaitMinutes: 30, atCapCiRetryMax: 3, forceMergeRequiresConfirmation: true, @@ -1445,6 +1522,158 @@ func prLiquidGlassBackdrop() -> some View { } } +private struct PrDetailActionsSheet: View { + let canUpdateMetadata: Bool + let canRunActions: Bool + let shouldShowClose: Bool + let shouldShowReopen: Bool + let canClose: Bool + let canReopen: Bool + let canOpenGitHub: Bool + let hasGitHubUrl: Bool + let onDismiss: () -> Void + let onEditTitle: () -> Void + let onEditDescription: () -> Void + let onSetLabels: () -> Void + let onSubmitReview: () -> Void + let onClose: () -> Void + let onReopen: () -> Void + let onOpenGitHub: () -> Void + let onCopyUrl: () -> Void + let onRefresh: () -> Void + + var body: some View { + ZStack { + prLiquidGlassBackdrop().ignoresSafeArea() + + VStack(spacing: 0) { + Capsule(style: .continuous) + .fill(Color.white.opacity(0.25)) + .frame(width: 36, height: 5) + .padding(.top, 8) + .padding(.bottom, 8) + + HStack { + Button("Done", action: onDismiss) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(PrGlassPalette.purpleBright) + Spacer(minLength: 0) + Text("Pull request actions") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color(red: 0xF0 / 255, green: 0xF0 / 255, blue: 0xF2 / 255)) + Spacer(minLength: 0) + Button(action: onRefresh) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 14, weight: .semibold)) + } + .accessibilityLabel("Refresh pull request") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.white.opacity(0.06)) + .frame(height: 0.5) + } + + ScrollView { + VStack(spacing: 10) { + PrDetailActionRow(title: "Edit title", symbol: "pencil", disabled: !canUpdateMetadata, action: onEditTitle) + PrDetailActionRow(title: "Edit description", symbol: "text.alignleft", disabled: !canUpdateMetadata, action: onEditDescription) + PrDetailActionRow(title: "Set labels", symbol: "tag", disabled: !canUpdateMetadata, action: onSetLabels) + PrDetailActionRow(title: "Submit review", symbol: "checkmark.seal", disabled: !canRunActions, action: onSubmitReview) + if shouldShowClose { + PrDetailActionRow( + title: "Close PR", + symbol: "xmark.circle", + tint: PrGlassPalette.danger, + disabled: !canClose, + isDestructive: true, + action: onClose + ) + } + if shouldShowReopen { + PrDetailActionRow( + title: "Reopen PR", + symbol: "arrow.counterclockwise", + disabled: !canReopen, + action: onReopen + ) + } + PrDetailActionRow( + title: "Open in GitHub", + symbol: "arrow.up.right.square", + disabled: !canOpenGitHub, + action: onOpenGitHub + ) + PrDetailActionRow( + title: "Copy URL", + symbol: "doc.on.doc", + disabled: !hasGitHubUrl, + action: onCopyUrl + ) + PrDetailActionRow(title: "Refresh", symbol: "arrow.clockwise", action: onRefresh) + } + .padding(16) + } + } + } + } +} + +private struct PrDetailActionRow: View { + let title: String + let symbol: String + let tint: Color + let disabled: Bool + let isDestructive: Bool + let action: () -> Void + + init( + title: String, + symbol: String, + tint: Color = PrGlassPalette.textSecondary, + disabled: Bool = false, + isDestructive: Bool = false, + action: @escaping () -> Void + ) { + self.title = title + self.symbol = symbol + self.tint = tint + self.disabled = disabled + self.isDestructive = isDestructive + self.action = action + } + + var body: some View { + let button = Button(role: isDestructive ? .destructive : nil, action: action) { + HStack(spacing: 12) { + Image(systemName: symbol) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 28, height: 28) + .background(tint.opacity(0.13), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(PrGlassPalette.textPrimary) + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(PrGlassPalette.textSecondary.opacity(0.7)) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .prGlassCard(cornerRadius: 14, shadow: false) + .opacity(disabled ? 0.45 : 1) + } + .buttonStyle(.plain) + .disabled(disabled) + .accessibilityLabel(title) + + button + } +} + private struct PrSingleLineEditSheet: View { @Environment(\.dismiss) private var dismiss let title: String diff --git a/apps/ios/ADE/Views/PRs/PrFiltersCard.swift b/apps/ios/ADE/Views/PRs/PrFiltersCard.swift index 691808e7a..857313b27 100644 --- a/apps/ios/ADE/Views/PRs/PrFiltersCard.swift +++ b/apps/ios/ADE/Views/PRs/PrFiltersCard.swift @@ -52,19 +52,27 @@ struct PrGitHubFiltersCard: View { @ViewBuilder private var sortMenu: some View { - Menu { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { ForEach(PrGitHubSortOption.allCases) { option in Button(action: { sortOption = option }) { Text(option.title) - if option == sortOption { - Image(systemName: "checkmark") - } + .font(.caption.weight(.semibold)) + .foregroundStyle(option == sortOption ? ADEColor.accent : ADEColor.textSecondary) + .lineLimit(1) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(option == sortOption ? ADEColor.accent.opacity(0.12) : ADEColor.surfaceBackground.opacity(0.45), in: Capsule()) + .overlay(Capsule().stroke(option == sortOption ? ADEColor.accent.opacity(0.4) : ADEColor.border.opacity(0.16), lineWidth: 0.5)) } + .buttonStyle(.plain) + .accessibilityLabel("Sort by \(option.title)") + .accessibilityValue(option == sortOption ? "Selected" : "") } - } label: { - sortMenuLabel } - .menuStyle(.borderlessButton) + } + .frame(maxWidth: 220) + .accessibilityElement(children: .contain) .accessibilityLabel("Sort pull requests") } diff --git a/apps/ios/ADE/Views/PRs/PrHelpers.swift b/apps/ios/ADE/Views/PRs/PrHelpers.swift index 0cf288ca9..7b93486a0 100644 --- a/apps/ios/ADE/Views/PRs/PrHelpers.swift +++ b/apps/ios/ADE/Views/PRs/PrHelpers.swift @@ -13,6 +13,14 @@ private let prIsoFallbackFormatter: ISO8601DateFormatter = { return formatter }() +private let prDateFormatterLock = NSLock() + +private let prParsedDateCache: NSCache = { + let cache = NSCache() + cache.countLimit = 512 + return cache +}() + private let prRelativeFormatter = RelativeDateTimeFormatter() private let prAbsoluteFormatter: DateFormatter = { @@ -22,17 +30,35 @@ private let prAbsoluteFormatter: DateFormatter = { return formatter }() +func normalizedPrBranchName(_ ref: String?) -> String { + var value = ref?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if value.hasPrefix("refs/heads/") { + value.removeFirst("refs/heads/".count) + } else if value.hasPrefix("refs/remotes/origin/") { + value.removeFirst("refs/remotes/origin/".count) + } else if value.hasPrefix("origin/") { + value.removeFirst("origin/".count) + } + return value +} + func matchedLaneForExactBranch(_ headBranch: String?, lanes: [LaneSummary]) -> LaneSummary? { - guard let headBranch = headBranch?.trimmingCharacters(in: .whitespacesAndNewlines), - !headBranch.isEmpty + let normalizedHead = normalizedPrBranchName(headBranch) + guard !normalizedHead.isEmpty else { return nil } + // Git refs are case-sensitive; matching case-insensitively could pick the wrong + // lane when two branches differ only by case (e.g. Feature-X vs feature-x). return lanes.first { lane in - lane.branchRef.caseInsensitiveCompare(headBranch) == .orderedSame + normalizedPrBranchName(lane.branchRef) == normalizedHead } } +func shouldFetchPrDetailLiveSidecars(hasLoadedLiveSidecars: Bool, refreshRemote: Bool) -> Bool { + refreshRemote || !hasLoadedLiveSidecars +} + func parsePullRequestPatch(_ patch: String) -> [PrDiffDisplayLine] { guard !patch.isEmpty else { return [] } @@ -490,7 +516,19 @@ func titleCase(_ raw: String) -> String { func prParsedDate(_ iso: String?) -> Date? { guard let iso, !iso.isEmpty else { return nil } - return prIsoFormatter.date(from: iso) ?? prIsoFallbackFormatter.date(from: iso) + let key = iso as NSString + if let cached = prParsedDateCache.object(forKey: key) { + return cached as Date + } + + prDateFormatterLock.lock() + let parsed = prIsoFormatter.date(from: iso) ?? prIsoFallbackFormatter.date(from: iso) + prDateFormatterLock.unlock() + + if let parsed { + prParsedDateCache.setObject(parsed as NSDate, forKey: key) + } + return parsed } func prRelativeTime(_ iso: String?) -> String { diff --git a/apps/ios/ADE/Views/PRs/PrMergeGateCard.swift b/apps/ios/ADE/Views/PRs/PrMergeGateCard.swift index af031455c..c36893217 100644 --- a/apps/ios/ADE/Views/PRs/PrMergeGateCard.swift +++ b/apps/ios/ADE/Views/PRs/PrMergeGateCard.swift @@ -166,19 +166,25 @@ struct PrMergeGateInfo: Equatable { func prComputeMergeGate( status: PrStatus?, checks: [PrCheck], + summaryChecksStatus: String? = nil, reviewThreadsUnresolved: Int, reviewsNeeded: Int, reviewsHave: Int, capabilities: PrActionCapabilities?, isDraft: Bool = false ) -> PrMergeGateInfo { - let failing = checks.filter { check in + let normalizedSummaryChecksStatus = summaryChecksStatus?.lowercased() + let summarySaysFailing = checks.isEmpty && ["failing", "failure", "failed"].contains(normalizedSummaryChecksStatus ?? "") + let summarySaysPending = checks.isEmpty && ["pending", "running", "in_progress"].contains(normalizedSummaryChecksStatus ?? "") + let summarySaysPassing = checks.isEmpty && ["passing", "success", "passed"].contains(normalizedSummaryChecksStatus ?? "") + let failingChecks = checks.filter { check in check.status == "completed" && check.conclusion != nil && check.conclusion != "success" && check.conclusion != "neutral" && check.conclusion != "skipped" }.count + let failing = failingChecks + (summarySaysFailing ? 1 : 0) let conflicts = status?.mergeConflicts ?? false let blockedReason = capabilities?.mergeBlockedReason?.trimmingCharacters(in: .whitespacesAndNewlines) let hasBlockedReason = !(blockedReason?.isEmpty ?? true) @@ -201,8 +207,10 @@ func prComputeMergeGate( if conflicts || failing > 0 || hasBlockedReason { var parts: [String] = [] - if failing > 0 { - parts.append("\(failing) failing check\(failing == 1 ? "" : "s")") + if summarySaysFailing { + parts.append("checks failing") + } else if failingChecks > 0 { + parts.append("\(failingChecks) failing check\(failingChecks == 1 ? "" : "s")") } if conflicts { parts.append("merge conflicts") @@ -221,6 +229,11 @@ func prComputeMergeGate( return PrMergeGateInfo(tone: .red, subline: subline, target: target) } + if status == nil && checks.isEmpty && !summarySaysPassing { + let subline = summarySaysPending ? "Checks pending" : "Waiting for synced PR status" + return PrMergeGateInfo(tone: .amber, subline: subline, target: .overview) + } + if behind > 0 || !mergeable { let baseLabel = "base" let subline: String diff --git a/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift b/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift index 5eb19f2e2..04c985374 100644 --- a/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift +++ b/apps/ios/ADE/Views/PRs/PrWorkflowCards.swift @@ -32,6 +32,37 @@ private enum WorkflowLandingConfirmation { } } +private struct PrMergeStrategyOptions: View { + @Binding var selection: PrMergeMethodOption + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(PrMergeMethodOption.allCases) { option in + ADEOptionButton( + title: option.shortTitle, + subtitle: option.description, + systemImage: icon(for: option), + isSelected: selection == option, + tint: PrGlassPalette.purple + ) { + selection = option + } + } + } + } + + private func icon(for option: PrMergeMethodOption) -> String { + switch option { + case .squash: + return "square.stack.3d.down.right.fill" + case .merge: + return "arrow.triangle.merge" + case .rebase: + return "arrow.triangle.2.circlepath" + } + } +} + // MARK: - Legacy cards (unchanged surface, lightly restyled) struct IntegrationWorkflowCard: View { @@ -142,13 +173,7 @@ struct QueueWorkflowCard: View { } if let activeEntry { - Picker("Merge strategy", selection: $mergeMethod) { - ForEach(PrMergeMethodOption.allCases) { option in - Text(option.shortTitle).tag(option) - } - } - .pickerStyle(.menu) - .adeInsetField() + PrMergeStrategyOptions(selection: $mergeMethod) Button("Land active PR") { landingConfirmation = .activePr(prId: activeEntry.prId) @@ -394,13 +419,7 @@ struct PrMobileWorkflowCardView: View { } if let activePrId = card.activePrId { - Picker("Merge strategy", selection: $mergeMethod) { - ForEach(PrMergeMethodOption.allCases) { option in - Text(option.shortTitle).tag(option) - } - } - .pickerStyle(.menu) - .adeInsetField() + PrMergeStrategyOptions(selection: $mergeMethod) HStack(spacing: 10) { Button("Land active PR") { @@ -601,14 +620,25 @@ struct PrMobileWorkflowCardView: View { } Spacer(minLength: 0) if lane.outcome != "clean", let proposalId = card.proposalId { - Menu { - Button("Resolve conflicts") { onResolveIntegrationLane(proposalId, lane.laneId) } - Button("Recheck") { onRecheckIntegrationLane(proposalId, lane.laneId) } - } label: { - Image(systemName: "ellipsis.circle") - .frame(width: 32, height: 32) + HStack(spacing: 6) { + Button { + onResolveIntegrationLane(proposalId, lane.laneId) + } label: { + Image(systemName: "wrench.and.screwdriver") + .frame(width: 30, height: 30) + } + .buttonStyle(.glass) + .accessibilityLabel("Resolve conflicts for \(lane.laneName)") + + Button { + onRecheckIntegrationLane(proposalId, lane.laneId) + } label: { + Image(systemName: "arrow.clockwise") + .frame(width: 30, height: 30) + } + .buttonStyle(.glass) + .accessibilityLabel("Recheck \(lane.laneName)") } - .buttonStyle(.glass) .disabled(!isLive) } } diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index 3b2f4f2df..2f00e0124 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -36,6 +36,7 @@ struct PRsTabView: View { @State private var rootActionTask: Task? @State private var githubDetailRequest: PrGitHubLaneLinkRequest? @State private var laneLinkRequest: PrGitHubLaneLinkRequest? + @State private var pendingLaneLinkRequest: PrGitHubLaneLinkRequest? @SceneStorage("ade.prs.rootSurface") private var rootSurfaceRawValue = PrRootSurface.github.rawValue @SceneStorage("ade.prs.workflowFilter") private var workflowFilterRawValue = PrWorkflowKindFilter.all.rawValue @SceneStorage("ade.prs.githubStatusFilter") private var githubStatusFilterRawValue = PrGitHubStatusFilter.open.rawValue @@ -425,15 +426,13 @@ struct PRsTabView: View { PrStackSheet(groupId: presentation.id, groupName: presentation.groupName) .environmentObject(syncService) } - .sheet(item: $githubDetailRequest) { request in + .sheet(item: $githubDetailRequest, onDismiss: presentPendingLaneLinkRequest) { request in PrGitHubReadDetailSheet( item: request.item, canLink: canLinkGitHubPullRequests, onLink: { + pendingLaneLinkRequest = request githubDetailRequest = nil - DispatchQueue.main.async { - laneLinkRequest = request - } }, onOpenGitHub: { openGitHub(urlString: request.item.githubUrl) @@ -465,6 +464,12 @@ struct PRsTabView: View { } } + private func presentPendingLaneLinkRequest() { + guard let request = pendingLaneLinkRequest else { return } + pendingLaneLinkRequest = nil + laneLinkRequest = request + } + /// Count shown in the hero-header chip — matches whichever surface the user /// is currently viewing, so we don't flash "GitHub 42" in the title while /// the workflows surface is showing 3 cards. @@ -1311,6 +1316,9 @@ struct PRsTabView: View { onCreateIntegration: handleCreateIntegrationPr ) .environmentObject(syncService) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .presentationContentInteraction(.scrolls) } private func handleCreateSinglePr( @@ -1720,8 +1728,15 @@ private struct PrLaneLinkSheet: View { @State private var selectedLaneId = "" private var availableLanes: [LaneSummary] { - lanes - .filter { $0.archivedAt == nil && $0.laneType != "primary" } + let expectedBranch = normalizedPrBranchName(item.headBranch) + return lanes + .filter { lane in + guard lane.archivedAt == nil, lane.laneType != "primary" else { return false } + guard !expectedBranch.isEmpty else { return false } + // Git refs are case-sensitive — case-insensitive matching can offer or + // preselect the wrong lane when two branches differ only by case. + return normalizedPrBranchName(lane.branchRef) == expectedBranch + } .sorted { lhs, rhs in lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending } } @@ -1795,9 +1810,10 @@ private struct PrLaneLinkSheet: View { Image(systemName: "tray") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(PrsGlass.textMuted) - Text("No eligible lanes are available to link.") + Text(emptyLaneMessage) .font(.system(size: 12)) .foregroundStyle(PrsGlass.textSecondary) + .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) } .padding(14) @@ -1840,7 +1856,12 @@ private struct PrLaneLinkSheet: View { } .onAppear { if selectedLaneId.isEmpty { - selectedLaneId = item.linkedLaneId ?? exactBranchMatchedLane?.id ?? "" + // Only honor linkedLaneId if it is still in the visible option set; otherwise + // the primary action stays enabled with no rendered selection. + let linkedIfVisible = item.linkedLaneId.flatMap { id in + availableLanes.contains(where: { $0.id == id }) ? id : nil + } + selectedLaneId = linkedIfVisible ?? exactBranchMatchedLane?.id ?? availableLanes.first?.id ?? "" } } } @@ -1853,8 +1874,18 @@ private struct PrLaneLinkSheet: View { if let exactBranchMatchedLane, selectedLaneId == exactBranchMatchedLane.id { return "Preselected because the PR branch matches \(exactBranchMatchedLane.branchRef)." } + if !canLink { + return "Reconnect before linking this PR." + } + let expectedBranch = normalizedPrBranchName(item.headBranch) + if expectedBranch.isEmpty { + return "This PR is missing a head branch, so ADE cannot choose a lane." + } + if availableLanes.isEmpty { + return "Create or import a lane on \(expectedBranch), then refresh PRs." + } if selectedLaneId.isEmpty { - return "No lane was preselected because the PR branch does not exactly match an ADE lane." + return "Choose the lane on \(expectedBranch)." } return "Confirm this lane before linking; ADE will attach this GitHub PR to the selected lane." } @@ -1862,6 +1893,14 @@ private struct PrLaneLinkSheet: View { private var laneSelectionTint: Color { selectedLaneId.isEmpty ? PrGlassPalette.warning : PrsGlass.textSecondary } + + private var emptyLaneMessage: String { + let expectedBranch = normalizedPrBranchName(item.headBranch) + guard !expectedBranch.isEmpty else { + return "This PR does not include a head branch." + } + return "No ADE lane is on \(expectedBranch)." + } } // MARK: - File-private liquid-glass primitives (sheets) diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index 5d87dd600..d678f2346 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -1,23 +1,45 @@ +import Combine import SwiftUI struct ConnectionSettingsView: View { - @EnvironmentObject private var syncService: SyncService + let syncService: SyncService + @Environment(\.dismiss) private var dismiss + @AppStorage("ade.colorScheme") private var colorSchemeRaw: String = ADEColorSchemeChoice.system.rawValue + @StateObject private var presentationModel = SettingsConnectionPresentationModel() @State private var presentedSheet: SettingsPairSheetRoute? @State private var pinPreset: PinPreset? + private var colorSchemeChoice: ADEColorSchemeChoice { + ADEColorSchemeChoice(rawValue: colorSchemeRaw) ?? .system + } + var body: some View { NavigationStack { ScrollView { LazyVStack(spacing: 18) { - SettingsConnectionHeader() - .environmentObject(syncService) + SettingsConnectionHeader( + snapshot: presentationModel.connectionSnapshot, + onDisconnect: { + syncService.disconnect() + }, + onReconnect: { preferTailnet in + Task { + await syncService.reconnectIfPossible( + userInitiated: true, + preferTailnet: preferTailnet + ) + } + } + ) .padding(.horizontal, 16) .padding(.top, 4) - SettingsPairingSection(presentedSheet: $presentedSheet) - .environmentObject(syncService) + SettingsPairingSection( + snapshot: presentationModel.pairingSnapshot, + presentedSheet: $presentedSheet + ) .padding(.horizontal, 16) SettingsTailscaleHelpSection() @@ -28,7 +50,7 @@ struct ConnectionSettingsView: View { syncService.uploadNotificationPrefs(prefs) }, onSendTestPush: { - syncService.sendTestPush() + await syncService.sendTestPush() } ) .padding(.horizontal, 16) @@ -36,8 +58,7 @@ struct ConnectionSettingsView: View { SettingsAppearanceSection() .padding(.horizontal, 16) - SettingsDiagnosticsSection() - .environmentObject(syncService) + SettingsDiagnosticsSection(snapshot: presentationModel.diagnosticsSnapshot) .padding(.horizontal, 16) Spacer(minLength: 20) @@ -65,6 +86,10 @@ struct ConnectionSettingsView: View { SettingsPinSheet(preset: preset, syncService: syncService) .presentationDetents([.large]) } + .preferredColorScheme(colorSchemeChoice.preferredColorScheme) + .onAppear { + presentationModel.bind(to: syncService) + } } } @@ -89,6 +114,149 @@ struct ConnectionSettingsView: View { } } +struct SettingsConnectionSnapshot: Equatable { + var health: SyncConnectionHealth + var connectionState: RemoteConnectionState + var hostDisplayName: String? + var pendingHostName: String? + var routeLine: String? + var canReconnectToSavedHost: Bool + var savedReconnectPrefersTailnet: Bool + var errorMessage: String? +} + +struct SettingsPairingSnapshot: Equatable { + var discoveredHostCount = 0 + var savedReconnectHostCount = 0 +} + +struct SettingsDiagnosticsSnapshot: Equatable { + var pairedMachineIdentity: String? + var lastSyncDescription: String? + var deviceIdentity: String? +} + +@MainActor +private final class SettingsConnectionPresentationModel: ObservableObject { + @Published private(set) var connectionSnapshot = SettingsConnectionSnapshot( + health: syncConnectionHealth( + connectionState: .disconnected, + prefersReducedSyncLoad: false, + lastError: nil + ), + connectionState: .disconnected, + hostDisplayName: nil, + pendingHostName: nil, + routeLine: nil, + canReconnectToSavedHost: false, + savedReconnectPrefersTailnet: false, + errorMessage: nil + ) + @Published private(set) var pairingSnapshot = SettingsPairingSnapshot() + @Published private(set) var diagnosticsSnapshot = SettingsDiagnosticsSnapshot() + + private weak var boundService: SyncService? + private var cancellable: AnyCancellable? + + func bind(to syncService: SyncService) { + guard boundService !== syncService else { + refresh(from: syncService) + return + } + + boundService = syncService + refresh(from: syncService) + cancellable = syncService.objectWillChange + .throttle(for: .milliseconds(250), scheduler: RunLoop.main, latest: true) + .sink { [weak self, weak syncService] _ in + Task { @MainActor in + guard let syncService else { return } + self?.refresh(from: syncService) + } + } + } + + private func refresh(from syncService: SyncService) { + let activeProfile = syncService.activeHostProfile + let savedReconnectHost = syncService.savedReconnectHost + let health = syncService.connectionHealth + let hostDisplayName = Self.trimmedNonEmpty(syncService.hostName) ?? Self.trimmedNonEmpty(activeProfile?.hostName) + let address = Self.trimmedNonEmpty(syncService.currentAddress) ?? Self.trimmedNonEmpty(activeProfile?.lastSuccessfulAddress) + let displayedDiscovery = syncDiscoveredHostsForDisplay( + savedHosts: syncService.savedReconnectHosts, + liveHosts: syncService.discoveredHosts + ) + + update( + &connectionSnapshot, + to: SettingsConnectionSnapshot( + health: health, + connectionState: syncService.connectionState, + hostDisplayName: hostDisplayName, + pendingHostName: health.transport == .connecting || health.transport == .unreachable ? hostDisplayName : nil, + routeLine: Self.routeLine(address: address, port: activeProfile?.port), + canReconnectToSavedHost: syncService.canReconnectToSavedHost, + savedReconnectPrefersTailnet: savedReconnectHost?.tailscaleAddress != nil, + errorMessage: health.transport == .unreachable ? health.lastFailureMessage : nil + ) + ) + + update( + &pairingSnapshot, + to: SettingsPairingSnapshot( + discoveredHostCount: displayedDiscovery.liveHosts.count, + savedReconnectHostCount: displayedDiscovery.savedHosts.count + ) + ) + + update( + &diagnosticsSnapshot, + to: SettingsDiagnosticsSnapshot( + pairedMachineIdentity: activeProfile?.hostIdentity.map(Self.shortIdentity), + lastSyncDescription: syncService.lastSyncAt.map(Self.relativeSyncDescription), + deviceIdentity: activeProfile?.pairedDeviceId.map(Self.shortIdentity) + ) + ) + } + + private func update(_ value: inout Value, to nextValue: Value) { + guard value != nextValue else { return } + value = nextValue + } + + private static func routeLine(address: String?, port: Int?) -> String? { + guard let address else { return nil } + let prefix = syncIsTailscaleIPv4Address(address) ? "Tailscale " : "" + if let port { + return "\(prefix)\(address) · :\(port)" + } + return "\(prefix)\(address)" + } + + private static func trimmedNonEmpty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } + + private static func relativeSyncDescription(_ date: Date) -> String { + let age = abs(Date().timeIntervalSince(date)) + guard age >= 5 else { return "just now" } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: Date()) + } + + private static func shortIdentity(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 12 else { return trimmed } + let prefix = trimmed.prefix(6) + let suffix = trimmed.suffix(4) + return "\(prefix)…\(suffix)" + } +} + private struct SettingsTailscaleHelpSection: View { var body: some View { VStack(alignment: .leading, spacing: 10) { diff --git a/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift b/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift index f95be9b24..38b8187aa 100644 --- a/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift +++ b/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift @@ -10,17 +10,19 @@ import UserNotifications /// dependencies. struct NotificationsCenterView: View { var onPreferencesChanged: (NotificationPreferences) -> Void - var onSendTestPush: () -> Void + var onSendTestPush: () async -> SyncSendTestPushResult @State private var prefs: NotificationPreferences @State private var authStatus: UNAuthorizationStatus = .notDetermined @State private var hasDeviceToken: Bool = false @State private var isRequestingAuthorization: Bool = false + @State private var isSendingTestPush: Bool = false + @State private var testPushResult: SyncSendTestPushResult? init( initialPreferences: NotificationPreferences = NotificationPreferences(), onPreferencesChanged: @escaping (NotificationPreferences) -> Void, - onSendTestPush: @escaping () -> Void + onSendTestPush: @escaping () async -> SyncSendTestPushResult ) { self.onPreferencesChanged = onPreferencesChanged self.onSendTestPush = onSendTestPush @@ -129,7 +131,13 @@ struct NotificationsCenterView: View { VStack(alignment: .leading, spacing: 8) { sendTestPushButton - if !canSendTestPush { + if let testPushResult { + Text(testPushResult.message) + .font(.caption) + .foregroundStyle(testPushResult.ok ? ADEColor.success : ADEColor.danger) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 2) + } else if !canSendTestPush { Text("Enable notifications and register this device before sending a test push.") .font(.caption) .foregroundStyle(ADEColor.textSecondary) @@ -381,8 +389,8 @@ struct NotificationsCenterView: View { // MARK: - Send test push private var sendTestPushButton: some View { - Button(action: onSendTestPush) { - Text("Send test push") + Button(action: sendTestPush) { + Text(isSendingTestPush ? "Sending test push..." : "Send test push") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(ADEColor.purpleAccent) .frame(maxWidth: .infinity) @@ -393,8 +401,8 @@ struct NotificationsCenterView: View { ) } .buttonStyle(.plain) - .disabled(!canSendTestPush) - .opacity(canSendTestPush ? 1 : 0.45) + .disabled(!canSendTestPush || isSendingTestPush) + .opacity(canSendTestPush ? (isSendingTestPush ? 0.65 : 1) : 0.45) .accessibilityHint( canSendTestPush ? "Ask the paired machine to send a test notification to this device" @@ -451,6 +459,19 @@ struct NotificationsCenterView: View { Task { await refreshAuthorizationStatus() } } + private func sendTestPush() { + guard canSendTestPush, !isSendingTestPush else { return } + isSendingTestPush = true + testPushResult = nil + Task { + let result = await onSendTestPush() + await MainActor.run { + testPushResult = result + isSendingTestPush = false + } + } + } + private func openSystemSettings() { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) diff --git a/apps/ios/ADE/Views/Settings/PerSessionOverrideView.swift b/apps/ios/ADE/Views/Settings/PerSessionOverrideView.swift index 9bc8ab261..bc3164bcf 100644 --- a/apps/ios/ADE/Views/Settings/PerSessionOverrideView.swift +++ b/apps/ios/ADE/Views/Settings/PerSessionOverrideView.swift @@ -1,5 +1,22 @@ import SwiftUI +func notificationHasActiveOverride(_ override: SessionNotificationOverride) -> Bool { + override.muted || override.awaitingInputOnly +} + +func notificationStaleOverrideIds( + overrides: [String: SessionNotificationOverride], + agents: [AgentSnapshot] +) -> [String] { + let agentIds = Set(agents.map(\.sessionId)) + return overrides + .filter { sessionId, override in + !agentIds.contains(sessionId) && notificationHasActiveOverride(override) + } + .map(\.key) + .sorted() +} + /// Lists every session we know about from the shared workspace snapshot and /// lets the user mute a session or restrict it to awaiting-input alerts only. /// @@ -11,8 +28,10 @@ struct PerSessionOverrideView: View { @State private var agents: [AgentSnapshot] = [] var body: some View { + let staleOverrideIds = notificationStaleOverrideIds(overrides: overrides, agents: agents) + Form { - if agents.isEmpty { + if agents.isEmpty && staleOverrideIds.isEmpty { Section { emptyState } @@ -21,6 +40,9 @@ struct PerSessionOverrideView: View { ForEach(agents) { agent in sessionRow(for: agent) } + ForEach(staleOverrideIds, id: \.self) { sessionId in + staleOverrideRow(for: sessionId) + } } footer: { Text("Overrides only affect push notifications — the session itself keeps running.") .font(.caption) @@ -78,6 +100,48 @@ struct PerSessionOverrideView: View { .padding(.vertical, 4) } + @ViewBuilder + private func staleOverrideRow(for sessionId: String) -> some View { + let override = overrides[sessionId] ?? SessionNotificationOverride() + + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Circle() + .fill(ADEColor.textMuted) + .frame(width: 10, height: 10) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(shortSessionId(sessionId)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Text("Saved override") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + Spacer() + } + + Toggle(isOn: mutedBinding(sessionId, fallback: override)) { + Label("Mute this session", systemImage: "bell.slash") + .labelStyle(.titleAndIcon) + .font(.caption) + } + .tint(ADEColor.purpleAccent) + .accessibilityHint("Silence all push alerts from this saved session") + + Toggle(isOn: awaitingOnlyBinding(sessionId, fallback: override)) { + Label("Awaiting-input only", systemImage: "hand.raised") + .labelStyle(.titleAndIcon) + .font(.caption) + } + .tint(ADEColor.purpleAccent) + .disabled(override.muted) + .accessibilityHint("Only alert when this saved session pauses for your input") + } + .padding(.vertical, 4) + } + private var emptyState: some View { VStack(alignment: .center, spacing: 10) { Image(systemName: "tray") @@ -112,7 +176,7 @@ struct PerSessionOverrideView: View { var current = overrides[sessionId] ?? fallback current.muted = newValue if newValue { current.awaitingInputOnly = false } - overrides[sessionId] = current + writeOverride(current, for: sessionId) } ) } @@ -123,11 +187,24 @@ struct PerSessionOverrideView: View { set: { newValue in var current = overrides[sessionId] ?? fallback current.awaitingInputOnly = newValue - overrides[sessionId] = current + writeOverride(current, for: sessionId) } ) } + private func writeOverride(_ override: SessionNotificationOverride, for sessionId: String) { + if notificationHasActiveOverride(override) { + overrides[sessionId] = override + } else { + overrides.removeValue(forKey: sessionId) + } + } + + private func shortSessionId(_ sessionId: String) -> String { + guard sessionId.count > 12 else { return sessionId } + return "\(sessionId.prefix(8))…\(sessionId.suffix(4))" + } + private func statusLine(for agent: AgentSnapshot) -> String { if agent.awaitingInput { return "Awaiting your reply" diff --git a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift index c605c251f..b9892c0c4 100644 --- a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift +++ b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift @@ -1,13 +1,16 @@ import SwiftUI struct SettingsConnectionHeader: View { - @EnvironmentObject private var syncService: SyncService @Environment(\.accessibilityReduceMotion) private var reduceMotion + let snapshot: SettingsConnectionSnapshot + let onDisconnect: () -> Void + let onReconnect: (Bool) -> Void + @State private var pulsing = false private var health: SyncConnectionHealth { - syncService.connectionHealth + snapshot.health } var body: some View { @@ -29,13 +32,20 @@ struct SettingsConnectionHeader: View { } } Spacer(minLength: 0) - SettingsConnectionQuickAction() - .environmentObject(syncService) + SettingsConnectionQuickAction( + connectionState: snapshot.connectionState, + canReconnectToSavedHost: snapshot.canReconnectToSavedHost, + savedReconnectPrefersTailnet: snapshot.savedReconnectPrefersTailnet, + onDisconnect: onDisconnect, + onReconnect: onReconnect + ) } if health.transport.isConnected { - SettingsConnectedHostDetails() - .environmentObject(syncService) + SettingsConnectedHostDetails( + hostDisplayName: snapshot.hostDisplayName, + routeLine: snapshot.routeLine + ) } else if let hostName = pendingHostName { Text(pendingDescription(hostName: hostName)) .font(.subheadline) @@ -113,23 +123,11 @@ struct SettingsConnectionHeader: View { } private var errorMessage: String? { - health.lastFailureMessage + snapshot.errorMessage } private var pendingHostName: String? { - switch health.transport { - case .connecting, .unreachable: - return displayHostName - default: - return nil - } - } - - private var displayHostName: String? { - if let name = syncService.hostName, !name.isEmpty { - return name - } - return syncService.activeHostProfile?.hostName + snapshot.pendingHostName } private var stateDetailLine: String? { @@ -138,7 +136,7 @@ struct SettingsConnectionHeader: View { if health.load == .strained { return "Live · machine responding slowly" } - if syncService.connectionState == .syncing { + if snapshot.connectionState == .syncing { return "Live · syncing changes" } return "Live · ready to sync" @@ -147,10 +145,10 @@ struct SettingsConnectionHeader: View { case .unreachable: return "Unable to reach your machine" case .disconnected: - if syncService.savedReconnectHost?.tailscaleAddress != nil { + if snapshot.savedReconnectPrefersTailnet { return "Saved machine · Tailscale route ready" } - if syncService.canReconnectToSavedHost { + if snapshot.canReconnectToSavedHost { return "Saved machine · not connected" } return "No paired machine" @@ -170,11 +168,12 @@ struct SettingsConnectionHeader: View { } private struct SettingsConnectedHostDetails: View { - @EnvironmentObject private var syncService: SyncService + let hostDisplayName: String? + let routeLine: String? var body: some View { VStack(alignment: .leading, spacing: 6) { - if let hostName = displayHostName { + if let hostName = hostDisplayName { Text(hostName) .font(.title3.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) @@ -189,38 +188,24 @@ private struct SettingsConnectedHostDetails: View { } } } - - private var displayHostName: String? { - if let name = syncService.hostName, !name.isEmpty { - return name - } - return syncService.activeHostProfile?.hostName - } - - private var routeLine: String? { - guard let address = syncService.currentAddress ?? syncService.activeHostProfile?.lastSuccessfulAddress else { - return nil - } - let prefix = syncIsTailscaleIPv4Address(address) ? "Tailscale " : "" - if let port = syncService.activeHostProfile?.port { - return "\(prefix)\(address) · :\(port)" - } - return "\(prefix)\(address)" - } } private struct SettingsConnectionQuickAction: View { - @EnvironmentObject private var syncService: SyncService + let connectionState: RemoteConnectionState + let canReconnectToSavedHost: Bool + let savedReconnectPrefersTailnet: Bool + let onDisconnect: () -> Void + let onReconnect: (Bool) -> Void var body: some View { - switch syncService.connectionState { + switch connectionState { case .connected, .syncing: ADEGlassActionButton( title: "Disconnect", symbol: "power", tint: ADEColor.textSecondary ) { - syncService.disconnect() + onDisconnect() } .accessibilityLabel("Disconnect from machine") @@ -237,18 +222,13 @@ private struct SettingsConnectionQuickAction: View { .glassEffect() case .error, .disconnected: - if syncService.canReconnectToSavedHost { + if canReconnectToSavedHost { ADEGlassActionButton( title: "Reconnect", symbol: "arrow.clockwise", tint: ADEColor.purpleAccent ) { - Task { - await syncService.reconnectIfPossible( - userInitiated: true, - preferTailnet: syncService.savedReconnectHost?.tailscaleAddress != nil - ) - } + onReconnect(savedReconnectPrefersTailnet) } .accessibilityLabel("Reconnect to saved machine") } diff --git a/apps/ios/ADE/Views/Settings/SettingsDiagnosticsSection.swift b/apps/ios/ADE/Views/Settings/SettingsDiagnosticsSection.swift index 6aff9ef5a..8d912c12e 100644 --- a/apps/ios/ADE/Views/Settings/SettingsDiagnosticsSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsDiagnosticsSection.swift @@ -1,7 +1,7 @@ import SwiftUI struct SettingsDiagnosticsSection: View { - @EnvironmentObject private var syncService: SyncService + let snapshot: SettingsDiagnosticsSnapshot var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -14,27 +14,27 @@ struct SettingsDiagnosticsSection: View { value: Self.appVersionString ) - if let identity = syncService.activeHostProfile?.hostIdentity { + if let identity = snapshot.pairedMachineIdentity { SettingsDetailRow( symbol: "desktopcomputer.and.arrow.down", label: "Paired machine", - value: Self.shortIdentity(identity) + value: identity ) } - if let lastSync = syncService.lastSyncAt { + if let lastSync = snapshot.lastSyncDescription { SettingsDetailRow( symbol: "clock.arrow.circlepath", label: "Last sync", - value: Self.relativeDate(lastSync) + value: lastSync ) } - if let deviceId = syncService.activeHostProfile?.pairedDeviceId { + if let deviceId = snapshot.deviceIdentity { SettingsDetailRow( symbol: "iphone", label: "This device", - value: Self.shortIdentity(deviceId) + value: deviceId ) } } @@ -47,20 +47,6 @@ struct SettingsDiagnosticsSection: View { let build = info?["CFBundleVersion"] as? String ?? "–" return "\(shortVersion) (\(build))" } - - private static func shortIdentity(_ raw: String) -> String { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count > 12 else { return trimmed } - let prefix = trimmed.prefix(6) - let suffix = trimmed.suffix(4) - return "\(prefix)…\(suffix)" - } - - private static func relativeDate(_ date: Date) -> String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .short - return formatter.localizedString(for: date, relativeTo: Date()) - } } struct SettingsDetailRow: View { diff --git a/apps/ios/ADE/Views/Settings/SettingsNotificationsSection.swift b/apps/ios/ADE/Views/Settings/SettingsNotificationsSection.swift index ffa2c2eab..a52b9e884 100644 --- a/apps/ios/ADE/Views/Settings/SettingsNotificationsSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsNotificationsSection.swift @@ -7,7 +7,7 @@ import SwiftUI /// `NotificationsCenterView` onto its existing `NavigationStack`. struct SettingsNotificationsSection: View { var onPreferencesChanged: (NotificationPreferences) -> Void - var onSendTestPush: () -> Void + var onSendTestPush: () async -> SyncSendTestPushResult @State private var prefs = NotificationPreferences() diff --git a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift index b5146d74b..57e924dee 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift @@ -1,7 +1,7 @@ import SwiftUI struct SettingsPairingSection: View { - @EnvironmentObject private var syncService: SyncService + let snapshot: SettingsPairingSnapshot @Binding var presentedSheet: SettingsPairSheetRoute? var body: some View { @@ -34,8 +34,8 @@ struct SettingsPairingSection: View { } private var discoverSubtitle: String? { - let count = syncService.discoveredHosts.count - let savedCount = syncService.savedReconnectHosts.count + let count = snapshot.discoveredHostCount + let savedCount = snapshot.savedReconnectHostCount if count == 0, savedCount > 0 { return savedCount == 1 ? "1 saved machine" : "\(savedCount) saved machines" } @@ -46,7 +46,7 @@ struct SettingsPairingSection: View { } private var pairingHint: String? { - guard !syncService.savedReconnectHosts.isEmpty else { + guard snapshot.savedReconnectHostCount > 0 else { return "Pick how to reach your machine" } return "Add another machine or switch saved machines" @@ -206,13 +206,14 @@ func syncDiscoveredHostsForDisplay( savedHosts: [DiscoveredSyncHost], liveHosts: [DiscoveredSyncHost] ) -> (savedHosts: [DiscoveredSyncHost], liveHosts: [DiscoveredSyncHost]) { + let coalescedLiveHosts = syncCoalescedLiveDiscoveredHosts(liveHosts) let saved = savedHosts.map { savedHost in - guard let liveHost = liveHosts.first(where: { syncDiscoveredHostsReferToSameMachine(savedHost, $0) }) else { + guard let liveHost = coalescedLiveHosts.first(where: { syncDiscoveredHostsReferToSameMachine(savedHost, $0) }) else { return savedHost } return syncMergeSavedDiscoveredHost(savedHost, withLiveHost: liveHost) } - let live = liveHosts.filter { liveHost in + let live = coalescedLiveHosts.filter { liveHost in !savedHosts.contains { savedHost in syncDiscoveredHostsReferToSameMachine(savedHost, liveHost) } @@ -220,6 +221,24 @@ func syncDiscoveredHostsForDisplay( return (savedHosts: saved, liveHosts: live) } +func syncCoalescedLiveDiscoveredHosts(_ hosts: [DiscoveredSyncHost]) -> [DiscoveredSyncHost] { + var byKey: [String: DiscoveredSyncHost] = [:] + var orderedKeys: [String] = [] + + for host in hosts { + let key = syncExistingDiscoveredHostDisplayKey(for: host, in: orderedKeys, byKey: byKey) + ?? syncDiscoveredHostDisplayKey(host) + if let existing = byKey[key] { + byKey[key] = syncMergeLiveDiscoveredHost(existing, with: host) + } else { + byKey[key] = host + orderedKeys.append(key) + } + } + + return orderedKeys.compactMap { byKey[$0] } +} + func syncDiscoveredHostDetailText(host: DiscoveredSyncHost, detailPrefix: String?) -> String { let route = syncDiscoveredHostPrimaryRoute(host: host, detailPrefix: detailPrefix) let prefix = detailPrefix ?? syncDiscoveredHostInferredRoutePrefix(host: host, route: route) @@ -259,6 +278,93 @@ private func syncDiscoveredHostsReferToSameMachine( return left.id == right.id } +private func syncExistingDiscoveredHostDisplayKey( + for host: DiscoveredSyncHost, + in orderedKeys: [String], + byKey: [String: DiscoveredSyncHost] +) -> String? { + guard let hostRoute = syncDiscoveredHostDisplayPrimaryRouteKey(host) else { return nil } + return orderedKeys.first { key in + guard let existing = byKey[key] else { return false } + return syncDiscoveredHostDisplayPrimaryRouteKey(existing) == hostRoute + } +} + +private func syncDiscoveredHostDisplayKey(_ host: DiscoveredSyncHost) -> String { + if let route = syncDiscoveredHostDisplayPrimaryRouteKey(host) { + return "route:\(route):\(host.port)" + } + if let identity = syncTrimmedNonEmpty(host.hostIdentity) { + return "identity:\(identity)" + } + if let name = syncTrimmedNonEmpty(host.hostName)?.lowercased() { + return "name:\(name):\(host.port)" + } + return "id:\(host.id)" +} + +private func syncDiscoveredHostDisplayPrimaryRouteKey(_ host: DiscoveredSyncHost) -> String? { + let lanRoute = host.addresses + .map(syncNormalizedRouteHost) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .first(where: { !$0.isEmpty && !syncIsLoopbackAddress($0) && !syncIsTailscaleRoute($0) && !$0.hasSuffix(".local") }) + if let lanRoute { + return lanRoute + } + + let bonjourRoute = host.addresses + .map(syncNormalizedRouteHost) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .first(where: { !$0.isEmpty && !syncIsLoopbackAddress($0) && !syncIsTailscaleRoute($0) }) + if let bonjourRoute { + return bonjourRoute + } + + return (host.tailscaleAddress.map { [$0] } ?? host.addresses) + .map(syncNormalizedRouteHost) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .first(where: { !$0.isEmpty && !syncIsLoopbackAddress($0) }) +} + +private func syncMergeLiveDiscoveredHost( + _ left: DiscoveredSyncHost, + with right: DiscoveredSyncHost +) -> DiscoveredSyncHost { + let preferred = syncPreferDiscoveredHostForDisplay(right, over: left) ? right : left + let fallback = preferred.id == right.id ? left : right + return DiscoveredSyncHost( + id: preferred.id, + serviceName: syncTrimmedNonEmpty(preferred.serviceName) ?? fallback.serviceName, + hostName: syncTrimmedNonEmpty(preferred.hostName) ?? fallback.hostName, + hostIdentity: syncTrimmedNonEmpty(preferred.hostIdentity) ?? syncTrimmedNonEmpty(fallback.hostIdentity), + port: preferred.port > 0 ? preferred.port : fallback.port, + addresses: syncUniqueNonEmptyStrings(preferred.addresses + fallback.addresses), + tailscaleAddress: syncTrimmedNonEmpty(preferred.tailscaleAddress) ?? syncTrimmedNonEmpty(fallback.tailscaleAddress), + runtimeKind: syncTrimmedNonEmpty(preferred.runtimeKind) ?? syncTrimmedNonEmpty(fallback.runtimeKind), + runtimeVersion: syncTrimmedNonEmpty(preferred.runtimeVersion) ?? syncTrimmedNonEmpty(fallback.runtimeVersion), + projectIds: syncUniqueNonEmptyStrings(preferred.projectIds + fallback.projectIds), + projectNames: syncUniqueNonEmptyStrings(preferred.projectNames + fallback.projectNames), + projectCount: max(preferred.projectCount ?? 0, fallback.projectCount ?? 0) > 0 + ? max(preferred.projectCount ?? 0, fallback.projectCount ?? 0) + : nil, + lastResolvedAt: max(preferred.lastResolvedAt, fallback.lastResolvedAt) + ) +} + +private func syncPreferDiscoveredHostForDisplay( + _ candidate: DiscoveredSyncHost, + over existing: DiscoveredSyncHost +) -> Bool { + let candidateName = syncTrimmedNonEmpty(candidate.hostName) ?? "" + let existingName = syncTrimmedNonEmpty(existing.hostName) ?? "" + let candidateLooksLikeDeviceName = !candidateName.localizedCaseInsensitiveContains(".local") + let existingLooksLikeDeviceName = !existingName.localizedCaseInsensitiveContains(".local") + if candidateLooksLikeDeviceName != existingLooksLikeDeviceName { + return candidateLooksLikeDeviceName + } + return candidate.lastResolvedAt >= existing.lastResolvedAt +} + private func syncMergeSavedDiscoveredHost( _ savedHost: DiscoveredSyncHost, withLiveHost liveHost: DiscoveredSyncHost diff --git a/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift b/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift index 4b4be6ecf..9b4f5053c 100644 --- a/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift +++ b/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift @@ -131,13 +131,9 @@ struct WorkTerminalSessionView: View { let transitionNamespace: Namespace.ID? let onOpenLane: (() -> Void)? - /// Termius-style char-by-char input: as the user types, each new character - /// is forwarded to the live PTY and the field is immediately cleared so the - /// PTY's own echo (rendered in the scrollback) is the only source of truth. - /// We never buffer a command on-device — `↵` and the keyboard return both - /// just send `\r` to the shell. - @State private var inputBuffer = "" - @FocusState private var inputFocused: Bool + /// Phone-friendly terminal input: keep the text visible while the user types, + /// then send the whole buffer on Return. The shortcut bar still sends raw + /// bytes immediately for shell/TUI controls like Esc, Tab, arrows, and ^C. @State private var sendingFeedback = 0 @State private var lastSentTerminalSize: WorkTerminalViewport? @State private var currentTerminalViewport: WorkTerminalViewport? @@ -174,6 +170,13 @@ struct WorkTerminalSessionView: View { .task(id: session.id) { subscriptionLifecycle.markVisible() try? await syncService.subscribeTerminal(sessionId: session.id) + if let currentTerminalViewport { + lastSentTerminalSize = nil + sendTerminalResize(currentTerminalViewport) + } + } + .task(id: terminalSnapshotBackstopKey) { + await runTerminalSnapshotBackstop() } .onDisappear { subscriptionLifecycle.scheduleUnsubscribe(sessionId: session.id, syncService: syncService) @@ -263,57 +266,20 @@ struct WorkTerminalSessionView: View { .buttonStyle(.plain) } - /// Slim composer. Each typed character streams straight to the PTY and we - /// then clear the field so it acts as a keyboard mount, not a buffer. The - /// `↵` button (and keyboard Return) explicitly send `\r` so the remote - /// shell submits — works for plain shells AND for TUI prompts that read - /// input character-by-character. + /// Slim composer. The `↵` button and keyboard Return send any visible buffer, + /// then `\r`, so the remote shell/TUI submits exactly what the phone shows. private var terminalInputBar: some View { - HStack(spacing: 8) { - TextField("Type to send keystrokes", text: $inputBuffer) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .submitLabel(.return) - .focused($inputFocused) - .font(.system(size: 13, design: .monospaced)) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .frame(minHeight: 32) - .frame(maxWidth: .infinity) - .background(ADEColor.surfaceBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .stroke(ADEColor.glassBorder, lineWidth: 0.5) - ) - .onChange(of: inputBuffer) { _, newValue in - guard !newValue.isEmpty, canSendInput else { - if !newValue.isEmpty { inputBuffer = "" } - return - } - syncService.sendTerminalInput(sessionId: session.id, data: newValue) - inputBuffer = "" - } - .onSubmit { submitReturn() } - .disabled(!canSendInput) - - Button(action: submitReturn) { - Image(systemName: "return") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(canSendInput ? ADEColor.accent : ADEColor.textMuted) - .frame(width: 36, height: 32) - } - .buttonStyle(.plain) - .background( - (canSendInput ? ADEColor.accent.opacity(0.14) : ADEColor.surfaceBackground.opacity(0.5)), - in: RoundedRectangle(cornerRadius: 9, style: .continuous) + TerminalInputComposer( + placeholder: "Type to send keystrokes", + isEnabled: canSendInput, + accentColor: UIColor(ADEColor.accent), + disabledColor: UIColor(ADEColor.textMuted), + surfaceColor: UIColor(ADEColor.surfaceBackground), + borderColor: UIColor(ADEColor.glassBorder), + onSubmit: submitReturn(input:) ) - .overlay( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .stroke(ADEColor.glassBorder, lineWidth: 0.5) - ) - .accessibilityLabel("Send Return") - .disabled(!canSendInput) - } + .frame(height: 32) + .frame(maxWidth: .infinity) .padding(.horizontal, 10) .padding(.vertical, 8) .background(.ultraThinMaterial) @@ -322,15 +288,55 @@ struct WorkTerminalSessionView: View { } } - private func submitReturn() { + private func submitReturn(input: String) { guard canSendInput else { return } - if !inputBuffer.isEmpty { - syncService.sendTerminalInput(sessionId: session.id, data: inputBuffer) - inputBuffer = "" - } - syncService.sendTerminalInput(sessionId: session.id, data: "\r") + let sessionId = session.id + // Send buffered text + Return as a single write so the carriage return + // can't arrive after later keystrokes (or be dropped entirely if the + // connection state flips during a delay). + syncService.sendTerminalInput(sessionId: sessionId, data: input + "\r") sendingFeedback &+= 1 - inputFocused = true + } + + private var terminalSnapshotBackstopKey: String { + "\(session.id)-\(session.status)-\(session.runtimeState)-\(canSendInput)-\(syncService.prefersReducedSyncLoad)" + } + + private var shouldBackstopTerminalSnapshots: Bool { + guard canSendInput else { return false } + let status = session.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return status != "ended" && status != "exited" && status != "stopped" + } + + @MainActor + private func runTerminalSnapshotBackstop() async { + guard shouldBackstopTerminalSnapshots else { return } + let initialDelay: UInt64 = 650_000_000 + let pollDelay: UInt64 = syncService.prefersReducedSyncLoad ? 2_500_000_000 : 1_250_000_000 + let staleInterval: TimeInterval = syncService.prefersReducedSyncLoad ? 4.5 : 2.0 + let snapshotBytes = syncService.prefersReducedSyncLoad ? 80_000 : 140_000 + + try? await Task.sleep(nanoseconds: initialDelay) + guard !Task.isCancelled, shouldBackstopTerminalSnapshots else { return } + if terminalVisibleBufferIsEmpty() { + try? await syncService.refreshTerminalSnapshot(sessionId: session.id, maxBytes: snapshotBytes) + } + + while !Task.isCancelled, shouldBackstopTerminalSnapshots { + try? await Task.sleep(nanoseconds: pollDelay) + guard !Task.isCancelled, shouldBackstopTerminalSnapshots else { return } + let lastUpdate = syncService.terminalBufferUpdatedAt[session.id] ?? Date.distantPast + if terminalVisibleBufferIsEmpty() || Date().timeIntervalSince(lastUpdate) >= staleInterval { + try? await syncService.refreshTerminalSnapshot(sessionId: session.id, maxBytes: snapshotBytes) + } + } + } + + private func terminalVisibleBufferIsEmpty() -> Bool { + let buffered = syncService.terminalBuffers[session.id] ?? "" + let fallback = session.lastOutputPreview ?? "" + return buffered.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && fallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private func handleTerminalViewportChange(_ viewport: WorkTerminalViewport) { @@ -346,6 +352,153 @@ struct WorkTerminalSessionView: View { } } +private struct TerminalInputComposer: UIViewRepresentable { + let placeholder: String + let isEnabled: Bool + let accentColor: UIColor + let disabledColor: UIColor + let surfaceColor: UIColor + let borderColor: UIColor + let onSubmit: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIView(context: Context) -> TerminalInputComposerView { + let view = TerminalInputComposerView() + view.textField.delegate = context.coordinator + return view + } + + func updateUIView(_ view: TerminalInputComposerView, context: Context) { + context.coordinator.parent = self + view.onSubmit = { [weak view] in + let text = view?.textField.text ?? "" + view?.textField.text = "" + onSubmit(text) + view?.textField.becomeFirstResponder() + } + view.configure( + placeholder: placeholder, + isEnabled: isEnabled, + accentColor: accentColor, + disabledColor: disabledColor, + surfaceColor: surfaceColor, + borderColor: borderColor + ) + } + + final class Coordinator: NSObject, UITextFieldDelegate { + var parent: TerminalInputComposer + + init(parent: TerminalInputComposer) { + self.parent = parent + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + let text = textField.text ?? "" + textField.text = "" + parent.onSubmit(text) + return false + } + } +} + +private final class TerminalInputComposerView: UIView { + let textField = UITextField() + private let returnButton = UIButton(type: .system) + var onSubmit: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + + textField.borderStyle = .none + textField.backgroundColor = .clear + textField.font = UIFont.monospacedSystemFont(ofSize: 13, weight: .regular) + textField.textColor = UIColor.label + textField.returnKeyType = .default + textField.keyboardType = .asciiCapable + textField.autocorrectionType = .no + textField.autocapitalizationType = .none + textField.spellCheckingType = .no + textField.smartQuotesType = .no + textField.smartDashesType = .no + textField.smartInsertDeleteType = .no + textField.textContentType = .none + textField.accessibilityLabel = "Terminal input" + + returnButton.setImage(UIImage(systemName: "return"), for: .normal) + returnButton.accessibilityLabel = "Send Return" + returnButton.layer.cornerRadius = 9 + returnButton.addTarget(self, action: #selector(submit), for: .touchUpInside) + + let fieldContainer = UIView() + fieldContainer.layer.cornerRadius = 9 + fieldContainer.layer.borderWidth = 0.5 + fieldContainer.translatesAutoresizingMaskIntoConstraints = false + textField.translatesAutoresizingMaskIntoConstraints = false + fieldContainer.addSubview(textField) + + returnButton.translatesAutoresizingMaskIntoConstraints = false + addSubview(fieldContainer) + addSubview(returnButton) + + NSLayoutConstraint.activate([ + fieldContainer.leadingAnchor.constraint(equalTo: leadingAnchor), + fieldContainer.topAnchor.constraint(equalTo: topAnchor), + fieldContainer.bottomAnchor.constraint(equalTo: bottomAnchor), + returnButton.leadingAnchor.constraint(equalTo: fieldContainer.trailingAnchor, constant: 8), + returnButton.trailingAnchor.constraint(equalTo: trailingAnchor), + returnButton.topAnchor.constraint(equalTo: topAnchor), + returnButton.bottomAnchor.constraint(equalTo: bottomAnchor), + returnButton.widthAnchor.constraint(equalToConstant: 36), + + textField.leadingAnchor.constraint(equalTo: fieldContainer.leadingAnchor, constant: 10), + textField.trailingAnchor.constraint(equalTo: fieldContainer.trailingAnchor, constant: -10), + textField.topAnchor.constraint(equalTo: fieldContainer.topAnchor), + textField.bottomAnchor.constraint(equalTo: fieldContainer.bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { + return nil + } + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: 32) + } + + func configure( + placeholder: String, + isEnabled: Bool, + accentColor: UIColor, + disabledColor: UIColor, + surfaceColor: UIColor, + borderColor: UIColor + ) { + textField.placeholder = placeholder + textField.tintColor = accentColor + textField.isEnabled = isEnabled + textField.alpha = isEnabled ? 1 : 0.55 + + if let fieldContainer = textField.superview { + fieldContainer.backgroundColor = surfaceColor.withAlphaComponent(isEnabled ? 0.55 : 0.38) + fieldContainer.layer.borderColor = borderColor.cgColor + } + + returnButton.isEnabled = isEnabled + returnButton.tintColor = isEnabled ? accentColor : disabledColor + returnButton.backgroundColor = (isEnabled ? accentColor : surfaceColor).withAlphaComponent(isEnabled ? 0.14 : 0.50) + returnButton.layer.borderWidth = 0.5 + returnButton.layer.borderColor = borderColor.cgColor + } + + @objc private func submit() { + onSubmit?() + } +} + @MainActor private final class WorkTerminalSubscriptionLifecycle: ObservableObject { private var generation = 0 diff --git a/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift b/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift index bf6ab0c73..768e0f4f8 100644 --- a/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift @@ -54,7 +54,8 @@ func workFilteredSessions( archivedSessionIds: Set, selectedStatus: WorkSessionStatusFilter, selectedLaneId: String, - searchText: String + searchText: String, + outputSearchBySessionId: [String: String] = [:] ) -> [TerminalSessionSummary] { sessions .filter { !isRunOwnedSession($0) } @@ -78,7 +79,12 @@ func workFilteredSessions( return false } - return workSessionMatchesSearch(session: session, summary: chatSummaries[session.id], query: searchText) + return workSessionMatchesSearch( + session: session, + summary: chatSummaries[session.id], + query: searchText, + outputSearchText: outputSearchBySessionId[session.id] + ) } .sorted { compareWorkSessionSortOrder($0, $1, chatSummaries: chatSummaries) } } @@ -125,6 +131,33 @@ func workFilesWorkspace(for laneId: String, in workspaces: [FilesWorkspace]) -> workspaces.first { $0.laneId == laneId } } +func resolvedWorkNavigationLaneId(for session: TerminalSessionSummary, lanes: [LaneSummary]) -> String { + if lanes.contains(where: { $0.id == session.laneId }) { + return session.laneId + } + + let sessionLaneName = normalizedWorkLaneLookupValue(session.laneName) + if sessionLaneName == "primary", + let primaryLane = lanes.first(where: { normalizedWorkLaneLookupValue($0.laneType) == "primary" }) + { + return primaryLane.id + } + + if let namedLane = lanes.first(where: { normalizedWorkLaneLookupValue($0.name) == sessionLaneName }) { + return namedLane.id + } + + if let branchLane = lanes.first(where: { normalizedWorkLaneLookupValue($0.branchRef) == sessionLaneName }) { + return branchLane.id + } + + return session.laneId +} + +private func normalizedWorkLaneLookupValue(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() +} + func workSessionDisplayTitle(session: TerminalSessionSummary, summary: AgentChatSessionSummary?) -> String { summary?.title ?? session.title } @@ -198,7 +231,8 @@ func workSessionEmptyStateMessage(status: WorkSessionStatusFilter, searchText: S private func workSessionMatchesSearch( session: TerminalSessionSummary, summary: AgentChatSessionSummary?, - query: String + query: String, + outputSearchText: String? = nil ) -> Bool { let tokens = query .trimmingCharacters(in: .whitespacesAndNewlines) @@ -206,11 +240,15 @@ private func workSessionMatchesSearch( .split(whereSeparator: \.isWhitespace) .map(String.init) guard !tokens.isEmpty else { return true } - let indexed = workSessionSearchIndex(session: session, summary: summary) + let indexed = workSessionSearchIndex(session: session, summary: summary, outputSearchText: outputSearchText) return tokens.allSatisfy(indexed.contains) } -private func workSessionSearchIndex(session: TerminalSessionSummary, summary: AgentChatSessionSummary?) -> String { +private func workSessionSearchIndex( + session: TerminalSessionSummary, + summary: AgentChatSessionSummary?, + outputSearchText: String? = nil +) -> String { let status = normalizedWorkChatSessionStatus(session: session, summary: summary) let statusTokens = status.replacingOccurrences(of: "-", with: " ") var fields: [String] = [] @@ -228,9 +266,88 @@ private func workSessionSearchIndex(session: TerminalSessionSummary, summary: Ag fields.append(summary?.model ?? "") fields.append(statusTokens) fields.append(session.pinned ? "pinned" : "") + fields.append(outputSearchText ?? "") return fields.joined(separator: " ").lowercased() } +private let workSessionOutputSearchMaxCharacters = 20_000 + +func workSessionOutputSearchIndexBySessionId(buffers: [String: String]) -> [String: String] { + guard !buffers.isEmpty else { return [:] } + var result: [String: String] = [:] + result.reserveCapacity(buffers.count) + for (sessionId, buffer) in buffers { + let searchText = workSessionOutputSearchText(buffer) + if !searchText.isEmpty { + result[sessionId] = searchText + } + } + return result +} + +func workSessionOutputSearchText(_ raw: String) -> String { + let tail = raw.count > workSessionOutputSearchMaxCharacters + ? String(raw.suffix(workSessionOutputSearchMaxCharacters)) + : raw + var output = "" + output.reserveCapacity(tail.count) + var index = tail.startIndex + while index < tail.endIndex { + let scalar = tail[index].unicodeScalars.first + if scalar?.value == 0x1B { + index = workAdvancePastTerminalEscape(in: tail, from: index) + continue + } + if let value = scalar?.value, value < 0x20 || value == 0x7F { + if tail[index] == "\n" || tail[index] == "\r" || tail[index] == "\t" { + output.append(" ") + } + index = tail.index(after: index) + continue + } + output.append(tail[index]) + index = tail.index(after: index) + } + return output.lowercased() +} + +private func workAdvancePastTerminalEscape(in text: String, from escapeIndex: String.Index) -> String.Index { + var index = text.index(after: escapeIndex) + guard index < text.endIndex else { return index } + + let introducer = text[index] + if introducer == "]" { + index = text.index(after: index) + while index < text.endIndex { + let scalar = text[index].unicodeScalars.first?.value + if scalar == 0x07 { + return text.index(after: index) + } + if scalar == 0x1B { + let next = text.index(after: index) + if next < text.endIndex && text[next] == "\\" { + return text.index(after: next) + } + } + index = text.index(after: index) + } + return text.endIndex + } + + if introducer == "[" || introducer == "(" || introducer == ")" || introducer == "P" || introducer == "^" || introducer == "_" { + index = text.index(after: index) + while index < text.endIndex { + if let scalar = text[index].unicodeScalars.first, scalar.value >= 0x40 && scalar.value <= 0x7E { + return text.index(after: index) + } + index = text.index(after: index) + } + return text.endIndex + } + + return text.index(after: index) +} + private func workSessionStatusSortRank(_ status: String) -> Int { switch status { case "awaiting-input": return 0 diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index 7538ce6e7..d78b7c03e 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -120,8 +120,8 @@ struct WorkComposerInputBanner: View { /// Compact horizontal strip matching the desktop composer toolbar: small /// single-line pills for access / model / reasoning, queued/pending status -/// chips, and nothing else. The access pill is a SwiftUI `Menu` so runtime -/// modes flip inline — no extra "session settings" sheet to wade through. +/// chips, and nothing else. Runtime and reasoning choices are visible chips +/// so mobile does not hide critical steering behind a native menu. struct WorkComposerChipStrip: View { let chatSummary: AgentChatSessionSummary? let queuedSteerCount: Int @@ -172,46 +172,19 @@ struct WorkComposerChipStrip: View { } return tiers }() - let label = current.isEmpty ? "Effort" : current.capitalized - Menu { + HStack(spacing: 6) { ForEach(menuTiers, id: \.self) { option in - Button { + composerOptionChip( + title: option.capitalized, + systemImage: "gauge.with.dots.needle.50percent", + tint: ADEColor.accent, + isSelected: option.lowercased() == current.lowercased(), + accessibilityPrefix: "Reasoning effort" + ) { onSelectEffort(option) - } label: { - if option.lowercased() == current.lowercased() { - Label(option.capitalized, systemImage: "checkmark") - } else { - Text(option.capitalized) - } } } - } label: { - HStack(spacing: 6) { - Image(systemName: "gauge.with.dots.needle.50percent") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) - Text(label) - .font(.caption.weight(.semibold)) - .foregroundStyle(current.isEmpty ? ADEColor.textSecondary : ADEColor.textPrimary) - .lineLimit(1) - Image(systemName: "chevron.down") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) - } - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background(Color.clear, in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.5) - ) } - .menuStyle(.borderlessButton) - .accessibilityLabel( - current.isEmpty - ? "Reasoning effort. Tap to choose a tier." - : "Reasoning effort: \(current.capitalized). Tap to change." - ) } } @@ -262,33 +235,68 @@ struct WorkComposerChipStrip: View { private func accessPill(summary: AgentChatSessionSummary) -> some View { let options = workRuntimeModeOptions(provider: summary.provider) let currentMode = workInitialRuntimeMode(summary) - let label = workRuntimeModeLabel(provider: summary.provider, mode: currentMode) let tint = workRuntimeModeTint(currentMode) if options.isEmpty || onSelectRuntimeMode == nil { - pillContent(dotColor: tint, label: label, showChevron: false) + pillContent(dotColor: tint, label: workRuntimeModeLabel(provider: summary.provider, mode: currentMode), showChevron: false) } else { - Menu { + HStack(spacing: 6) { ForEach(options) { option in - Button { + composerOptionChip( + title: option.title, + systemImage: nil, + tint: workRuntimeModeTint(option.id), + isSelected: option.id == currentMode, + accessibilityPrefix: "Access mode" + ) { onSelectRuntimeMode?(option.id) - } label: { - if option.id == currentMode { - Label(option.title, systemImage: "checkmark") - } else { - Text(option.title) - } } } - } label: { - pillContent(dotColor: tint, label: label, showChevron: true) } - .menuStyle(.borderlessButton) - .buttonStyle(.plain) - .accessibilityLabel("Access mode: \(label). Tap to change.") } } + private func composerOptionChip( + title: String, + systemImage: String?, + tint: Color, + isSelected: Bool, + accessibilityPrefix: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 6) { + Circle() + .fill(tint) + .frame(width: 6, height: 6) + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(isSelected ? tint : ADEColor.textMuted) + } + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) + .lineLimit(1) + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(tint) + } + } + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background((isSelected ? tint.opacity(0.12) : Color.clear), in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(isSelected ? tint.opacity(0.4) : ADEColor.border.opacity(0.22), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("\(accessibilityPrefix): \(title)") + .accessibilityValue(isSelected ? "Selected" : "") + } + @ViewBuilder private func pillContent(dotColor: Color, label: String, showChevron: Bool) -> some View { HStack(spacing: 6) { @@ -387,10 +395,9 @@ struct WorkQueuedSteerStrip: View { let onDispatchInline: (@MainActor (String) async -> Void)? let onDispatchInterrupt: (@MainActor (String) async -> Void)? - // Collapsed by default: with long turns users can have 3-5 queued items - // and the composer area is the most vertical-space-constrained region - // on iPhone. Users open the strip only when they need to edit/cancel. - @State private var isExpanded: Bool = false + // Expanded by default so Send now / Interrupt / Edit / Cancel have the same + // immediate affordance as desktop and the TUI after a steer is staged. + @State private var isExpanded: Bool = true // Cancel haptic token: bumped each time a row's cancel lands so the // whole strip can drive a single sensoryFeedback modifier. @State private var cancelHapticToken: Int = 0 @@ -445,6 +452,7 @@ struct WorkQueuedSteerStrip: View { .sensoryFeedback(.impact(weight: .light), trigger: cancelHapticToken) .animation(.smooth(duration: 0.22), value: isExpanded) .animation(.smooth(duration: 0.22), value: anyEditing) + .accessibilityElement(children: .contain) } private var header: some View { @@ -480,6 +488,7 @@ struct WorkQueuedSteerStrip: View { .buttonStyle(.plain) .accessibilityLabel(steers.count == 1 ? "1 staged message" : "\(steers.count) staged messages") .accessibilityHint(isExpanded || anyEditing ? "Collapse staged messages" : "Expand to send now, interrupt, edit, or cancel staged messages") + .accessibilityIdentifier("Work.Chat.StagedStrip.Header") .disabled(anyEditing) } } @@ -566,6 +575,7 @@ struct WorkQueuedSteerRow: View { .controlSize(.mini) .disabled(busy || !isLive) .accessibilityHint("Fold this message into the active turn") + .accessibilityIdentifier("Work.Chat.StagedStrip.SendNow") } if let onDispatchInterrupt { @@ -581,6 +591,7 @@ struct WorkQueuedSteerRow: View { .controlSize(.mini) .disabled(busy || !isLive) .accessibilityHint("Stop the current turn and run this instead") + .accessibilityIdentifier("Work.Chat.StagedStrip.Interrupt") } Button { @@ -594,6 +605,7 @@ struct WorkQueuedSteerRow: View { .tint(ADEColor.accent) .controlSize(.mini) .disabled(busy || !isLive) + .accessibilityIdentifier("Work.Chat.StagedStrip.Edit") Button(role: .destructive) { Task { await onCancel() } @@ -606,6 +618,7 @@ struct WorkQueuedSteerRow: View { .tint(ADEColor.danger) .controlSize(.mini) .disabled(busy || !isLive) + .accessibilityIdentifier("Work.Chat.StagedStrip.Cancel") } } } @@ -616,8 +629,7 @@ struct WorkQueuedSteerRow: View { RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(ADEColor.glassBorder, lineWidth: 0.5) ) - .accessibilityElement(children: .combine) - .accessibilityLabel("Staged message (sends after turn): \(steer.text)") + .accessibilityElement(children: .contain) } } diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index 74533e964..2f9bf3872 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -88,12 +88,7 @@ struct WorkSessionHeader: View { /// the per-message timestamp for the same reason. struct WorkChatMessageBubble: View { let message: WorkChatMessage - - /// When true, this row is the active assistant message in a streaming turn. - /// Drives the subtle streaming shimmer treatment. Defaults to `false` so - /// existing call sites keep working; the session view sets it to `true` - /// for the latest assistant message while `sessionStatus == "active"`. - var isLive: Bool = false + @State private var assistantLineBudget = workAssistantMessageInitialLineBudget /// Provider string for the current chat session (e.g. "claude", "codex", "cursor"). /// Injected via `.environment(\.workChatProvider, ...)` by the session view. @@ -123,7 +118,62 @@ struct WorkChatMessageBubble: View { private var assistantRow: some View { // Model name intentionally absent here. Usage / composer show model; the // turn line is time-only. - WorkMarkdownRenderer(markdown: message.markdown) + let preview = workAssistantMessagePreview( + message.markdown, + lineBudget: assistantLineBudget, + characterBudget: workAssistantMessageCharacterBudget(forLineBudget: assistantLineBudget) + ) + + return VStack(alignment: .leading, spacing: 10) { + if preview.isTruncated { + Text(preview.text) + .font(.body) + .foregroundStyle(ADEColor.textPrimary) + .tint(ADEColor.accent) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .accessibilityLabel(workAssistantMessageAccessibilityLabel(preview)) + } else { + WorkMarkdownRenderer(markdown: preview.text) + .accessibilityElement(children: .ignore) + .accessibilityLabel(workAssistantMessageAccessibilityLabel(preview)) + } + + if preview.isTruncated { + HStack(spacing: 8) { + Text("\(preview.visibleLineCount) of \(preview.totalLineCount) lines") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + + Spacer(minLength: 0) + + Button { + UIPasteboard.general.string = message.markdown + } label: { + Label("Copy full", systemImage: "doc.on.doc") + .labelStyle(.titleAndIcon) + } + .buttonStyle(.glass) + .tint(ADEColor.textSecondary) + .controlSize(.mini) + + if assistantLineBudget < min(preview.totalLineCount, workAssistantMessageMaxLineBudget) { + Button { + assistantLineBudget = min( + assistantLineBudget + workAssistantMessageLineBudgetStep, + workAssistantMessageMaxLineBudget + ) + } label: { + Label("Show more", systemImage: "chevron.down") + .labelStyle(.titleAndIcon) + } + .buttonStyle(.glass) + .tint(ADEColor.accent) + .controlSize(.mini) + } + } + } + } .padding(.horizontal, 14) .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) @@ -133,7 +183,6 @@ struct WorkChatMessageBubble: View { .stroke(accent.opacity(0.16), lineWidth: 0.6) ) .frame(maxWidth: .infinity, alignment: .leading) - .adeStreamingShimmer(isActive: isLive, cornerRadius: 14) .contextMenu { Button { UIPasteboard.general.string = message.markdown @@ -141,8 +190,7 @@ struct WorkChatMessageBubble: View { Label("Copy message", systemImage: "doc.on.doc") } } - .accessibilityElement(children: .combine) - .accessibilityLabel("Assistant message. \(message.markdown)") + .accessibilityElement(children: .contain) .adeInspectable( "Work.Chat.MessageBubble.Assistant", metadata: [ @@ -185,7 +233,7 @@ struct WorkChatMessageBubble: View { } } .accessibilityElement(children: .combine) - .accessibilityLabel("Your message. \(message.markdown)") + .accessibilityLabel("Your message. \(workChatAccessibilityPreview(message.markdown))") .adeInspectable( "Work.Chat.MessageBubble.User", metadata: [ @@ -240,6 +288,95 @@ struct WorkChatMessageBubble: View { } } +let workAssistantMessageInitialLineBudget = 48 +let workAssistantMessageLineBudgetStep = 48 +let workAssistantMessageMaxLineBudget = 192 +let workAssistantMessageInitialCharacterBudget = 4_000 +let workAssistantMessageCharacterBudgetStep = 4_000 +let workChatAccessibilityPreviewLimit = 800 + +struct WorkAssistantMessagePreview { + let text: String + let isTruncated: Bool + let visibleLineCount: Int + let totalLineCount: Int +} + +func workAssistantMessageCharacterBudget(forLineBudget lineBudget: Int) -> Int { + let extraSteps = max((lineBudget - workAssistantMessageInitialLineBudget) / workAssistantMessageLineBudgetStep, 0) + return workAssistantMessageInitialCharacterBudget + (extraSteps * workAssistantMessageCharacterBudgetStep) +} + +func workAssistantMessagePreview( + _ markdown: String, + lineBudget: Int, + characterBudget: Int +) -> WorkAssistantMessagePreview { + let normalized = markdown.replacingOccurrences(of: "\r\n", with: "\n") + let lines = normalized.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard !lines.isEmpty else { + return WorkAssistantMessagePreview(text: markdown, isTruncated: false, visibleLineCount: 0, totalLineCount: 0) + } + + let clampedLineBudget = max(lineBudget, 1) + let clampedCharacterBudget = max(characterBudget, 256) + if lines.count <= clampedLineBudget && normalized.count <= clampedCharacterBudget { + return WorkAssistantMessagePreview( + text: markdown, + isTruncated: false, + visibleLineCount: lines.count, + totalLineCount: lines.count + ) + } + + var renderedLines: [String] = [] + renderedLines.reserveCapacity(min(lines.count, clampedLineBudget)) + var usedCharacters = 0 + + for line in lines { + guard renderedLines.count < clampedLineBudget else { break } + let newlineCost = renderedLines.isEmpty ? 0 : 1 + let remaining = clampedCharacterBudget - usedCharacters - newlineCost + guard remaining > 0 else { break } + + if line.count > remaining { + renderedLines.append(String(line.prefix(remaining))) + usedCharacters = clampedCharacterBudget + break + } + + renderedLines.append(line) + usedCharacters += line.count + newlineCost + } + + let previewText = renderedLines.joined(separator: "\n") + return WorkAssistantMessagePreview( + text: previewText, + isTruncated: renderedLines.count < lines.count || previewText.count < normalized.count, + visibleLineCount: renderedLines.count, + totalLineCount: lines.count + ) +} + +func workAssistantMessageAccessibilityLabel(_ preview: WorkAssistantMessagePreview) -> String { + if preview.isTruncated { + return "Assistant response preview. \(preview.visibleLineCount) of \(preview.totalLineCount) lines shown." + } + let trimmed = preview.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return "Assistant response." + } + if trimmed.count <= 500 { + return "Assistant response. \(trimmed)" + } + return "Assistant response preview. \(trimmed.prefix(500))" +} + +func workChatAccessibilityPreview(_ markdown: String) -> String { + guard markdown.count > workChatAccessibilityPreviewLimit else { return markdown } + return "\(markdown.prefix(workChatAccessibilityPreviewLimit))..." +} + /// Centered time pill that introduces each turn (model lives in the usage /// row and composer; matches desktop’s time-only turn divider). struct WorkTurnSeparatorView: View { diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift index 8043ff466..f80a8b965 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift @@ -14,7 +14,7 @@ extension WorkChatSessionView { let echoSnapshot = localEchoMessages timelineRebuildTask = Task.detached(priority: .utility) { - try? await Task.sleep(for: .milliseconds(80)) + try? await Task.sleep(for: .milliseconds(220)) guard !Task.isCancelled else { return } let nextSnapshot = buildWorkChatTimelineSnapshot( transcript: transcriptSnapshot, @@ -71,9 +71,10 @@ extension WorkChatSessionView { @MainActor func scrollToLatest(_ proxy: ScrollViewProxy, animated: Bool) { + let targetId = visibleTimeline.last?.id ?? "chat-end" if animated { withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { - proxy.scrollTo("chat-end", anchor: .bottom) + proxy.scrollTo(targetId, anchor: .bottom) } return } @@ -81,7 +82,7 @@ extension WorkChatSessionView { var transaction = Transaction() transaction.animation = nil withTransaction(transaction) { - proxy.scrollTo("chat-end", anchor: .bottom) + proxy.scrollTo(targetId, anchor: .bottom) } } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+MessageLiveness.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+MessageLiveness.swift deleted file mode 100644 index d4dedb019..000000000 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+MessageLiveness.swift +++ /dev/null @@ -1,20 +0,0 @@ -import SwiftUI - -/// Derives whether a given chat message is the "live" assistant bubble in a -/// streaming turn. Used by the timeline renderer to decide which bubble -/// should show the streaming shimmer + accent glow treatment. -/// -/// A message is live when: -/// - the session is actively streaming (`isLive && sessionStatus == "active"`), -/// - the message is an assistant message, -/// - and it is the most recent assistant message in the current timeline. -extension WorkChatSessionView { - func isLatestAssistantMessageLive(_ message: WorkChatMessage) -> Bool { - guard isLive, sessionStatus == "active" else { return false } - guard message.role.lowercased() == "assistant" else { return false } - - // Cached with the visible timeline presentation so each message row does - // not scan the transcript during focus/layout churn. - return timelinePresentation.latestVisibleAssistantMessageId == message.id - } -} diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index 2be938dba..d798ecb19 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -7,7 +7,7 @@ extension WorkChatSessionView { func timelineEntryView(for entry: WorkTimelineEntry, proxy: ScrollViewProxy) -> some View { switch entry.payload { case .message(let message): - WorkChatMessageBubble(message: message, isLive: isLatestAssistantMessageLive(message)) + WorkChatMessageBubble(message: message) case .toolCard(let toolCard): timelineToolCard(toolCard) case .eventCard(let card): diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 51fb762f5..48c682858 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -11,6 +11,7 @@ struct WorkChatSessionView: View { let transcript: [WorkChatEnvelope] let fallbackEntries: [AgentChatTranscriptEntry] let artifacts: [ComputerUseArtifactSummary] + let optimisticPendingSteers: [WorkPendingSteerModel] let localEchoMessages: [WorkLocalEchoMessage] @Binding var expandedToolCardIds: Set @Binding var artifactContent: [String: WorkLoadedArtifactContent] @@ -69,7 +70,10 @@ struct WorkChatSessionView: View { } var pendingSteers: [WorkPendingSteerModel] { - timelineSnapshot.pendingSteers + mergeWorkPendingSteers( + optimistic: optimisticPendingSteers, + canonical: timelineSnapshot.pendingSteers + ) } var primaryPendingInput: WorkPendingInputItem? { @@ -77,7 +81,27 @@ struct WorkChatSessionView: View { } var hasPendingInputGate: Bool { - !pendingInputs.isEmpty || sessionStatus == "awaiting-input" + workChatComposerBlocksFreeformInput(pendingInputCount: pendingInputs.count, sessionStatus: sessionStatus) + } + + var awaitingPromptDetailsMissing: Bool { + workChatAwaitingPromptDetailsMissing(pendingInputCount: pendingInputs.count, sessionStatus: sessionStatus) + } + + var awaitingPromptDetailsMessage: String { + let fallback = "The session is marked as needing input, but the prompt details have not synced to this iPhone yet. Keep the machine connected and try again when the prompt appears." + guard let preview = awaitingPromptPreview else { return fallback } + return "\(preview)\n\(fallback)" + } + + private var awaitingPromptPreview: String? { + [chatSummary?.lastOutputPreview, session.lastOutputPreview] + .compactMap { value -> String? in + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + .first } var toolCards: [WorkToolCardModel] { @@ -138,25 +162,39 @@ struct WorkChatSessionView: View { // Existing chats accept messages while live, and can still accept them // during reconnects when desktop advertised chat.send as queueable. Pending // input is gated separately so the host receives a structured answer. - canSendMessages && !sending && !hasPendingInputGate + canSendMessages && (!sending || sendWillQueue) && !hasPendingInputGate } var composerFeedback: String? { - if sending { - return sendWillQueue ? "Queueing message for machine..." : "Sending message to machine..." + if sending && !sendWillQueue { + return "Sending message to machine..." } if sendWillQueue { - return "Machine is reconnecting. Send will queue until it is back." + return sessionStatus == "active" + ? "Message will stage behind the active turn." + : "Machine is reconnecting. Send will queue until it is back." } if !canSendMessages { return "Reconnect to send messages." } - if hasPendingInputGate || sessionStatus == "awaiting-input" { + if !pendingInputs.isEmpty { return "Answer the waiting prompt above, or decline it before sending another message." } + if awaitingPromptDetailsMissing { + return "Waiting for prompt details from the machine." + } return nil } + var jumpToLatestPillBottomPadding: CGFloat { + // The pill is an overlay, so it needs to sit above the safe-area composer + // instead of covering the Send/Stop control. Staged steers add a second + // composer band, so give the pill extra air when that strip is present. + if !pendingSteers.isEmpty { return 220 } + if !pendingInputs.isEmpty { return 150 } + return 116 + } + @ViewBuilder var sessionOverviewSection: some View { // When live, approval_request cards (tool approval gates) render at the @@ -184,6 +222,17 @@ struct WorkChatSessionView: View { } } + if awaitingPromptDetailsMissing { + ADENoticeCard( + title: "Prompt details syncing", + message: awaitingPromptDetailsMessage, + icon: "exclamationmark.bubble.fill", + tint: ADEColor.warning, + actionTitle: nil, + action: nil + ) + } + // 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 { @@ -246,43 +295,6 @@ struct WorkChatSessionView: View { // lifecycle controls live outside the composer; this space is reserved // for pending input and send feedback. - if let primary = primaryPendingInput { - let overflow = max(pendingInputs.count - 1, 0) - switch primary { - case .approval(let approval): - WorkComposerInputBanner( - title: overflow > 0 ? "Approval waiting (+\(overflow) more)" : "Approval waiting", - message: approval.description, - icon: "checkmark.shield", - tint: ADEColor.warning - ) - case .question(let question): - WorkComposerInputBanner( - title: overflow > 0 ? "Question waiting (+\(overflow) more)" : "Question waiting", - message: question.question, - icon: "questionmark.circle", - tint: ADEColor.warning - ) - case .permission(let permission): - WorkComposerInputBanner( - title: overflow > 0 ? "Permission waiting (+\(overflow) more)" : "Permission waiting", - message: permission.description, - icon: "lock.shield", - tint: ADEColor.warning - ) - case .planApproval(let plan): - // Plan-approval cards render inline in the timeline. The composer - // banner just gives a lightweight heads-up so the user knows there's - // a decision waiting even if they haven't scrolled to it yet. - WorkComposerInputBanner( - title: overflow > 0 ? "Plan approval waiting (+\(overflow) more)" : "Plan approval waiting", - message: plan.title, - icon: "list.bullet.clipboard", - tint: Color(red: 0.95, green: 0.72, blue: 0.15) - ) - } - } - if !pendingSteers.isEmpty { WorkQueuedSteerStrip( steers: pendingSteers, @@ -306,6 +318,8 @@ struct WorkChatSessionView: View { await runSessionAction { await dispatch(steerId) steerEditDrafts.removeValue(forKey: steerId) + scrollToLatest(proxy, animated: true) + unreadBelowCount = 0 } } }, @@ -314,6 +328,8 @@ struct WorkChatSessionView: View { await runSessionAction { await dispatch(steerId) steerEditDrafts.removeValue(forKey: steerId) + scrollToLatest(proxy, animated: true) + unreadBelowCount = 0 } } } @@ -344,7 +360,7 @@ struct WorkChatSessionView: View { awaitingInputGate: hasPendingInputGate, canCompose: canCompose, canSend: canSend, - sending: sending, + sending: sending && !sendWillQueue, // Show a Stop affordance on the Send button while the assistant is // generating. The chip strip stays usable so users can switch // access/model mid-turn; interruption replaces "Send" with a @@ -407,8 +423,14 @@ struct WorkChatSessionView: View { .scrollDismissesKeyboard(.interactively) .adeScreenBackground() .adeNavigationGlass() - .safeAreaInset(edge: .bottom) { + .safeAreaInset(edge: .bottom, spacing: 0) { composerInset(proxy: proxy) + .background(alignment: .bottom) { + WorkChatComposerBackdrop() + } + } + .overlay(alignment: .top) { + WorkChatNavigationBackdrop() } .overlay(alignment: .bottomTrailing) { if unreadBelowCount > 0 { @@ -417,7 +439,7 @@ struct WorkChatSessionView: View { unreadBelowCount = 0 } .padding(.trailing, 16) - .padding(.bottom, 14) + .padding(.bottom, jumpToLatestPillBottomPadding) .transition(.move(edge: .trailing).combined(with: .opacity)) } } @@ -508,17 +530,48 @@ struct WorkChatSessionView: View { } } +private struct WorkChatNavigationBackdrop: View { + var body: some View { + LinearGradient( + colors: [ + ADEColor.pageBackground, + ADEColor.pageBackground.opacity(0.96), + ADEColor.pageBackground.opacity(0) + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 112) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) + } +} + +private struct WorkChatComposerBackdrop: View { + var body: some View { + LinearGradient( + colors: [ + ADEColor.pageBackground.opacity(0), + ADEColor.pageBackground.opacity(0.94), + ADEColor.pageBackground + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea(edges: .bottom) + .allowsHitTesting(false) + } +} + struct WorkTimelinePresentation: Equatable { let entries: [WorkTimelineEntry] let visibleEntries: [WorkTimelineEntry] let hiddenCount: Int - let latestVisibleAssistantMessageId: String? static let empty = WorkTimelinePresentation( entries: [], visibleEntries: [], - hiddenCount: 0, - latestVisibleAssistantMessageId: nil + hiddenCount: 0 ) } @@ -537,18 +590,23 @@ private func makeWorkTimelinePresentation( return WorkTimelinePresentation( entries: entries, visibleEntries: visibleEntries, - hiddenCount: max(entries.count - visibleEntries.count, 0), - latestVisibleAssistantMessageId: latestVisibleAssistantMessageId(in: visibleEntries) + hiddenCount: max(entries.count - visibleEntries.count, 0) ) } -private func latestVisibleAssistantMessageId(in entries: [WorkTimelineEntry]) -> String? { - for entry in entries.reversed() { - if case .message(let message) = entry.payload, message.role.lowercased() == "assistant" { - return message.id - } - } - return nil +func mergeWorkPendingSteers( + optimistic: [WorkPendingSteerModel], + canonical: [WorkPendingSteerModel] +) -> [WorkPendingSteerModel] { + guard !optimistic.isEmpty else { return canonical } + guard !canonical.isEmpty else { return optimistic } + var seen = Set() + var result: [WorkPendingSteerModel] = [] + for steer in optimistic + canonical { + guard seen.insert(steer.id).inserted else { continue } + result.append(steer) + } + return result } private struct WorkChatComposerCard: View { @@ -666,7 +724,10 @@ private struct WorkChatComposerDraftInput: View { WorkChatComposerTextField( draftState: draftState, canCompose: canCompose, - placeholder: awaitingInputGate ? "Answer the prompt above…" : "Type to vibecode…" + placeholder: workChatComposerPlaceholder( + pendingInputCount: pendingInputCount, + sessionStatus: awaitingInputGate ? "awaiting-input" : "" + ) ) HStack(alignment: .center, spacing: 8) { @@ -690,7 +751,21 @@ private struct WorkChatComposerDraftInput: View { ) if showInterrupt { - stopButton + if draftState.hasSendableText { + stopButton(compact: true) + WorkChatComposerSendButton( + draftState: draftState, + canSend: canSend, + sending: sending, + accent: sendAccent, + label: "Stage", + accessibilityLabelText: "Stage message", + onSend: onSend, + onSent: onSent + ) + } else { + stopButton() + } } else { WorkChatComposerSendButton( draftState: draftState, @@ -706,7 +781,7 @@ private struct WorkChatComposerDraftInput: View { } @ViewBuilder - private var stopButton: some View { + private func stopButton(compact: Bool = false) -> some View { Button { Task { await onInterrupt() } } label: { @@ -719,11 +794,13 @@ private struct WorkChatComposerDraftInput: View { Image(systemName: "stop.fill") .font(.system(size: 12, weight: .bold)) } - Text("Stop") - .font(.caption.weight(.semibold)) + if !compact { + Text("Stop") + .font(.caption.weight(.semibold)) + } } .foregroundStyle(Color.white) - .padding(.horizontal, 12) + .padding(.horizontal, compact ? 10 : 12) .padding(.vertical, 8) .background( Capsule(style: .continuous) @@ -798,6 +875,8 @@ private struct WorkChatComposerSendButton: View { let canSend: Bool let sending: Bool let accent: Color + var label = "Send" + var accessibilityLabelText = "Send message" let onSend: @MainActor (String) async -> Bool let onSent: () -> Void @@ -826,7 +905,7 @@ private struct WorkChatComposerSendButton: View { Image(systemName: "paperplane.fill") .font(.system(size: 12, weight: .bold)) } - Text("Send") + Text(label) .font(.caption.weight(.semibold)) } .foregroundStyle(sendEnabled ? Color.white : ADEColor.textSecondary) @@ -843,12 +922,12 @@ private struct WorkChatComposerSendButton: View { .shadow(color: sendEnabled ? accent.opacity(0.4) : .clear, radius: 8, y: 2) } .buttonStyle(.plain) - .accessibilityLabel(sending ? "Sending message" : "Send message") + .accessibilityLabel(sending ? "Sending message" : accessibilityLabelText) .disabled(!sendEnabled) .adeInspectable( "Work.Chat.Composer.SendButton", metadata: [ - "label": sending ? "Sending message" : "Send message", + "label": sending ? "Sending message" : accessibilityLabelText, "role": "button" ] ) diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 6ae8e18cc..147f4cf5b 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -195,20 +195,27 @@ private func backfillMissingTextEnvelopes( fallback: [WorkChatEnvelope] ) -> [WorkChatEnvelope] { guard !fallback.isEmpty else { return transcript } + var merged = transcript var seen: Set = [] - for envelope in transcript { - if let key = workTextContentKey(for: envelope) { + for envelope in merged { + for key in workTextBackfillDedupeKeys(for: envelope) { seen.insert(key) } } + var didReplace = false var missing: [WorkChatEnvelope] = [] for envelope in fallback { - guard let key = workTextContentKey(for: envelope), !seen.contains(key) else { continue } - seen.insert(key) + if replaceTruncatedTextEnvelope(in: &merged, with: envelope) { + didReplace = true + workTextBackfillDedupeKeys(for: envelope).forEach { seen.insert($0) } + continue + } + let keys = workTextBackfillDedupeKeys(for: envelope) + guard !keys.isEmpty, keys.allSatisfy({ !seen.contains($0) }) else { continue } + keys.forEach { seen.insert($0) } missing.append(envelope) } - guard !missing.isEmpty else { return transcript } - var merged = transcript + guard didReplace || !missing.isEmpty else { return transcript } merged.append(contentsOf: missing) return merged.sorted { lhs, rhs in if lhs.timestamp == rhs.timestamp { @@ -218,6 +225,33 @@ private func backfillMissingTextEnvelopes( } } +private func replaceTruncatedTextEnvelope( + in transcript: inout [WorkChatEnvelope], + with fallback: WorkChatEnvelope +) -> Bool { + guard let fallbackIdentity = workTextRoleTurnKey(for: fallback), + let fallbackText = workTextEnvelopeText(fallback), + !fallbackText.isEmpty + else { return false } + + guard let index = transcript.firstIndex(where: { candidate in + guard workTextRoleTurnKey(for: candidate) == fallbackIdentity, + let candidateText = workTextEnvelopeText(candidate) + else { return false } + let normalizedCandidate = candidateText.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedFallback = fallbackText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedCandidate.isEmpty, normalizedFallback.count > normalizedCandidate.count else { + return false + } + return normalizedFallback.contains(normalizedCandidate) + || normalizedFallback.hasSuffix(normalizedCandidate) + || normalizedFallback.hasPrefix(normalizedCandidate) + }) else { return false } + + transcript[index] = fallback + return true +} + /// Identity key for a user/assistant text envelope used for backfill dedup. /// Fallback entries set `itemId: nil`, live envelopes carry an SDK-assigned /// id — so plain equality on merge keys would treat "same message, different @@ -237,6 +271,39 @@ private func workTextContentKey(for envelope: WorkChatEnvelope) -> String? { } } +private func workTextBackfillDedupeKeys(for envelope: WorkChatEnvelope) -> [String] { + guard let key = workTextContentKey(for: envelope) else { return [] } + switch envelope.event { + case .userMessage(let text, let turnId, _, _, _): + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) + return [key, "user|\(turnId ?? "")|\(normalized)"] + default: + return [key] + } +} + +private func workTextRoleTurnKey(for envelope: WorkChatEnvelope) -> String? { + switch envelope.event { + case .userMessage(_, let turnId, let steerId, _, _): + return "user|\(turnId ?? "")|\(steerId ?? "")" + case .assistantText(_, let turnId, _): + return "assistant|\(turnId ?? "")" + default: + return nil + } +} + +private func workTextEnvelopeText(_ envelope: WorkChatEnvelope) -> String? { + switch envelope.event { + case .userMessage(let text, _, _, _, _): + return text + case .assistantText(let text, _, _): + return text + default: + return nil + } +} + func mergeWorkChatTranscripts(base: [WorkChatEnvelope], live: [WorkChatEnvelope]) -> [WorkChatEnvelope] { guard !live.isEmpty else { return base } guard !base.isEmpty else { return live } @@ -648,8 +715,8 @@ func derivePendingWorkSteers(from transcript: [WorkChatEnvelope]) -> [WorkPendin queue.removeValue(forKey: steerId) resolved.insert(steerId) } - case .systemNotice(_, _, _, _, let steerId): - if let steerId { + case .systemNotice(_, let message, _, _, let steerId): + if let steerId, workSystemNoticeResolvesQueuedSteer(message) { queue.removeValue(forKey: steerId) resolved.insert(steerId) } @@ -660,6 +727,13 @@ func derivePendingWorkSteers(from transcript: [WorkChatEnvelope]) -> [WorkPendin return order.compactMap { queue[$0] } } +func workSystemNoticeResolvesQueuedSteer(_ message: String) -> Bool { + let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized.contains("cancelled") + || normalized.contains("canceled") + || normalized.contains("delivering") +} + func pendingWorkInputItemIds(from transcript: [WorkChatEnvelope]) -> Set { var approvals: [String: String?] = [:] var questions: [String: String?] = [:] diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index cfb6502b8..fab128044 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -82,6 +82,9 @@ struct WorkNewChatScreen: View { laneSelector modeSelector + if sessionMode == .cli { + cliProviderSelector + } } .padding(.horizontal, 20) .padding(.vertical, 16) @@ -104,6 +107,7 @@ struct WorkNewChatScreen: View { .navigationTitle("New Chat") .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .tabBar) + .adeRootTabBarHidden() .toolbar { ToolbarItem(placement: .topBarTrailing) { if busy { @@ -121,6 +125,11 @@ struct WorkNewChatScreen: View { } .onChange(of: provider) { _, newProvider in runtimeMode = workDefaultRuntimeMode(provider: newProvider) + if sessionMode == .cli { + normalizeCliSelection() + } else if !workNewChatModel(modelId, belongsTo: workNormalizedNewChatProvider(newProvider)) { + modelId = workDefaultNewChatModelId(provider: newProvider) + } if !modelSupportsReasoning(modelId: modelId, provider: newProvider) { reasoningEffort = "" } @@ -128,6 +137,8 @@ struct WorkNewChatScreen: View { .onChange(of: sessionMode) { _, newMode in if newMode == .chat { normalizeChatSelection() + } else { + normalizeCliSelection() } } .onChange(of: modelId) { _, newModel in @@ -172,65 +183,176 @@ struct WorkNewChatScreen: View { .accessibilityLabel("ADE") } - @ViewBuilder - private var laneSelector: some View { - Menu { - ForEach(lanes) { lane in - Button { - selectedLaneId = lane.id - } label: { - if lane.id == selectedLaneId { - Label(lane.name, systemImage: "checkmark") - } else { - Text(lane.name) - } + private func compactChoiceChip( + title: String, + systemImage: String?, + tint: Color, + isSelected: Bool, + accessibilityPrefix: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 6) { + Circle().fill(tint).frame(width: 6, height: 6) + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(isSelected ? tint : ADEColor.textMuted) } - } - if lanes.isEmpty { - Text("No lanes available") - .font(.footnote) - .foregroundStyle(ADEColor.textMuted) - } - Divider() - Button { - Task { await onRefreshLanes() } - } label: { - Label("Refresh lanes", systemImage: "arrow.clockwise") - } - } label: { - HStack(spacing: 8) { - Image(systemName: "arrow.triangle.branch") + Text(title) .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.accent) - Text(selectedLaneName) - .font(.footnote.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Image(systemName: "chevron.down") - .font(.caption2.weight(.bold)) - .foregroundStyle(ADEColor.textMuted) + .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) + .lineLimit(1) + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(tint) + } } - .padding(.horizontal, 14) - .padding(.vertical, 9) - .background(ADEColor.surfaceBackground.opacity(0.7), in: Capsule(style: .continuous)) - .glassEffect() + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background((isSelected ? tint.opacity(0.12) : Color.clear), in: Capsule(style: .continuous)) .overlay( Capsule(style: .continuous) - .stroke(ADEColor.accent.opacity(0.32), lineWidth: 0.6) + .stroke(isSelected ? tint.opacity(0.4) : ADEColor.border.opacity(0.22), lineWidth: 0.5) ) } .buttonStyle(.plain) + .accessibilityLabel("\(accessibilityPrefix): \(title)") + .accessibilityValue(isSelected ? "Selected" : "") + } + + @ViewBuilder + private var laneSelector: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(lanes) { lane in + compactChoiceChip( + title: lane.name, + systemImage: "arrow.triangle.branch", + tint: ADEColor.accent, + isSelected: lane.id == selectedLaneId, + accessibilityPrefix: "Lane" + ) { + selectedLaneId = lane.id + } + } + if lanes.isEmpty { + Text("No lanes available") + .font(.footnote.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background(ADEColor.surfaceBackground.opacity(0.55), in: Capsule(style: .continuous)) + } + Button { + Task { await onRefreshLanes() } + } label: { + Image(systemName: "arrow.clockwise") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .frame(width: 34, height: 34) + .background(ADEColor.surfaceBackground.opacity(0.55), in: Circle()) + .glassEffect() + .overlay(Circle().stroke(ADEColor.accent.opacity(0.26), lineWidth: 0.6)) + } + .buttonStyle(.plain) + .accessibilityLabel("Refresh lanes") + } + } + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) + .accessibilityLabel("Lane selector. Current lane \(selectedLaneName).") } @ViewBuilder private var modeSelector: some View { - Picker("Session type", selection: $sessionMode) { + HStack(spacing: 3) { ForEach(WorkNewSessionMode.allCases) { mode in - Text(mode.title).tag(mode) + let isSelected = sessionMode == mode + Button { + guard !isSelected else { return } + withAnimation(.snappy(duration: 0.16)) { + sessionMode = mode + } + } label: { + Text(mode.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity, minHeight: 34) + .padding(.horizontal, 8) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(ADEColor.accent.opacity(0.18)) + } + } + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(ADEColor.accent.opacity(0.35), lineWidth: 0.75) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Session type: \(mode.title)") + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } + } + .padding(3) + .background(ADEColor.recessedBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.5) + } + .padding(.horizontal, 8) + } + + @ViewBuilder + private var cliProviderSelector: some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { + ForEach(workCliProviderOptions) { option in + let isSelected = provider == option.id + Button { + provider = option.id + } label: { + HStack(spacing: 8) { + WorkProviderLogo( + provider: option.id, + fallbackSymbol: option.id == "shell" ? "terminal.fill" : providerIcon(option.id), + tint: providerTint(option.id), + size: 16 + ) + Text(option.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.78) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: 34, alignment: .leading) + .padding(.horizontal, 10) + .background { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(isSelected ? providerTint(option.id).opacity(0.16) : ADEColor.surfaceBackground.opacity(0.55)) + } + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(isSelected ? providerTint(option.id).opacity(0.36) : ADEColor.border.opacity(0.22), lineWidth: 0.6) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel("CLI provider: \(option.title)") + .accessibilityAddTraits(isSelected ? [.isSelected] : []) } } - .pickerStyle(.segmented) .padding(.horizontal, 8) - .accessibilityLabel("Session type") } @ViewBuilder @@ -288,14 +410,20 @@ struct WorkNewChatScreen: View { let normalizedReasoning = reasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines) do { if sessionMode == .cli { + let cliModelId = workCliSupportsModelSelection(provider: provider) ? modelId : nil + let cliReasoningEffort = workCliSupportsReasoningSelection(provider: provider) && !normalizedReasoning.isEmpty + ? normalizedReasoning + : nil let result = try await syncService.startCliSession( laneId: selectedLaneId, provider: provider, permissionMode: workCliPermissionMode(provider: provider, runtimeMode: runtimeMode), - title: workCliProviderOptions.first(where: { $0.id == provider })?.title, + title: workCliInitialSessionTitle(provider: provider, opener: opener), initialInput: opener.isEmpty ? nil : opener, - cols: 88, - rows: 28 + modelId: cliModelId, + reasoningEffort: cliReasoningEffort, + cols: 48, + rows: 24 ) if let session = result.session { await onCliStarted(session) @@ -309,9 +437,9 @@ struct WorkNewChatScreen: View { tracked: true, pinned: false, manuallyNamed: nil, - goal: nil, + goal: opener.isEmpty ? nil : opener, toolType: workCliToolType(provider: provider), - title: workCliProviderOptions.first(where: { $0.id == provider })?.title ?? providerLabel(provider), + title: workCliInitialSessionTitle(provider: provider, opener: opener), status: "running", startedAt: workDateFormatter.string(from: Date()), endedAt: nil, @@ -341,7 +469,9 @@ struct WorkNewChatScreen: View { codexApprovalPolicy: wire.codexApprovalPolicy, codexSandbox: wire.codexSandbox, codexConfigSource: wire.codexConfigSource, - opencodePermissionMode: wire.opencodePermissionMode + opencodePermissionMode: wire.opencodePermissionMode, + droidPermissionMode: wire.droidPermissionMode, + cursorModeId: wire.cursorModeId ) await onStarted(summary, opener) busy = false @@ -367,6 +497,21 @@ struct WorkNewChatScreen: View { reasoningEffort = "" } } + + private func normalizeCliSelection() { + let family = providerFamilyKey(provider) + guard workCliSupportsModelSelection(provider: family) else { + reasoningEffort = "" + return + } + if !workNewChatModel(modelId, belongsTo: family) { + modelId = workDefaultNewChatModelId(provider: family) + } + if (!workCliSupportsReasoningSelection(provider: family) + || !modelSupportsReasoning(modelId: modelId, provider: family)) { + reasoningEffort = "" + } + } } private func workNormalizedNewChatProvider(_ provider: String) -> String { @@ -381,6 +526,10 @@ private func workNewChatModel(_ modelId: String, belongsTo provider: String) -> } private func workDefaultNewChatModelId(provider: String) -> String { + let family = providerFamilyKey(provider) + if let defaultModel = workDefaultCatalogModelId(provider: family) { + return defaultModel + } switch workNormalizedNewChatProvider(provider) { case "codex": return workDefaultCatalogModelId(provider: "codex") ?? "gpt-5.5" case "cursor": return "auto" @@ -389,6 +538,34 @@ private func workDefaultNewChatModelId(provider: String) -> String { } } +private func workCliSupportsModelSelection(provider: String) -> Bool { + providerFamilyKey(provider) != "shell" +} + +private func workCliSupportsReasoningSelection(provider: String) -> Bool { + let family = providerFamilyKey(provider) + return family == "claude" || family == "codex" || family == "droid" +} + +private func workCliInitialSessionTitle(provider: String, opener: String) -> String { + let fallback = workCliProviderOptions.first(where: { $0.id == provider })?.title ?? providerLabel(provider) + let seed = opener + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !seed.isEmpty, providerFamilyKey(provider) != "shell" else { + return fallback + } + let clipped: String + if seed.count > 72 { + let prefix = String(seed.prefix(72)) + clipped = prefix.replacingOccurrences(of: #"\s+\S*$"#, with: "", options: .regularExpression) + } else { + clipped = seed + } + return clipped.trimmingCharacters(in: CharacterSet(charactersIn: ".?!,:; ").union(.whitespacesAndNewlines)) +} + func workCliPermissionMode(provider: String, runtimeMode: String) -> String? { if provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "shell" { return nil @@ -477,81 +654,28 @@ private struct WorkNewChatComposerBar: View { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .center, spacing: 10) { if sessionMode == .chat { - Button { - onOpenModelPicker() - } label: { - HStack(spacing: 6) { - WorkProviderLogo( - provider: provider, - fallbackSymbol: providerIcon(provider), - tint: providerTint(provider), - size: 16 - ) - Text(modelName) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - if !reasoningEffort.isEmpty { - Text("·") - .font(.caption2) - .foregroundStyle(ADEColor.textMuted.opacity(0.5)) - Text(reasoningEffort.capitalized) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - } - Image(systemName: "chevron.down") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) - } - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background(Color.clear, in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) + modelPickerButton } else { - cliProviderMenu + cliProviderChips + if workCliSupportsModelSelection(provider: provider) { + modelPickerButton + } } if !runtimeOptions.isEmpty { - Menu { + HStack(spacing: 6) { ForEach(runtimeOptions) { option in - Button { + compactChoiceChip( + title: option.title, + systemImage: nil, + tint: workRuntimeModeTint(option.id), + isSelected: option.id == runtimeMode, + accessibilityPrefix: "Access mode" + ) { runtimeMode = option.id - } label: { - if option.id == runtimeMode { - Label(option.title, systemImage: "checkmark") - } else { - Text(option.title) - } } } - } label: { - HStack(spacing: 6) { - Circle().fill(runtimeTint).frame(width: 6, height: 6) - Text(runtimeLabel) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - Image(systemName: "chevron.down") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) - } - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background(runtimeTint.opacity(0.06), in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(runtimeTint.opacity(0.22), lineWidth: 0.5) - ) } - .menuStyle(.borderlessButton) - .buttonStyle(.plain) - .accessibilityLabel("Access mode: \(runtimeLabel). Tap to change.") } } .padding(.trailing, 4) @@ -630,31 +754,85 @@ private struct WorkNewChatComposerBar: View { } } - private var cliProviderMenu: some View { - Menu { + private var cliProviderChips: some View { + HStack(spacing: 6) { ForEach(workCliProviderOptions) { option in - Button { + compactChoiceChip( + title: option.title, + systemImage: option.id == "shell" ? "terminal.fill" : providerIcon(option.id), + tint: providerTint(option.id), + isSelected: option.id == provider, + accessibilityPrefix: "CLI provider" + ) { provider = option.id - } label: { - if option.id == provider { - Label(option.title, systemImage: "checkmark") - } else { - Text(option.title) - } } } + } + } + + private func compactChoiceChip( + title: String, + systemImage: String?, + tint: Color, + isSelected: Bool, + accessibilityPrefix: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 6) { + Circle().fill(tint).frame(width: 6, height: 6) + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(isSelected ? tint : ADEColor.textMuted) + } + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) + .lineLimit(1) + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(tint) + } + } + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background((isSelected ? tint.opacity(0.12) : Color.clear), in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(isSelected ? tint.opacity(0.4) : ADEColor.border.opacity(0.22), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("\(accessibilityPrefix): \(title)") + .accessibilityValue(isSelected ? "Selected" : "") + } + + private var modelPickerButton: some View { + Button { + onOpenModelPicker() } label: { HStack(spacing: 6) { WorkProviderLogo( provider: provider, - fallbackSymbol: provider == "shell" ? "terminal.fill" : providerIcon(provider), + fallbackSymbol: providerIcon(provider), tint: providerTint(provider), size: 16 ) - Text(workCliProviderOptions.first(where: { $0.id == provider })?.title ?? providerLabel(provider)) + Text(modelName) .font(.caption.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) + if !reasoningEffort.isEmpty { + Text("·") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted.opacity(0.5)) + Text(reasoningEffort.capitalized) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } Image(systemName: "chevron.down") .font(.system(size: 9, weight: .bold)) .foregroundStyle(ADEColor.textMuted) @@ -667,9 +845,8 @@ private struct WorkNewChatComposerBar: View { .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.5) ) } - .menuStyle(.borderlessButton) .buttonStyle(.plain) - .accessibilityLabel("CLI provider: \(workCliProviderOptions.first(where: { $0.id == provider })?.title ?? provider). Tap to change.") + .accessibilityLabel("Model: \(modelName). Tap to change.") } } diff --git a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift index 1b50723be..5fae41361 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift @@ -209,18 +209,24 @@ struct WorkNewChatSheet: View { if let reasoningEfforts = selectedModel?.reasoningEfforts, !reasoningEfforts.isEmpty { GlassSection(title: "Reasoning") { VStack(alignment: .leading, spacing: 12) { - Picker("Reasoning", selection: $selectedReasoningEffort) { - Text("Default").tag("") - ForEach(reasoningEfforts) { effort in - Text(effort.effort.capitalized).tag(effort.effort) - } + ADEOptionButton( + title: "Default", + subtitle: "Use the runtime default for this model.", + systemImage: "sparkle", + isSelected: selectedReasoningEffort.isEmpty + ) { + selectedReasoningEffort = "" } - .pickerStyle(.segmented) - if let effort = reasoningEfforts.first(where: { $0.effort == selectedReasoningEffort }) { - Text(effort.description) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) + ForEach(reasoningEfforts) { effort in + ADEOptionButton( + title: effort.effort.capitalized, + subtitle: effort.description, + systemImage: "brain.head.profile", + isSelected: selectedReasoningEffort == effort.effort + ) { + selectedReasoningEffort = effort.effort + } } } } diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index a55de5a49..3867a6562 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -429,6 +429,7 @@ private enum WorkPreviewData { transcript: WorkPreviewData.transcript, fallbackEntries: [], artifacts: [WorkPreviewData.artifact], + optimisticPendingSteers: [], localEchoMessages: [], expandedToolCardIds: Binding>.constant(["cmd-1"]), artifactContent: .constant([:]), diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index 1c659042e..eab1f347e 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -57,8 +57,7 @@ struct WorkFiltersSection: View { .padding(.vertical, 8) .frame(minHeight: 32) .frame(maxWidth: .infinity) - .background(ADEColor.surfaceBackground.opacity(0.6), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .glassEffect(in: .rect(cornerRadius: 10)) + .background(ADEColor.composerBackground, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(ADEColor.glassBorder, lineWidth: 0.5) @@ -74,10 +73,9 @@ struct WorkFiltersSection: View { .foregroundStyle(filterOpen ? ADEColor.accent : ADEColor.textSecondary) .frame(width: 32, height: 32) .background( - (filterOpen ? ADEColor.accent.opacity(0.12) : ADEColor.surfaceBackground.opacity(0.55)), + (filterOpen ? ADEColor.accent.opacity(0.12) : ADEColor.composerBackground), in: RoundedRectangle(cornerRadius: 10, style: .continuous) ) - .glassEffect(in: .rect(cornerRadius: 10)) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(filterOpen ? ADEColor.accent.opacity(0.32) : ADEColor.glassBorder, lineWidth: 0.5) @@ -98,34 +96,34 @@ struct WorkFiltersSection: View { .frame(maxWidth: .infinity) .padding(.vertical, 11) .background(ADEColor.accent, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .glassEffect(in: .rect(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(.white.opacity(0.18), lineWidth: 0.6) ) - .shadow(color: ADEColor.accent.opacity(0.35), radius: 12, x: 0, y: 4) + .shadow(color: ADEColor.accent.opacity(0.18), radius: 6, x: 0, y: 2) } .buttonStyle(.plain) .disabled(!isLive) .opacity(isLive ? 1 : 0.55) .accessibilityLabel("Start new chat") - HStack(spacing: 6) { - WorkFlatCountChip(icon: "bolt.fill", text: "\(liveCount) live", tint: ADEColor.success) - if needsInputCount > 0 { - WorkFlatCountChip(icon: "exclamationmark.circle.fill", text: "\(needsInputCount) waiting", tint: ADEColor.warning) - } - Spacer(minLength: 0) - if hasActiveFilters { - Button("Clear") { - withAnimation(.snappy(duration: 0.18)) { - onClear() + if needsInputCount > 0 || hasActiveFilters { + HStack(spacing: 6) { + if needsInputCount > 0 { + WorkFlatCountChip(icon: "exclamationmark.circle.fill", text: "\(needsInputCount) waiting", tint: ADEColor.warning) + } + Spacer(minLength: 0) + if hasActiveFilters { + Button("Clear") { + withAnimation(.snappy(duration: 0.18)) { + onClear() + } } + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .buttonStyle(.plain) + .accessibilityLabel("Clear Work filters") } - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.accent) - .buttonStyle(.plain) - .accessibilityLabel("Clear Work filters") } } @@ -154,40 +152,57 @@ struct WorkFiltersSection: View { .padding(.vertical, 1) } - HStack(spacing: 8) { - Menu { + VStack(alignment: .leading, spacing: 8) { + Text("Group") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .textCase(.uppercase) + .tracking(0.5) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { ForEach(WorkSessionOrganization.allCases) { option in - Button(option.title) { - organization = option + WorkFilterChip( + title: option.title, + selected: organization == option, + tint: ADEColor.accent + ) { + withAnimation(.snappy(duration: 0.18)) { + organization = option + } } } - } label: { - WorkFilterMenuLabel( - icon: "rectangle.stack", - title: "Group", - value: organization.title - ) } - .buttonStyle(.plain) + } - Menu { - Button("All lanes") { selectedLaneId = "all" } + Text("Lane") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .textCase(.uppercase) + .tracking(0.5) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + WorkFilterChip( + title: "All lanes", + selected: selectedLaneId == "all", + tint: ADEColor.accent + ) { + selectedLaneId = "all" + } ForEach(lanes) { lane in - Button(lane.name) { selectedLaneId = lane.id } + WorkFilterChip( + title: lane.name, + selected: selectedLaneId == lane.id, + tint: ADEColor.accent + ) { + selectedLaneId = lane.id + } + } } - } label: { - WorkFilterMenuLabel( - icon: "arrow.triangle.branch", - title: "Lane", - value: selectedLaneName - ) } - .buttonStyle(.plain) } } .padding(12) - .background(ADEColor.surfaceBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .glassEffect(in: .rect(cornerRadius: 14)) + .background(ADEColor.composerBackground, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .stroke(ADEColor.glassBorder, lineWidth: 0.5) @@ -380,7 +395,6 @@ struct WorkLiveCountPill: View { .padding(.horizontal, 9) .padding(.vertical, 5) .background(tint.opacity(0.14), in: Capsule()) - .glassEffect() .overlay( Capsule().stroke(tint.opacity(0.28), lineWidth: 0.5) ) @@ -496,6 +510,13 @@ struct WorkSessionListRow: View { } ) .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if shouldShowStopRuntimeAction { + Button("Stop runtime", role: .destructive) { + onStopRuntime(session) + } + .tint(ADEColor.danger) + } + Button(isArchived ? "Restore" : "Archive") { onArchive(session) } @@ -545,8 +566,7 @@ struct WorkSessionListRow: View { } private var shouldShowStopRuntimeAction: Bool { - guard !isChatSession(session) else { return false } - return status == "active" || status == "awaiting-input" + isStoppableRuntimeSession(session, summary: chatSummary) } private var shouldShowDeleteAction: Bool { diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index 74c01c571..a91a6663c 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -16,6 +16,9 @@ extension WorkRootScreen { let selectedStatusSnapshot = selectedStatus let selectedLaneIdSnapshot = selectedLaneId let searchTextSnapshot = searchText + let outputSearchBySessionId = workSessionOutputSearchIndexBySessionId( + buffers: searchTextSnapshot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [:] : syncService.terminalBuffers + ) let organization = WorkSessionOrganization(rawValue: sessionOrganizationRaw) ?? .byStatus sessionPresentationRebuildTask = Task.detached(priority: .utility) { @@ -29,6 +32,7 @@ extension WorkRootScreen { selectedStatus: selectedStatusSnapshot, selectedLaneId: selectedLaneIdSnapshot, searchText: searchTextSnapshot, + outputSearchBySessionId: outputSearchBySessionId, organization: organization, orderedLanes: lanesSnapshot ) @@ -42,6 +46,26 @@ extension WorkRootScreen { } } + @MainActor + func hydrateSearchOutputBuffersIfNeeded() async { + guard isLive, isWorkRootActive else { return } + guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let candidates = mergedSessions + .filter { session in + !isRunOwnedSession(session) + && syncService.terminalBuffers[session.id] == nil + && normalizedWorkChatSessionStatus(session: session, summary: chatSummaries[session.id]) != "ended" + } + .prefix(8) + guard !candidates.isEmpty else { return } + + for session in candidates { + guard isLive, !Task.isCancelled else { return } + try? await syncService.subscribeTerminal(sessionId: session.id) + } + scheduleSessionPresentationRebuild() + } + @MainActor func refreshFromPullGesture() async { await reload(refreshRemote: true) @@ -228,12 +252,11 @@ extension WorkRootScreen { if isChatSession(session) { if archivedSessionIds.contains(session.id) { try await syncService.unarchiveChatSession(sessionId: session.id) + applyArchivedSessionOverride(sessionIds: [session.id], archived: false) } else { try await syncService.archiveChatSession(sessionId: session.id) + applyArchivedSessionOverride(sessionIds: [session.id], archived: true) } - let localIds = Set(archivedSessionIdsStorage.split(separator: "\n").map(String.init)) - let prunedLocal = localIds.subtracting([session.id]) - archivedSessionIdsStorage = prunedLocal.sorted().joined(separator: "\n") await reload(refreshRemote: true) return } @@ -269,21 +292,21 @@ extension WorkRootScreen { } @MainActor - func submitRename() async { - guard let renameTarget else { return } - let trimmedTitle = renameText.trimmingCharacters(in: .whitespacesAndNewlines) + func submitRename(target capturedTarget: TerminalSessionSummary? = nil, title capturedTitle: String? = nil) async { + guard let renameTarget = capturedTarget ?? renameTarget else { return } + let trimmedTitle = (capturedTitle ?? renameText).trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedTitle.isEmpty else { ADEHaptics.error() errorMessage = "Session title cannot be empty." return } do { - _ = try await syncService.updateChatSession( + try await syncService.updateSessionMeta( sessionId: renameTarget.id, title: trimmedTitle, manuallyNamed: true ) - try await syncService.updateSessionMeta( + _ = try? await syncService.updateChatSession( sessionId: renameTarget.id, title: trimmedTitle, manuallyNamed: true @@ -311,7 +334,13 @@ extension WorkRootScreen { } func goToLane(_ session: TerminalSessionSummary) { - syncService.requestedLaneNavigation = LaneNavigationRequest(laneId: session.laneId) + let laneId = resolvedWorkNavigationLaneId(for: session, lanes: lanes) + Task { @MainActor in + // Context-menu actions fire before iOS fully dismisses the menu. Publish + // the cross-tab request after that dismissal so Lanes can present detail. + try? await Task.sleep(for: .milliseconds(450)) + syncService.requestedLaneNavigation = LaneNavigationRequest(laneId: laneId) + } } func openSession(_ session: TerminalSessionSummary) { @@ -325,6 +354,17 @@ extension WorkRootScreen { } } + @MainActor + func handleRequestedWorkSessionNavigation() async { + guard let request = syncService.requestedWorkSessionNavigation else { return } + navigationMutationPending = false + selectedSessionTransitionId = request.sessionId + var fresh = NavigationPath() + fresh.append(WorkSessionRoute(sessionId: request.sessionId)) + path = fresh + syncService.requestedWorkSessionNavigation = nil + } + func deleteChatSession(_ session: TerminalSessionSummary) { Task { do { diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Selection.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Selection.swift index 579f915d5..00d797e14 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Selection.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Selection.swift @@ -8,9 +8,7 @@ extension WorkRootScreen { var bulkSelectedRunningCount: Int { bulkSelectedSessions.filter { session in - guard !isChatSession(session) else { return false } - let status = normalizedWorkChatSessionStatus(session: session, summary: chatSummaries[session.id]) - return status == "active" || status == "awaiting-input" || status == "idle" + isStoppableRuntimeSession(session, summary: chatSummaries[session.id]) }.count } @@ -53,6 +51,26 @@ extension WorkRootScreen { } } + @MainActor + func applyArchivedSessionOverride(sessionIds: Set, archived: Bool) { + guard !sessionIds.isEmpty else { return } + var localIds = Set(archivedSessionIdsStorage.split(separator: "\n").map(String.init)) + if archived { + localIds.formUnion(sessionIds) + } else { + localIds.subtract(sessionIds) + } + archivedSessionIdsStorage = localIds.sorted().joined(separator: "\n") + + let archivedAt = archived ? workDateFormatter.string(from: Date()) : nil + for sessionId in sessionIds { + guard var summary = chatSummaries[sessionId] else { continue } + summary.archivedAt = archivedAt + chatSummaries[sessionId] = summary + } + syncService.cacheChatSummaries(chatSummaries) + } + func exitSelectionMode() { withAnimation(.snappy) { isSelecting = false @@ -63,9 +81,7 @@ extension WorkRootScreen { @MainActor func performBulkStopRuntime() async { let targets = bulkSelectedSessions.filter { session in - guard !isChatSession(session) else { return false } - let status = normalizedWorkChatSessionStatus(session: session, summary: chatSummaries[session.id]) - return status == "active" || status == "awaiting-input" || status == "idle" + isStoppableRuntimeSession(session, summary: chatSummaries[session.id]) } guard !targets.isEmpty else { return } bulkBusy = true @@ -100,21 +116,29 @@ extension WorkRootScreen { bulkBusy = true defer { bulkBusy = false } var failed = 0 - await withTaskGroup(of: Bool.self) { group in + var succeededIds = Set() + await withTaskGroup(of: (String, Bool).self) { group in for session in targets { group.addTask { do { try await syncService.archiveChatSession(sessionId: session.id) - return true + return (session.id, true) } catch { - return false + return (session.id, false) } } } - for await success in group where !success { - failed += 1 + for await (sessionId, success) in group { + if success { + succeededIds.insert(sessionId) + } else { + failed += 1 + } } } + if !succeededIds.isEmpty { + applyArchivedSessionOverride(sessionIds: succeededIds, archived: true) + } await reload(refreshRemote: true) if failed > 0 { bulkActionErrorMessage = "Archive failed for \(failed) chat\(failed == 1 ? "" : "s")." @@ -152,9 +176,7 @@ extension WorkRootScreen { } } if !succeededIds.isEmpty { - var localIds = Set(archivedSessionIdsStorage.split(separator: "\n").map(String.init)) - for sessionId in succeededIds { localIds.remove(sessionId) } - archivedSessionIdsStorage = localIds.sorted().joined(separator: "\n") + applyArchivedSessionOverride(sessionIds: succeededIds, archived: false) } await reload(refreshRemote: true) if failed > 0 { @@ -193,9 +215,7 @@ extension WorkRootScreen { } } if !succeededIds.isEmpty { - var localIds = Set(archivedSessionIdsStorage.split(separator: "\n").map(String.init)) - for sessionId in succeededIds { localIds.remove(sessionId) } - archivedSessionIdsStorage = localIds.sorted().joined(separator: "\n") + applyArchivedSessionOverride(sessionIds: succeededIds, archived: false) } await reload(refreshRemote: true) if failed > 0 { diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 5dd618207..45111b471 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -5,6 +5,21 @@ import AVKit let workDateFormatter = ISO8601DateFormatter() +func resolvedWorkArchivedSessionIds( + localStorage: String, + chatSummaries: [String: AgentChatSessionSummary], + sessions: [TerminalSessionSummary] = [] +) -> Set { + let local = Set(localStorage.split(separator: "\n").map(String.init)) + let archivedChats = Set(chatSummaries.values.compactMap { summary in + summary.archivedAt == nil ? nil : summary.sessionId + }) + let archivedSessions = Set(sessions.compactMap { session in + session.archivedAt == nil ? nil : session.id + }) + return local.union(archivedChats).union(archivedSessions) +} + struct WorkSessionRoute: Hashable { let sessionId: String var openingPrompt: String? = nil @@ -23,6 +38,7 @@ struct WorkRootSessionPresentationTaskKey: Equatable { let selectedLaneId: String let selectedStatus: WorkSessionStatusFilter let searchText: String + let searchOutputRevision: Int? let archivedSessionIdsStorage: String let sessionOrganizationRaw: String } @@ -85,18 +101,11 @@ struct WorkRootScreen: View { } var archivedSessionIds: Set { - let local = Set(archivedSessionIdsStorage.split(separator: "\n").map(String.init)) - var result = Set() - for summary in chatSummaries.values { - if summary.archivedAt != nil { - result.insert(summary.sessionId) - } - } - let remoteKnownIds = Set(chatSummaries.values.map { $0.sessionId }) - for id in local where !remoteKnownIds.contains(id) { - result.insert(id) - } - return result + resolvedWorkArchivedSessionIds( + localStorage: archivedSessionIdsStorage, + chatSummaries: chatSummaries, + sessions: sessions + Array(optimisticSessions.values) + ) } var laneById: [String: LaneSummary] { @@ -196,6 +205,7 @@ struct WorkRootScreen: View { selectedLaneId: selectedLaneId, selectedStatus: selectedStatus, searchText: searchText, + searchOutputRevision: searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : syncService.terminalBufferRevision, archivedSessionIdsStorage: archivedSessionIdsStorage, sessionOrganizationRaw: sessionOrganizationRaw ) @@ -205,6 +215,10 @@ struct WorkRootScreen: View { isWorkRootActive ? syncService.localStateRevision : nil } + var workSessionNavigationRequestKey: String? { + syncService.requestedWorkSessionNavigation?.id + } + var body: some View { NavigationStack(path: $path) { ScrollViewReader { proxy in @@ -452,18 +466,37 @@ struct WorkRootScreen: View { } .task(id: sessionPresentationTaskKey) { scheduleSessionPresentationRebuild() + await hydrateSearchOutputBuffersIfNeeded() } .task(id: pollingKey) { await pollRunningChats() } + .task(id: workSessionNavigationRequestKey) { + guard isTabActive, workSessionNavigationRequestKey != nil else { return } + await handleRequestedWorkSessionNavigation() + } + .onAppear { + guard isTabActive, syncService.requestedWorkSessionNavigation != nil else { return } + Task { await handleRequestedWorkSessionNavigation() } + } + .onChange(of: isTabActive) { _, active in + guard active, syncService.requestedWorkSessionNavigation != nil else { return } + Task { await handleRequestedWorkSessionNavigation() } + } + .onChange(of: syncService.requestedWorkSessionNavigation?.id) { _, requestId in + guard isTabActive, requestId != nil else { return } + Task { await handleRequestedWorkSessionNavigation() } + } .navigationDestination(for: WorkSessionRoute.self) { route in let routeTransitionNamespace = route.openingPrompt == nil && selectedSessionTransitionId == route.sessionId ? (ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? sessionTransitionNamespace : nil) : nil + let initialSession = optimisticSessions[route.sessionId] + ?? mergedSessions.first(where: { $0.id == route.sessionId }) WorkSessionDestinationView( sessionId: route.sessionId, initialOpeningPrompt: route.openingPrompt, - initialSession: mergedSessions.first(where: { $0.id == route.sessionId }), + initialSession: initialSession, initialChatSummary: chatSummaries[route.sessionId], initialTranscript: transcriptCache[route.sessionId], transitionNamespace: routeTransitionNamespace, @@ -516,7 +549,9 @@ struct WorkRootScreen: View { renameTarget = nil } Button("Save") { - Task { await submitRename() } + let target = renameTarget + let title = renameText + Task { await submitRename(target: target, title: title) } } } message: { Text("Give this session a clearer title for search, pinning, and activity tracking.") diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index 8f106cd09..91191099e 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -5,25 +5,40 @@ import AVKit extension WorkSessionDestinationView { @MainActor func sendMessage(_ text: String) async -> Bool { - guard !sending else { return false } + let useSteer = shouldSteerActiveTurn + guard !sending || useSteer else { return false } let text = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return false } guard canSendChatMessages else { return false } + let initialDeliveryState = (sendWillQueueChatMessage || useSteer) ? "queued" : "sending" let echo = WorkLocalEchoMessage( text: text, timestamp: workDateFormatter.string(from: Date()), - deliveryState: sendWillQueueChatMessage ? "queued" : "sending" + deliveryState: initialDeliveryState ) let echoId = echo.id localEchoMessages.append(echo) sending = true defer { sending = false } do { - let delivery = try await syncService.sendChatMessage(sessionId: sessionId, text: text) + let delivery: SyncChatMessageDelivery + if useSteer { + delivery = try await syncService.steerChatSession(sessionId: sessionId, text: text) + } else { + do { + delivery = try await syncService.sendChatMessage(sessionId: sessionId, text: text) + } catch where workChatErrorIndicatesActiveTurn(error) { + updateLocalEchoDeliveryState(echoId: echoId, deliveryState: "queued") + delivery = try await syncService.steerChatSession(sessionId: sessionId, text: text) + } + } switch delivery { - case .queued: + case .queued(let steerId): updateLocalEchoDeliveryState(echoId: echoId, deliveryState: "queued") + if let steerId { + upsertOptimisticPendingSteer(id: steerId, text: text, timestamp: echo.timestamp) + } case .sent: updateLocalEchoDeliveryState(echoId: echoId, deliveryState: nil) await refreshChatStateAfterAction(forceRemote: true) @@ -69,6 +84,7 @@ extension WorkSessionDestinationView { func cancelSteer(_ steerId: String) async { do { try await syncService.cancelChatSteer(sessionId: sessionId, steerId: steerId) + optimisticPendingSteers.removeAll { $0.id == steerId } await refreshChatStateAfterAction(forceRemote: true) errorMessage = nil } catch { @@ -83,6 +99,14 @@ extension WorkSessionDestinationView { guard !trimmed.isEmpty else { return } do { try await syncService.editChatSteer(sessionId: sessionId, steerId: steerId, text: trimmed) + if let index = optimisticPendingSteers.firstIndex(where: { $0.id == steerId }) { + optimisticPendingSteers[index] = WorkPendingSteerModel( + id: steerId, + text: trimmed, + turnId: optimisticPendingSteers[index].turnId, + timestamp: workDateFormatter.string(from: Date()) + ) + } await refreshChatStateAfterAction(forceRemote: true) errorMessage = nil } catch { @@ -95,6 +119,7 @@ extension WorkSessionDestinationView { func dispatchSteerInline(_ steerId: String) async { do { try await syncService.dispatchChatSteer(sessionId: sessionId, steerId: steerId, mode: "inline") + optimisticPendingSteers.removeAll { $0.id == steerId } await refreshChatStateAfterAction(forceRemote: true) errorMessage = nil } catch { @@ -107,6 +132,7 @@ extension WorkSessionDestinationView { func dispatchSteerInterrupt(_ steerId: String) async { do { try await syncService.dispatchChatSteer(sessionId: sessionId, steerId: steerId, mode: "interrupt") + optimisticPendingSteers.removeAll { $0.id == steerId } await refreshChatStateAfterAction(forceRemote: true) errorMessage = nil } catch { @@ -145,59 +171,30 @@ extension WorkSessionDestinationView { @MainActor func selectRuntimeMode(_ modeId: String) async { guard let summary = chatSummary else { return } - // Mirror the per-provider flag mapping from the retired session settings - // sheet so access-pill menu picks land with the same sync payload shape. - var permissionMode: String? - var interactionMode: String? - var claudePermissionMode: String? - var codexApprovalPolicy: String? - var codexSandbox: String? - var codexConfigSource: String? - var opencodePermissionMode: String? - - switch summary.provider.lowercased() { - case "claude": - switch modeId { - case "plan": - interactionMode = "plan" - claudePermissionMode = "default" - permissionMode = "plan" - case "edit": - interactionMode = "default" - claudePermissionMode = "acceptEdits" - permissionMode = "edit" - case "full-auto": - interactionMode = "default" - claudePermissionMode = "bypassPermissions" - permissionMode = "full-auto" - default: - interactionMode = "default" - claudePermissionMode = "default" - permissionMode = "default" - } - case "codex": - let wire = workRuntimeWireFields(provider: summary.provider, mode: modeId) - permissionMode = wire.permissionMode - codexApprovalPolicy = wire.codexApprovalPolicy - codexSandbox = wire.codexSandbox - codexConfigSource = wire.codexConfigSource - case "opencode": - opencodePermissionMode = modeId - permissionMode = modeId - default: - return - } + let wire = workRuntimeWireFields(provider: summary.provider, mode: modeId) + guard wire.permissionMode != nil + || wire.interactionMode != nil + || wire.claudePermissionMode != nil + || wire.codexApprovalPolicy != nil + || wire.codexSandbox != nil + || wire.codexConfigSource != nil + || wire.opencodePermissionMode != nil + || wire.droidPermissionMode != nil + || wire.cursorModeId != nil + else { return } do { _ = try await syncService.updateChatSession( sessionId: sessionId, - permissionMode: permissionMode, - interactionMode: interactionMode, - claudePermissionMode: claudePermissionMode, - codexApprovalPolicy: codexApprovalPolicy, - codexSandbox: codexSandbox, - codexConfigSource: codexConfigSource, - opencodePermissionMode: opencodePermissionMode + permissionMode: wire.permissionMode, + interactionMode: wire.interactionMode, + claudePermissionMode: wire.claudePermissionMode, + codexApprovalPolicy: wire.codexApprovalPolicy, + codexSandbox: wire.codexSandbox, + codexConfigSource: wire.codexConfigSource, + opencodePermissionMode: wire.opencodePermissionMode, + droidPermissionMode: wire.droidPermissionMode, + cursorModeId: wire.cursorModeId ) await refreshChatStateAfterAction(forceRemote: true) errorMessage = nil @@ -422,7 +419,8 @@ extension WorkSessionDestinationView { } func openSessionLane() { - guard let laneId = session?.laneId ?? initialSession?.laneId else { return } + guard let currentSession = session ?? initialSession else { return } + let laneId = resolvedWorkNavigationLaneId(for: currentSession, lanes: lanes) syncService.requestedLaneNavigation = LaneNavigationRequest(laneId: laneId) } } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index a835ac310..4299faffc 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -23,6 +23,86 @@ func workChatSendWillQueueMessage( isLive && !hostReachable && chatSendQueueable } +func workChatLiveObservationKey(sessionId: String, chatEventNotificationRevision: Int) -> String { + "\(sessionId)-\(chatEventNotificationRevision)" +} + +func workChatShouldSteerActiveTurn( + session: TerminalSessionSummary?, + summary: AgentChatSessionSummary? +) -> Bool { + normalizedWorkChatSessionStatus(session: session, summary: summary) == "active" +} + +func workChatSupportsManualSteerDispatch( + session: TerminalSessionSummary?, + summary: AgentChatSessionSummary? +) -> Bool { + let provider = summary?.provider ?? workChatProviderFamilyFromToolType(session?.toolType) + guard let provider else { return false } + return providerFamilyKey(provider) == "claude" +} + +func latestActiveTurnId(from transcript: [WorkChatEnvelope]) -> String? { + for envelope in sortedWorkChatEnvelopes(transcript).reversed() { + switch envelope.event { + case .assistantText(_, let turnId, _), + .activity(_, _, let turnId), + .userMessage(_, let turnId, _, _, _): + if let turnId, !turnId.isEmpty { return turnId } + case .status(_, _, let turnId): + if let turnId, !turnId.isEmpty { return turnId } + default: + continue + } + } + return nil +} + +func transcriptContainsResolvedSteer(_ transcript: [WorkChatEnvelope], steerId: String) -> Bool { + for envelope in sortedWorkChatEnvelopes(transcript).reversed() { + switch envelope.event { + case .userMessage(_, _, let candidate, let deliveryState, _): + guard candidate == steerId else { continue } + return deliveryState != "queued" + case .systemNotice(_, let message, _, _, let candidate): + guard candidate == steerId else { continue } + return workSystemNoticeResolvesQueuedSteer(message) + default: + continue + } + } + return false +} + +func workChatShouldPreferFallbackTranscript( + fallbackTranscript: [WorkChatEnvelope], + sessionStatus: String, + liveTranscript: [WorkChatEnvelope] +) -> Bool { + !fallbackTranscript.isEmpty + && sessionStatus != "active" + && !workTranscriptIndicatesActiveTurn(liveTranscript) +} + +func workChatErrorIndicatesActiveTurn(_ error: Error) -> Bool { + let message = (error as NSError).localizedDescription.lowercased() + return message.contains("turn already active") + || message.contains("turn is already active") + || message.contains("already active") +} + +private func workChatProviderFamilyFromToolType(_ toolType: String?) -> String? { + let raw = toolType?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + guard !raw.isEmpty else { return nil } + if raw == "cursor" || raw.hasPrefix("cursor") { return "cursor" } + if raw.hasPrefix("claude") { return "claude" } + if raw.hasPrefix("codex") { return "codex" } + if raw.hasPrefix("opencode") { return "opencode" } + if raw.hasPrefix("droid") || raw.hasPrefix("factory") { return "droid" } + return raw +} + struct WorkSessionDestinationView: View { @EnvironmentObject var syncService: SyncService @@ -45,6 +125,7 @@ struct WorkSessionDestinationView: View { @State var fallbackEntries: [AgentChatTranscriptEntry] = [] @State var artifacts: [ComputerUseArtifactSummary] = [] @State var localEchoMessages: [WorkLocalEchoMessage] = [] + @State var optimisticPendingSteers: [WorkPendingSteerModel] = [] @State var expandedToolCardIds = Set() @State var artifactContent: [String: WorkLoadedArtifactContent] = [:] @State var artifactContentLoadsInFlight = Set() @@ -56,7 +137,9 @@ struct WorkSessionDestinationView: View { @State var announcedLaneId: String? @State var lastSessionRowRefreshAt = Date.distantPast @State var lastTranscriptRemoteRefreshAt = Date.distantPast + @State var lastCanonicalTranscriptRefreshAt = Date.distantPast @State var lastArtifactRefreshAt = Date.distantPast + @State var canonicalTranscriptRefreshInFlight = false @State var handledOpeningPromptKey: String? @State var stagedOpeningPromptKey: String? @@ -98,29 +181,28 @@ struct WorkSessionDestinationView: View { ) } + var shouldSteerActiveTurn: Bool { + hostReachable && workChatShouldSteerActiveTurn(session: session, summary: chatSummary) + } + + var supportsManualSteerDispatch: Bool { + workChatSupportsManualSteerDispatch(session: session, summary: chatSummary) + } + /// Trailing nav-bar control scoped to the session's lane. The visible branch /// icon keeps it distinct from in-transcript overflow menus. @ViewBuilder var sessionHeaderTrailingControls: some View { if let session, showsLaneActions { - Menu { - Section("Lane") { - Text(session.laneName) - } - Button { - openSessionLane() - } label: { - Label("Go to lane", systemImage: "arrow.triangle.branch") - } - } label: { + Button(action: openSessionLane) { Image(systemName: "arrow.triangle.branch") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(ADEColor.textSecondary) - .frame(width: 28, height: 28) + .frame(width: 34, height: 34) .contentShape(Rectangle()) } - .menuStyle(.borderlessButton) - .accessibilityLabel("Session lane actions") + .buttonStyle(.glass) + .accessibilityLabel("Open lane \(session.laneName)") } else { EmptyView() } @@ -151,6 +233,7 @@ struct WorkSessionDestinationView: View { } .task(id: liveChatObservationKey) { syncTranscriptFromLiveEvents() + await reconcileIdleCanonicalTranscriptIfNeeded() } .task(id: artifactObservationKey) { // Proof rows arrive through CRDT-backed local DB updates, not chat @@ -189,6 +272,7 @@ struct WorkSessionDestinationView: View { transcript: transcript, fallbackEntries: fallbackEntries, artifacts: artifacts, + optimisticPendingSteers: optimisticPendingSteers, localEchoMessages: localEchoMessages, expandedToolCardIds: $expandedToolCardIds, artifactContent: $artifactContent, @@ -200,7 +284,7 @@ struct WorkSessionDestinationView: View { isLive: isLiveAndReachable, canComposeMessages: canComposeChatMessages, canSendMessages: canSendChatMessages, - sendWillQueue: sendWillQueueChatMessage, + sendWillQueue: sendWillQueueChatMessage || shouldSteerActiveTurn, transitionNamespace: transitionNamespace, onOpenLane: showsLaneActions ? openSessionLane : nil, onSend: sendMessage, @@ -219,8 +303,8 @@ struct WorkSessionDestinationView: View { }, onCancelSteer: cancelSteer, onEditSteer: editSteer, - onDispatchSteerInline: dispatchSteerInline, - onDispatchSteerInterrupt: dispatchSteerInterrupt, + onDispatchSteerInline: supportsManualSteerDispatch ? dispatchSteerInline : nil, + onDispatchSteerInterrupt: supportsManualSteerDispatch ? dispatchSteerInterrupt : nil, onSelectModel: selectModel, onSelectRuntimeMode: selectRuntimeMode, onSelectEffort: selectReasoningEffort, @@ -250,7 +334,10 @@ struct WorkSessionDestinationView: View { } var liveChatObservationKey: String { - "\(sessionId)-\(syncService.chatEventNotificationRevision)-\(syncService.chatEventRevision(for: sessionId))" + workChatLiveObservationKey( + sessionId: sessionId, + chatEventNotificationRevision: syncService.chatEventNotificationRevision + ) } var artifactObservationKey: String { @@ -283,9 +370,6 @@ struct WorkSessionDestinationView: View { if let fetchedSummary = try? await syncService.fetchChatSummary(sessionId: sessionId) { chatSummary = fetchedSummary } - if isLiveAndReachable, let currentSession = session ?? initialSession, isChatSession(currentSession) { - try? await syncService.subscribeToChatEvents(sessionId: sessionId) - } if !syncService.prefersReducedSyncLoad { await refreshArtifacts(force: true) } @@ -298,8 +382,18 @@ struct WorkSessionDestinationView: View { @MainActor func loadTranscript(forceRemote: Bool, preferLightweight: Bool = false) async { + let status = normalizedWorkChatSessionStatus(session: session ?? initialSession, summary: chatSummary ?? initialChatSummary) + if forceRemote, let currentSession = session ?? initialSession, isChatSession(currentSession) { - try? await syncService.subscribeToChatEvents(sessionId: sessionId) + if status == "active" { + try? await syncService.subscribeToChatEvents(sessionId: sessionId, requestSnapshot: true) + } else { + // Active streaming stays on reduced snapshots for performance, but an + // idle detail view must reconcile against a full event snapshot. A + // reduced JSONL tail can start mid-message and render as a broken + // final transcript until the canonical transcript fetch lands. + try? await syncService.requestFullChatEventSnapshot(sessionId: sessionId) + } } let liveTranscript = makeWorkChatTranscript(from: syncService.chatEventHistory(sessionId: sessionId)) @@ -312,8 +406,16 @@ struct WorkSessionDestinationView: View { // in the fallback render path. var fetchedFallbackEntriesAvailable = false - let shouldFetchFallback = !preferLightweight || (liveTranscript.isEmpty && transcript.isEmpty) - if shouldFetchFallback, let response = try? await syncService.fetchChatTranscriptResponse(sessionId: sessionId) { + // Reduced-load mode skips heavy transcript fetches during live streaming, + // but once a session is idle the phone must reconcile with the canonical + // host transcript. Live event snapshots can be byte-capped tails of a long + // answer, which are useful while streaming but not enough for final copy + // or history. + let shouldFetchFallback = !preferLightweight + || (liveTranscript.isEmpty && transcript.isEmpty) + || (!liveTranscript.isEmpty && status != "active") + let fallbackMaxChars = status == "active" ? 32_000 : 120_000 + if shouldFetchFallback, let response = try? await syncService.fetchChatTranscriptResponse(sessionId: sessionId, maxChars: fallbackMaxChars) { fetchedFallbackEntries = response.entries fetchedFallbackEntriesAvailable = true fallbackTranscript = makeWorkChatTranscript(from: response.entries, sessionId: sessionId) @@ -332,10 +434,25 @@ struct WorkSessionDestinationView: View { eventTranscript = mergeWorkChatTranscripts(base: eventTranscript, live: liveTranscript) } + let canonicalEventTranscript: [WorkChatEnvelope] + if !fallbackTranscript.isEmpty, status != "active" { + canonicalEventTranscript = eventTranscript.filter { envelope in + switch envelope.event { + case .userMessage, .assistantText, .status: + return false + default: + return true + } + } + } else { + canonicalEventTranscript = eventTranscript + } + + let mergeBaseTranscript = !fallbackTranscript.isEmpty && status != "active" ? [] : transcript let mergedTranscript = preferredWorkTranscript( - current: transcript, + current: mergeBaseTranscript, fallback: fallbackTranscript, - eventTranscript: eventTranscript + eventTranscript: canonicalEventTranscript ) if !mergedTranscript.isEmpty, mergedTranscript != transcript { transcript = mergedTranscript @@ -425,21 +542,35 @@ struct WorkSessionDestinationView: View { }) { echo = existingEcho } else { + let useSteer = shouldSteerActiveTurn let nextEcho = WorkLocalEchoMessage( text: prompt, timestamp: workDateFormatter.string(from: Date()), - deliveryState: sendWillQueueChatMessage ? "queued" : "sending" + deliveryState: (sendWillQueueChatMessage || useSteer) ? "queued" : "sending" ) localEchoMessages.append(nextEcho) echo = nextEcho } - updateLocalEchoDeliveryState(echoId: echo.id, deliveryState: sendWillQueueChatMessage ? "queued" : "sending") - sending = true + let useSteer = shouldSteerActiveTurn + updateLocalEchoDeliveryState(echoId: echo.id, deliveryState: (sendWillQueueChatMessage || useSteer) ? "queued" : "sending") do { - let delivery = try await syncService.sendChatMessage(sessionId: sessionId, text: prompt) + let delivery: SyncChatMessageDelivery + if useSteer { + delivery = try await syncService.steerChatSession(sessionId: sessionId, text: prompt) + } else { + do { + delivery = try await syncService.sendChatMessage(sessionId: sessionId, text: prompt) + } catch where workChatErrorIndicatesActiveTurn(error) { + updateLocalEchoDeliveryState(echoId: echo.id, deliveryState: "queued") + delivery = try await syncService.steerChatSession(sessionId: sessionId, text: prompt) + } + } switch delivery { - case .queued: + case .queued(let steerId): updateLocalEchoDeliveryState(echoId: echo.id, deliveryState: "queued") + if let steerId { + upsertOptimisticPendingSteer(id: steerId, text: prompt, timestamp: echo.timestamp) + } case .sent: updateLocalEchoDeliveryState(echoId: echo.id, deliveryState: nil) await refreshChatStateAfterAction(forceRemote: true) @@ -450,7 +581,6 @@ struct WorkSessionDestinationView: View { localEchoMessages.removeAll { $0.id == echo.id } errorMessage = "Opening message did not reach the machine. The chat exists; tap Send to retry. \(error.localizedDescription)" } - sending = false } @MainActor @@ -460,10 +590,11 @@ struct WorkSessionDestinationView: View { let promptKey = "\(sessionId)|\(prompt)" guard stagedOpeningPromptKey != promptKey else { return } stagedOpeningPromptKey = promptKey + let useSteer = shouldSteerActiveTurn localEchoMessages.append(WorkLocalEchoMessage( text: prompt, timestamp: workDateFormatter.string(from: Date()), - deliveryState: sendWillQueueChatMessage ? "queued" : "sending" + deliveryState: (sendWillQueueChatMessage || useSteer) ? "queued" : "sending" )) } @@ -471,17 +602,86 @@ struct WorkSessionDestinationView: View { func syncTranscriptFromLiveEvents() { let liveTranscript = makeWorkChatTranscript(from: syncService.chatEventHistory(sessionId: sessionId)) guard !liveTranscript.isEmpty else { return } + let fallbackTranscript = makeWorkChatTranscript(from: fallbackEntries, sessionId: sessionId) + let status = normalizedWorkChatSessionStatus(session: session ?? initialSession, summary: chatSummary ?? initialChatSummary) + let shouldPreferFallbackTranscript = workChatShouldPreferFallbackTranscript( + fallbackTranscript: fallbackTranscript, + sessionStatus: status, + liveTranscript: liveTranscript + ) + let canonicalLiveTranscript: [WorkChatEnvelope] + if shouldPreferFallbackTranscript { + canonicalLiveTranscript = liveTranscript.filter { envelope in + switch envelope.event { + case .userMessage, .assistantText, .status: + return false + default: + return true + } + } + } else { + canonicalLiveTranscript = liveTranscript + } + let mergeBaseTranscript = shouldPreferFallbackTranscript ? [] : transcript let mergedTranscript = preferredWorkTranscript( - current: transcript, - fallback: makeWorkChatTranscript(from: fallbackEntries, sessionId: sessionId), - eventTranscript: liveTranscript + current: mergeBaseTranscript, + fallback: fallbackTranscript, + eventTranscript: canonicalLiveTranscript ) if mergedTranscript != transcript { transcript = mergedTranscript } + reconcileOptimisticPendingSteers(with: mergedTranscript) reconcileLocalEchoMessages() } + @MainActor + func upsertOptimisticPendingSteer(id: String, text: String, timestamp: String) { + let turnId = latestActiveTurnId(from: transcript) + let model = WorkPendingSteerModel(id: id, text: text, turnId: turnId, timestamp: timestamp) + if let index = optimisticPendingSteers.firstIndex(where: { $0.id == id }) { + optimisticPendingSteers[index] = model + } else { + optimisticPendingSteers.append(model) + } + } + + @MainActor + func reconcileOptimisticPendingSteers(with transcript: [WorkChatEnvelope]) { + guard !optimisticPendingSteers.isEmpty else { return } + let pendingIds = Set(derivePendingWorkSteers(from: transcript).map(\.id)) + optimisticPendingSteers.removeAll { steer in + transcriptContainsResolvedSteer(transcript, steerId: steer.id) || pendingIds.contains(steer.id) + } + } + + @MainActor + func reconcileIdleCanonicalTranscriptIfNeeded() async { + guard !canonicalTranscriptRefreshInFlight else { return } + + // We used to bail when fallbackEntries was non-empty, but loadTranscript + // now populates fallbackEntries during active sessions too — so a populated + // cache no longer means "we already reconciled". Rely on the active-status + // gate plus the 6s debounce below to throttle work instead. + let status = normalizedWorkChatSessionStatus(session: session ?? initialSession, summary: chatSummary ?? initialChatSummary) + guard status != "active" else { return } + + let liveTranscript = makeWorkChatTranscript(from: syncService.chatEventHistory(sessionId: sessionId)) + guard !workTranscriptIndicatesActiveTurn(liveTranscript) else { return } + + let hasLiveOrCachedText = !liveTranscript.isEmpty || !transcript.isEmpty + guard hasLiveOrCachedText else { return } + + let now = Date() + guard now.timeIntervalSince(lastCanonicalTranscriptRefreshAt) >= 6 else { return } + + canonicalTranscriptRefreshInFlight = true + lastCanonicalTranscriptRefreshAt = now + defer { canonicalTranscriptRefreshInFlight = false } + + await loadTranscript(forceRemote: isLiveAndReachable, preferLightweight: false) + } + @MainActor func reconcileLocalEchoMessages() { guard !localEchoMessages.isEmpty else { return } @@ -550,24 +750,41 @@ private struct WorkSessionNavigationChromeModifier: View switch mode { case .pushedDetail: content - .navigationTitle(title) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(true) - .toolbar(.hidden, for: .tabBar) - .toolbar { - ToolbarItem(placement: .topBarLeading) { + .safeAreaInset(edge: .top, spacing: 0) { + HStack(spacing: 10) { Button { dismiss() } label: { - Label("Work", systemImage: "chevron.left") - .labelStyle(.titleAndIcon) + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .semibold)) + .frame(width: 38, height: 38) } + .buttonStyle(.glass) .accessibilityLabel("Back to Work") - } - ToolbarItem(placement: .topBarTrailing) { + + Text(title) + .font(.headline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: 0) + trailingControls() } + .padding(.horizontal, 16) + .padding(.bottom, 8) + .background { + ADEColor.pageBackground + .opacity(0.98) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) + } } + .navigationTitle("") + .toolbar(.hidden, for: .tabBar) + .toolbar(.hidden, for: .navigationBar) + .adeRootTabBarHidden() case .embedded: content } diff --git a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift index 6dd924e22..87c72c7af 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift @@ -76,6 +76,7 @@ func buildWorkRootSessionPresentation( selectedStatus: WorkSessionStatusFilter, selectedLaneId: String, searchText: String, + outputSearchBySessionId: [String: String] = [:], organization: WorkSessionOrganization, orderedLanes: [LaneSummary] ) -> WorkRootSessionPresentation { @@ -90,7 +91,8 @@ func buildWorkRootSessionPresentation( archivedSessionIds: archivedSessionIds, selectedStatus: selectedStatus, selectedLaneId: selectedLaneId, - searchText: searchText + searchText: searchText, + outputSearchBySessionId: outputSearchBySessionId ) var liveChatSessions: [TerminalSessionSummary] = [] diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift index 372baf9be..008c2fcb1 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift @@ -224,18 +224,24 @@ struct WorkSessionSettingsSheet: View { if let reasoningEfforts = selectedModel?.reasoningEfforts, !reasoningEfforts.isEmpty { GlassSection(title: "Reasoning") { VStack(alignment: .leading, spacing: 12) { - Picker("Reasoning", selection: $selectedReasoningEffort) { - Text("Default").tag("") - ForEach(reasoningEfforts) { effort in - Text(effort.effort.capitalized).tag(effort.effort) - } + ADEOptionButton( + title: "Default", + subtitle: "Use the runtime default for this model.", + systemImage: "sparkle", + isSelected: selectedReasoningEffort.isEmpty + ) { + selectedReasoningEffort = "" } - .pickerStyle(.segmented) - if let effort = reasoningEfforts.first(where: { $0.effort == selectedReasoningEffort }) { - Text(effort.description) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) + ForEach(reasoningEfforts) { effort in + ADEOptionButton( + title: effort.effort.capitalized, + subtitle: effort.description, + systemImage: "brain.head.profile", + isSelected: selectedReasoningEffort == effort.effort + ) { + selectedReasoningEffort = effort.effort + } } } } diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index 587336840..171ede2fa 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -20,6 +20,30 @@ func isRunOwnedSession(_ session: TerminalSessionSummary) -> Bool { .lowercased() == "run-shell" } +func isStoppableRuntimeSession(_ session: TerminalSessionSummary, summary: AgentChatSessionSummary? = nil) -> Bool { + guard !isChatSession(session) else { return false } + let status = normalizedWorkChatSessionStatus(session: session, summary: summary) + return status == "active" || status == "awaiting-input" || status == "idle" +} + +func workChatComposerBlocksFreeformInput(pendingInputCount: Int, sessionStatus: String) -> Bool { + pendingInputCount > 0 || sessionStatus == "awaiting-input" +} + +func workChatAwaitingPromptDetailsMissing(pendingInputCount: Int, sessionStatus: String) -> Bool { + pendingInputCount == 0 && sessionStatus == "awaiting-input" +} + +func workChatComposerPlaceholder(pendingInputCount: Int, sessionStatus: String) -> String { + if workChatAwaitingPromptDetailsMissing(pendingInputCount: pendingInputCount, sessionStatus: sessionStatus) { + return "Waiting for prompt details..." + } + if workChatComposerBlocksFreeformInput(pendingInputCount: pendingInputCount, sessionStatus: sessionStatus) { + return "Answer the prompt above..." + } + return "Type to vibecode..." +} + func terminalSessionHasResumeTarget(_ session: TerminalSessionSummary) -> Bool { if session.resumeMetadata != nil { return true @@ -203,6 +227,31 @@ private func rawWorkChatSessionStatus(session: TerminalSessionSummary?, summary: if summary?.awaitingInput == true { return "awaiting-input" } + if let session { + let sessionStatus = session.status.lowercased() + let runtimeState = session.runtimeState.lowercased() + if sessionStatus == "awaiting-input" || sessionStatus == "awaiting_input" || runtimeState == "waiting-input" { + return "awaiting-input" + } + switch runtimeState { + case "idle": + return "idle" + case "running": + return "active" + case "stopped", "exited", "completed", "failed", "interrupted": + return "ended" + default: + if sessionStatus == "running" { + return "active" + } + if sessionStatus == "idle" || sessionStatus == "paused" { + return "idle" + } + if sessionStatus == "ended" || sessionStatus == "completed" || sessionStatus == "failed" || sessionStatus == "interrupted" || sessionStatus == "exited" { + return "ended" + } + } + } if let status = summary?.status.lowercased() { switch status { case "active", "running": @@ -217,16 +266,7 @@ private func rawWorkChatSessionStatus(session: TerminalSessionSummary?, summary: } guard let session else { return "ended" } - switch session.runtimeState.lowercased() { - case "waiting-input": - return "awaiting-input" - case "idle": - return "idle" - case "running": - return "active" - default: - return session.status == "running" ? "active" : "ended" - } + return session.status.lowercased() == "running" ? "active" : "ended" } func normalizedRuntimeState(for summary: AgentChatSessionSummary) -> String { @@ -286,6 +326,7 @@ func workRuntimeModeOptions(provider: String) -> [WorkRuntimeModeOption] { case "claude": return [ WorkRuntimeModeOption(id: "default", title: "Default"), + WorkRuntimeModeOption(id: "auto", title: "Auto"), WorkRuntimeModeOption(id: "plan", title: "Plan"), WorkRuntimeModeOption(id: "edit", title: "Auto edit"), WorkRuntimeModeOption(id: "full-auto", title: "Bypass"), @@ -303,6 +344,20 @@ func workRuntimeModeOptions(provider: String) -> [WorkRuntimeModeOption] { WorkRuntimeModeOption(id: "edit", title: "Edit"), WorkRuntimeModeOption(id: "full-auto", title: "Full auto"), ] + case "cursor": + return [ + WorkRuntimeModeOption(id: "agent", title: "Agent"), + WorkRuntimeModeOption(id: "ask", title: "Ask"), + WorkRuntimeModeOption(id: "plan", title: "Plan"), + WorkRuntimeModeOption(id: "full-auto", title: "Full auto"), + ] + case "droid", "factory": + return [ + WorkRuntimeModeOption(id: "read-only", title: "Read-only"), + WorkRuntimeModeOption(id: "auto-low", title: "Auto low"), + WorkRuntimeModeOption(id: "auto-medium", title: "Auto medium"), + WorkRuntimeModeOption(id: "auto-high", title: "Auto high"), + ] default: return [] } @@ -312,6 +367,7 @@ func workRuntimeModeLabel(provider: String, mode: String) -> String { switch provider.lowercased() { case "claude": switch mode { + case "auto": return "Auto" case "plan": return "Plan" case "edit": return "Auto edit" case "full-auto": return "Bypass" @@ -326,6 +382,16 @@ func workRuntimeModeLabel(provider: String, mode: String) -> String { } case "opencode": return mode.isEmpty ? "Edit" : mode.capitalized + case "cursor": + return workCursorModeLabel(mode.isEmpty ? "agent" : mode) + case "droid", "factory": + switch mode { + case "read-only": return "Read-only" + case "auto-low": return "Auto low" + case "auto-medium": return "Auto medium" + case "auto-high": return "Auto high" + default: return mode.isEmpty ? "Auto low" : mode.capitalized + } default: return mode.isEmpty ? "Access" : mode.capitalized } @@ -333,19 +399,22 @@ func workRuntimeModeLabel(provider: String, mode: String) -> String { func workRuntimeModeTint(_ mode: String) -> Color { switch mode { - case "full-auto": return ADEColor.danger - case "edit": return ADEColor.warning - case "plan": return ADEColor.accent + case "full-auto", "auto-high": return ADEColor.danger + case "edit", "auto", "ask", "auto-low", "auto-medium": return ADEColor.warning + case "plan", "read-only": return ADEColor.accent default: return ADEColor.textSecondary } } -/// Default runtime mode for a fresh chat given the provider. "default" for Claude/Codex, -/// "edit" for OpenCode (matches desktop's new-session defaults). +/// Default runtime mode for a fresh chat given the provider. Mirrors the +/// desktop/TUI defaults: Default for Claude/Codex, Edit for OpenCode, Agent +/// for Cursor, and Auto low for Droid. func workDefaultRuntimeMode(provider: String) -> String { switch provider.lowercased() { case "claude", "codex": return "default" case "opencode": return "edit" + case "cursor": return "agent" + case "droid", "factory": return "auto-low" default: return "" } } @@ -359,6 +428,8 @@ struct WorkRuntimeWireFields { var codexSandbox: String? var codexConfigSource: String? var opencodePermissionMode: String? + var droidPermissionMode: String? + var cursorModeId: String? } func workRuntimeWireFields(provider: String, mode: String) -> WorkRuntimeWireFields { @@ -366,6 +437,10 @@ func workRuntimeWireFields(provider: String, mode: String) -> WorkRuntimeWireFie switch provider.lowercased() { case "claude": switch mode { + case "auto": + fields.interactionMode = "default" + fields.claudePermissionMode = "auto" + fields.permissionMode = "auto" case "plan": fields.interactionMode = "plan" fields.claudePermissionMode = "default" @@ -407,6 +482,30 @@ func workRuntimeWireFields(provider: String, mode: String) -> WorkRuntimeWireFie case "opencode": fields.opencodePermissionMode = mode fields.permissionMode = mode + case "cursor": + fields.cursorModeId = mode.isEmpty ? "agent" : mode + switch fields.cursorModeId { + case "plan": + fields.permissionMode = "plan" + case "ask": + fields.permissionMode = "edit" + case "full-auto": + fields.permissionMode = "full-auto" + default: + fields.permissionMode = "default" + } + case "droid", "factory": + fields.droidPermissionMode = mode.isEmpty ? "auto-low" : mode + switch fields.droidPermissionMode { + case "read-only": + fields.permissionMode = "plan" + case "auto-medium": + fields.permissionMode = "default" + case "auto-high": + fields.permissionMode = "full-auto" + default: + fields.permissionMode = "edit" + } default: break } @@ -435,6 +534,9 @@ func workInitialRuntimeMode(_ summary: AgentChatSessionSummary) -> String { if summary.interactionMode == "plan" || summary.permissionMode == "plan" { return "plan" } + if summary.claudePermissionMode == "auto" || summary.permissionMode == "auto" { + return "auto" + } if summary.claudePermissionMode == "bypassPermissions" || summary.permissionMode == "full-auto" { return "full-auto" } @@ -455,11 +557,25 @@ func workInitialRuntimeMode(_ summary: AgentChatSessionSummary) -> String { return "default" case "opencode": return summary.opencodePermissionMode ?? summary.permissionMode ?? "edit" + case "cursor": + return summary.cursorModeId ?? workCursorCurrentModeId(summary.cursorModeSnapshot) ?? "agent" + case "droid", "factory": + return summary.droidPermissionMode ?? workDroidModeFromPermissionMode(summary.permissionMode) ?? "auto-low" default: return "" } } +func workDroidModeFromPermissionMode(_ permissionMode: String?) -> String? { + switch permissionMode { + case "plan": return "read-only" + case "edit": return "auto-low" + case "default": return "auto-medium" + case "full-auto": return "auto-high" + default: return nil + } +} + func workInitialCursorModeId(_ summary: AgentChatSessionSummary) -> String { summary.cursorModeId ?? workCursorCurrentModeId(summary.cursorModeSnapshot) ?? "agent" } diff --git a/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift b/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift index 75b712e05..24d813022 100644 --- a/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift +++ b/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift @@ -45,22 +45,30 @@ struct WorkTerminalEmulatorView: UIViewRepresentable { self.onViewportChange = onViewportChange } - func updateViewport(_ viewport: WorkTerminalViewport, rawText: String, revision: Int, view: ADETerminalTextView) { - guard viewport != lastViewport else { return } + @discardableResult + func updateViewport(_ viewport: WorkTerminalViewport, rawText: String, revision: Int, view: ADETerminalTextView) -> Bool { + guard viewport != lastViewport else { return false } lastViewport = viewport let columnsChanged = screen.resize(cols: viewport.cols) - if columnsChanged || revision != lastRevision || rawText != lastRawText { + var didRender = false + if columnsChanged || rawText != lastRawText { screen.reset() screen.write(rawText) lastRawText = rawText - lastRevision = revision + view.render(screen.attributedString(font: view.terminalFont)) + didRender = true } - view.render(screen.attributedString(font: view.terminalFont)) + lastRevision = revision onViewportChange(viewport) + return didRender } - func render(rawText: String, revision: Int, in view: ADETerminalTextView) { - guard revision != lastRevision || rawText != lastRawText else { return } + @discardableResult + func render(rawText: String, revision: Int, in view: ADETerminalTextView) -> Bool { + guard rawText != lastRawText else { + lastRevision = revision + return false + } defer { lastRevision = revision lastRawText = rawText @@ -74,6 +82,7 @@ struct WorkTerminalEmulatorView: UIViewRepresentable { screen.write(rawText) } view.render(screen.attributedString(font: view.terminalFont)) + return true } } } @@ -84,6 +93,8 @@ final class ADETerminalTextView: UIView { private let textView = UITextView() private var lastViewport: WorkTerminalViewport? + private var hasRenderedContent = false + private var pendingScrollToBottom = false override init(frame: CGRect) { super.init(frame: frame) @@ -119,26 +130,45 @@ final class ADETerminalTextView: UIView { override func layoutSubviews() { super.layoutSubviews() + if pendingScrollToBottom { + scrollToBottom() + } publishViewportIfNeeded() } func render(_ attributed: NSAttributedString) { let nearBottom = textView.contentOffset.y + textView.bounds.height >= textView.contentSize.height - 80 + let shouldStickToBottom = !hasRenderedContent || pendingScrollToBottom || nearBottom textView.attributedText = attributed - if nearBottom { - let bottomY = max(-textView.adjustedContentInset.top, textView.contentSize.height - textView.bounds.height + textView.adjustedContentInset.bottom) - textView.setContentOffset(CGPoint(x: 0, y: bottomY), animated: false) + hasRenderedContent = true + if shouldStickToBottom { + scrollToBottom() } publishViewportIfNeeded() } + private func scrollToBottom() { + guard textView.bounds.height > 1 else { + pendingScrollToBottom = true + return + } + textView.layoutIfNeeded() + let bottomY = max( + -textView.adjustedContentInset.top, + textView.contentSize.height - textView.bounds.height + textView.adjustedContentInset.bottom + ) + textView.setContentOffset(CGPoint(x: 0, y: bottomY), animated: false) + pendingScrollToBottom = false + } + private func publishViewportIfNeeded() { guard bounds.width > 1, bounds.height > 1 else { return } let charSize = ("W" as NSString).size(withAttributes: [.font: terminalFont]) let usableWidth = max(1, bounds.width - textView.textContainerInset.left - textView.textContainerInset.right) let usableHeight = max(1, bounds.height - textView.textContainerInset.top - textView.textContainerInset.bottom) + let fittedCols = Int(floor(usableWidth / max(1, charSize.width))) - 2 let viewport = WorkTerminalViewport( - cols: max(20, min(240, Int(floor(usableWidth / max(1, charSize.width))))), + cols: max(20, min(240, fittedCols)), rows: max(4, min(120, Int(floor(usableHeight / max(1, terminalFont.lineHeight))))) ) guard viewport != lastViewport else { return } diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 583f3f4e1..e27a54522 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -648,6 +648,141 @@ private func laterWorkTimestamp(_ lhs: String, _ rhs: String) -> String { return lhs } +private func approvalRequestEventCard( + id: String, + timestamp: String, + description: String, + detail: String?, + itemId: String +) -> WorkEventCardModel { + if let planApproval = pendingWorkPlanApprovalFromApproval(description: description, detail: detail, itemId: itemId) { + return WorkEventCardModel( + id: id, + kind: "planApproval", + title: "Plan approval requested", + icon: "list.clipboard", + tint: .warning, + timestamp: timestamp, + body: nonEmptyWorkTimelineText(planApproval.title) ?? nonEmptyWorkTimelineText(description), + bullets: workTimelinePlanBullets(from: planApproval.planText), + metadata: nonEmptyWorkTimelineText(planApproval.source).map { [$0] } ?? [] + ) + } + + if let question = pendingWorkQuestionFromApproval(description: description, detail: detail, itemId: itemId) { + return WorkEventCardModel( + id: id, + kind: "question", + title: "Question asked", + icon: "questionmark.circle", + tint: .warning, + timestamp: timestamp, + body: workApprovalRequestBody(primary: question.title, secondary: question.body, fallback: description), + bullets: workTimelineQuestionBullets(from: question), + metadata: [] + ) + } + + if let permission = pendingWorkPermissionFromApproval(description: description, detail: detail, itemId: itemId) { + return WorkEventCardModel( + id: id, + kind: "permission", + title: "Permission requested", + icon: "hand.raised.fill", + tint: .warning, + timestamp: timestamp, + body: workApprovalRequestBody(primary: permission.description, secondary: permission.detail, fallback: description), + bullets: [], + metadata: [permission.tool].compactMap(nonEmptyWorkTimelineText) + ) + } + + return WorkEventCardModel( + id: id, + kind: "approval", + title: "Approval needed", + icon: "checkmark.shield", + tint: .warning, + timestamp: timestamp, + body: nonEmptyWorkTimelineText(description), + bullets: genericApprovalDetailBullets(from: detail), + metadata: [] + ) +} + +private func workApprovalRequestBody(primary: String?, secondary: String?, fallback: String) -> String? { + var pieces: [String] = [] + for candidate in [primary, secondary, fallback] { + guard let text = nonEmptyWorkTimelineText(candidate) else { continue } + let alreadyIncluded = pieces.contains { existing in + existing.caseInsensitiveCompare(text) == .orderedSame + } + if !alreadyIncluded { + pieces.append(text) + } + } + guard !pieces.isEmpty else { return nil } + return pieces.prefix(2).joined(separator: "\n") +} + +private func workTimelineQuestionBullets(from model: WorkPendingQuestionModel) -> [String] { + model.questions.prefix(4).map { question in + let questionText = question.isSecret + ? "Secure response requested" + : (nonEmptyWorkTimelineText(question.question) ?? "Response requested") + var text = question.header.map { "\($0): \(questionText)" } ?? questionText + let options = question.options + .compactMap { nonEmptyWorkTimelineText($0.label) } + .prefix(4) + .joined(separator: ", ") + if !options.isEmpty { + text += " Options: \(options)" + } else if question.allowsFreeform { + text += " Freeform response allowed." + } + return truncatedWorkTimelineText(text, limit: 220) + } +} + +private func workTimelinePlanBullets(from planText: String) -> [String] { + planText + .components(separatedBy: .newlines) + .compactMap { raw -> String? in + let text = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: #"^[-*]\s+"#, with: "", options: .regularExpression) + .replacingOccurrences(of: #"^\d+\.\s+"#, with: "", options: .regularExpression) + return nonEmptyWorkTimelineText(text) + } + .prefix(4) + .map { truncatedWorkTimelineText($0, limit: 180) } +} + +private func genericApprovalDetailBullets(from detail: String?) -> [String] { + guard let detail = nonEmptyWorkTimelineText(detail) else { return [] } + if let object = workJSONObject(from: detail) { + let request = object["request"] as? [String: Any] ?? object + return [ + optionalString(request["description"]), + optionalString(request["tool"]) ?? optionalString(request["toolName"]), + ] + .compactMap(nonEmptyWorkTimelineText) + .map { truncatedWorkTimelineText($0, limit: 180) } + } + return [truncatedWorkTimelineText(detail, limit: 240)] +} + +private func nonEmptyWorkTimelineText(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed +} + +private func truncatedWorkTimelineText(_ value: String, limit: Int) -> String { + guard value.count > limit, limit > 3 else { return value } + return "\(value.prefix(limit - 3))..." +} + private func mergedWorkEventCard(_ existing: WorkEventCardModel, with incoming: WorkEventCardModel) -> WorkEventCardModel? { guard existing.kind == incoming.kind else { return nil } if existing.kind == "reasoning" { @@ -723,17 +858,13 @@ private func eventCard(for envelope: WorkChatEnvelope) -> WorkEventCardModel? { bullets: [], metadata: [] ) - case .approvalRequest(let description, let detail, _, _): - return WorkEventCardModel( + case .approvalRequest(let description, let detail, let itemId, _): + return approvalRequestEventCard( id: envelope.id, - kind: "approval", - title: "Approval needed", - icon: "checkmark.shield", - tint: .warning, timestamp: envelope.timestamp, - body: description, - bullets: detail.map { [$0] } ?? [], - metadata: [] + description: description, + detail: detail, + itemId: itemId ) case .pendingInputResolved(_, let resolution, _): return WorkEventCardModel( diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 4da99ba5d..b75a61fc9 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -1,5 +1,6 @@ import XCTest import SQLite3 +import UIKit @testable import ADE /// Default `lastActivityAt`/`startedAt` for fixture sessions. Returns "now" @@ -60,9 +61,206 @@ final class ADETests: XCTestCase { XCTAssertEqual(output, "errok") } + @MainActor + func testDeepLinkRouterRequestsWorkSessionNavigation() throws { + let previousShared = SyncService.shared + defer { SyncService.shared = previousShared } + + let database = makeDatabase(baseURL: makeTemporaryDirectory()) + defer { database.close() } + let service = SyncService(database: database) + // Bind explicitly so the deep-link router routes through our test instance instead + // of relying on initializer side effects to update SyncService.shared. + SyncService.shared = service + service.requestedWorkSessionNavigation = nil + + DeepLinkRouter.shared.handle(try XCTUnwrap(URL(string: "ade://session/session-123"))) + + XCTAssertEqual(service.requestedWorkSessionNavigation?.sessionId, "session-123") + } + + @MainActor + func testTerminalEmulatorSkipsDuplicateRevisionRenders() { + let view = ADETerminalTextView(frame: CGRect(x: 0, y: 0, width: 320, height: 300)) + let coordinator = WorkTerminalEmulatorView.Coordinator { _ in } + + XCTAssertTrue(coordinator.render(rawText: "bash-3.2$ echo ok\nok\n", revision: 1, in: view)) + XCTAssertFalse(coordinator.render(rawText: "bash-3.2$ echo ok\nok\n", revision: 2, in: view)) + XCTAssertTrue(coordinator.render(rawText: "bash-3.2$ echo ok\nok\nbash-3.2$ ", revision: 3, in: view)) + } + + @MainActor + func testTerminalViewportRevisionOnlyUpdateDoesNotRerender() { + let view = ADETerminalTextView(frame: CGRect(x: 0, y: 0, width: 320, height: 300)) + let coordinator = WorkTerminalEmulatorView.Coordinator { _ in } + + XCTAssertTrue( + coordinator.updateViewport( + WorkTerminalViewport(cols: 48, rows: 12), + rawText: "one\n", + revision: 1, + view: view + ) + ) + XCTAssertFalse( + coordinator.updateViewport( + WorkTerminalViewport(cols: 48, rows: 13), + rawText: "one\n", + revision: 2, + view: view + ) + ) + } + + func testNotificationPreferencesSavePrunesInactivePerSessionOverrides() throws { + let suiteName = "ADETests.NotificationPreferences.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + var prefs = NotificationPreferences() + prefs.perSessionOverrides = [ + "inactive": SessionNotificationOverride(), + "muted": SessionNotificationOverride(muted: true), + "awaiting": SessionNotificationOverride(awaitingInputOnly: true), + ] + + prefs.save(to: defaults) + + let loaded = NotificationPreferences.load(from: defaults) + XCTAssertNil(loaded.perSessionOverrides["inactive"]) + XCTAssertEqual(loaded.perSessionOverrides["muted"], SessionNotificationOverride(muted: true)) + XCTAssertEqual(loaded.perSessionOverrides["awaiting"], SessionNotificationOverride(awaitingInputOnly: true)) + } + + @MainActor + func testSyncNotificationPrefsPayloadOmitsInactivePerSessionOverrides() { + var prefs = NotificationPreferences() + prefs.perSessionOverrides = [ + "inactive": SessionNotificationOverride(), + "active": SessionNotificationOverride(awaitingInputOnly: true), + ] + + let payload = SyncService.encodeNotificationPrefsForDesktop(prefs) + let overrides = payload["perSessionOverrides"] as? [String: [String: Bool]] + + XCTAssertNil(overrides?["inactive"]) + XCTAssertEqual(overrides?["active"]?["muted"], false) + XCTAssertEqual(overrides?["active"]?["awaitingInputOnly"], true) + } + + func testNotificationStaleOverrideIdsKeepSavedOverridesVisible() { + let agent = AgentSnapshot( + sessionId: "active-session", + provider: "codex", + laneName: "Primary", + title: "Active", + status: "idle", + awaitingInput: false, + lastActivityAt: Date(), + elapsedSeconds: 0, + preview: nil, + progress: nil, + phase: nil, + toolCalls: 0 + ) + + let staleIds = notificationStaleOverrideIds( + overrides: [ + "inactive-stale": SessionNotificationOverride(), + "active-session": SessionNotificationOverride(muted: true), + "saved-stale": SessionNotificationOverride(awaitingInputOnly: true), + ], + agents: [agent] + ) + + XCTAssertEqual(staleIds, ["saved-stale"]) + } + func testShellCliPermissionModeDoesNotInheritRuntimeMode() { XCTAssertNil(workCliPermissionMode(provider: "shell", runtimeMode: "plan")) XCTAssertEqual(workCliPermissionMode(provider: "codex", runtimeMode: "plan"), "plan") + XCTAssertEqual(workCliPermissionMode(provider: "claude", runtimeMode: "auto"), "auto") + } + + func testMobileRuntimeModeOptionsMirrorDesktopAndTuiProviders() { + XCTAssertEqual(workRuntimeModeOptions(provider: "claude").map(\.id), ["default", "auto", "plan", "edit", "full-auto"]) + XCTAssertEqual(workRuntimeModeOptions(provider: "codex").map(\.id), ["default", "plan", "full-auto", "config-toml"]) + XCTAssertEqual(workRuntimeModeOptions(provider: "opencode").map(\.id), ["plan", "edit", "full-auto"]) + XCTAssertEqual(workRuntimeModeOptions(provider: "cursor").map(\.id), ["agent", "ask", "plan", "full-auto"]) + XCTAssertEqual(workRuntimeModeOptions(provider: "droid").map(\.id), ["read-only", "auto-low", "auto-medium", "auto-high"]) + + let claudeAuto = workRuntimeWireFields(provider: "claude", mode: "auto") + XCTAssertEqual(claudeAuto.permissionMode, "auto") + XCTAssertEqual(claudeAuto.claudePermissionMode, "auto") + XCTAssertEqual(claudeAuto.interactionMode, "default") + + let cursorAsk = workRuntimeWireFields(provider: "cursor", mode: "ask") + XCTAssertEqual(cursorAsk.permissionMode, "edit") + XCTAssertEqual(cursorAsk.cursorModeId, "ask") + + let droidHigh = workRuntimeWireFields(provider: "droid", mode: "auto-high") + XCTAssertEqual(droidHigh.permissionMode, "full-auto") + XCTAssertEqual(droidHigh.droidPermissionMode, "auto-high") + } + + func testResolvedWorkArchivedSessionIdsKeepsLocalOverrideForKnownChat() { + let summary = makeAgentChatSessionSummary( + sessionId: "chat-known", + status: "idle", + archivedAt: nil + ) + + let archived = resolvedWorkArchivedSessionIds( + localStorage: "chat-known\nchat-local", + chatSummaries: ["chat-known": summary] + ) + + XCTAssertEqual(archived, ["chat-known", "chat-local"]) + } + + func testResolvedWorkArchivedSessionIdsReadsHydratedTerminalArchiveState() { + let session = makeTerminalSessionSummary( + id: "chat-hydrated", + toolType: "codex-chat", + archivedAt: "2026-05-18T13:04:31.483Z" + ) + + let archived = resolvedWorkArchivedSessionIds( + localStorage: "", + chatSummaries: [:], + sessions: [session] + ) + + XCTAssertEqual(archived, ["chat-hydrated"]) + } + + func testResolvedWorkNavigationLaneIdKeepsKnownLaneId() { + let session = makeTerminalSessionSummary(laneId: "lane-active", laneName: "Active", toolType: "codex-chat") + let lanes = [makeLaneSummary(id: "lane-active", name: "Active", laneType: "worktree", branchRef: "ade/active")] + + XCTAssertEqual(resolvedWorkNavigationLaneId(for: session, lanes: lanes), "lane-active") + } + + func testResolvedWorkNavigationLaneIdMapsStalePrimarySessionToActivePrimary() { + let session = makeTerminalSessionSummary(laneId: "lane-stale-primary", laneName: "Primary", toolType: "codex-chat") + let lanes = [ + makeLaneSummary(id: "lane-active-primary", name: "Primary", laneType: "primary", branchRef: "main"), + makeLaneSummary(id: "lane-feature", name: "Feature", laneType: "worktree", branchRef: "ade/feature") + ] + + XCTAssertEqual(resolvedWorkNavigationLaneId(for: session, lanes: lanes), "lane-active-primary") + } + + func testResolvedWorkNavigationLaneIdFallsBackToMatchingNameOrBranch() { + let renamedSession = makeTerminalSessionSummary(laneId: "lane-stale", laneName: "Feature Lane", toolType: "codex-chat") + let branchSession = makeTerminalSessionSummary(laneId: "lane-stale-branch", laneName: "ade/feature-lane", toolType: "codex-chat") + let lanes = [ + makeLaneSummary(id: "lane-by-name", name: "Feature Lane", laneType: "worktree", branchRef: "ade/other"), + makeLaneSummary(id: "lane-by-branch", name: "Other Lane", laneType: "worktree", branchRef: "ade/feature-lane") + ] + + XCTAssertEqual(resolvedWorkNavigationLaneId(for: renamedSession, lanes: lanes), "lane-by-name") + XCTAssertEqual(resolvedWorkNavigationLaneId(for: branchSession, lanes: lanes), "lane-by-branch") } func testTerminalDisplayPreservesAnsiRunsForRendering() { @@ -555,6 +753,126 @@ final class ADETests: XCTestCase { ) } + func testDiscoveredHostsDisplayCoalescesDuplicateLiveRoutes() { + let staleName = DiscoveredSyncHost( + id: "stale-device", + serviceName: "ADE Sync stale", + hostName: "MacBook-Pro-567.local", + hostIdentity: "stale-device", + port: 8787, + addresses: ["MacBook-Pro-567.local", "192.168.1.249"], + tailscaleAddress: "100.75.21.10", + runtimeKind: "daemon", + runtimeVersion: "1.0.0-beta.1", + projectIds: ["project-a"], + projectNames: ["ADE"], + projectCount: 1, + lastResolvedAt: "2026-05-10T10:00:00.000Z" + ) + let friendlyName = DiscoveredSyncHost( + id: "fresh-device", + serviceName: "ADE Sync fresh", + hostName: "lappy", + hostIdentity: "fresh-device", + port: 8787, + addresses: ["192.168.1.249"], + tailscaleAddress: "100.75.21.10", + runtimeKind: "daemon", + runtimeVersion: "1.0.0-beta.1", + projectIds: ["project-b", "project-a"], + projectNames: ["Versic", "ADE"], + projectCount: 2, + lastResolvedAt: "2026-05-10T10:00:01.000Z" + ) + + let displayed = syncDiscoveredHostsForDisplay(savedHosts: [], liveHosts: [staleName, friendlyName]) + + XCTAssertEqual(displayed.savedHosts.count, 0) + XCTAssertEqual(displayed.liveHosts.count, 1) + XCTAssertEqual(displayed.liveHosts[0].hostName, "lappy") + XCTAssertEqual(displayed.liveHosts[0].addresses, ["192.168.1.249", "MacBook-Pro-567.local"]) + XCTAssertEqual(displayed.liveHosts[0].projectNames, ["Versic", "ADE"]) + XCTAssertEqual(displayed.liveHosts[0].projectCount, 2) + } + + func testDiscoveredHostsDisplayCoalescesDuplicateLiveRoutesAcrossPorts() { + let pairService = DiscoveredSyncHost( + id: "pair-service", + serviceName: "ADE Pair", + hostName: "MacBook-Pro-567.local", + hostIdentity: "machine-pair-service", + port: 8787, + addresses: ["192.168.1.249"], + tailscaleAddress: nil, + runtimeKind: "daemon", + runtimeVersion: "1.0.0-beta.1", + projectIds: ["project-a"], + projectNames: ["ADE"], + projectCount: 1, + lastResolvedAt: "2026-05-10T10:00:00.000Z" + ) + let runtimeService = DiscoveredSyncHost( + id: "runtime-service", + serviceName: "ADE Runtime", + hostName: "lappy", + hostIdentity: "machine-runtime-service", + port: 8790, + addresses: ["192.168.1.249"], + tailscaleAddress: nil, + runtimeKind: "daemon", + runtimeVersion: "1.0.0-beta.1", + projectIds: ["project-b", "project-a"], + projectNames: ["Versic", "ADE"], + projectCount: 2, + lastResolvedAt: "2026-05-10T10:00:01.000Z" + ) + + let displayed = syncDiscoveredHostsForDisplay(savedHosts: [], liveHosts: [pairService, runtimeService]) + + XCTAssertEqual(displayed.liveHosts.count, 1) + XCTAssertEqual(displayed.liveHosts[0].hostName, "lappy") + XCTAssertEqual(displayed.liveHosts[0].port, 8790) + XCTAssertEqual(displayed.liveHosts[0].addresses, ["192.168.1.249"]) + XCTAssertEqual(displayed.liveHosts[0].projectNames, ["Versic", "ADE"]) + } + + func testDiscoveredHostsDisplayKeepsDistinctLanHostsWithSharedTailnetMetadata() { + let currentMachine = DiscoveredSyncHost( + id: "current-machine", + serviceName: "ADE Runtime current", + hostName: "Mac.lan", + hostIdentity: "current-machine", + port: 8787, + addresses: ["192.168.1.240", "192.168.1.249"], + tailscaleAddress: "100.75.20.63", + runtimeKind: "daemon", + runtimeVersion: "1.0.0-beta.1", + projectIds: ["project-a"], + projectNames: ["ADE"], + projectCount: 1, + lastResolvedAt: "2026-05-10T10:00:00.000Z" + ) + let otherMachine = DiscoveredSyncHost( + id: "other-machine", + serviceName: "ADE Runtime other", + hostName: "lappy", + hostIdentity: "other-machine", + port: 8790, + addresses: ["192.168.1.249"], + tailscaleAddress: "100.75.20.63", + runtimeKind: "daemon", + runtimeVersion: "1.0.0-beta.1", + projectIds: ["project-b"], + projectNames: ["Versic"], + projectCount: 1, + lastResolvedAt: "2026-05-10T10:00:01.000Z" + ) + + let displayed = syncDiscoveredHostsForDisplay(savedHosts: [], liveHosts: [currentMachine, otherMachine]) + + XCTAssertEqual(displayed.liveHosts.map(\.hostName), ["Mac.lan", "lappy"]) + } + @MainActor func testSyncMergesDuplicateBonjourHostsByDeviceIdentityWithProjectMetadata() { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) @@ -793,6 +1111,94 @@ final class ADETests: XCTestCase { ) } + func testSyncConnectPortCandidatesDoNotScanBonjourHostnameFallbackWindow() { + XCTAssertEqual( + syncConnectPortCandidates(primaryPort: 8787, addresses: ["Aruls-Mac-Studio.local."]), + [8787] + ) + XCTAssertEqual( + syncConnectPortCandidates(primaryPort: 8787, addresses: ["192.168.1.8"]).prefix(3), + [8787, 8788, 8789] + ) + } + + @MainActor + func testSyncDiscoveredHostIgnoresTimestampOnlyRefreshForPublishedList() { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + service.applyDiscoveredHostsForTesting([ + DiscoveredSyncHost( + id: "bonjour-host", + serviceName: "ADE Sync Mac 8787", + hostName: "Mac Studio", + hostIdentity: "host-1", + port: 8787, + addresses: ["Aruls-Mac-Studio.local"], + tailscaleAddress: nil, + lastResolvedAt: "2026-04-23T00:00:00.000Z" + ), + ]) + let firstPublished = service.discoveredHosts + + service.applyDiscoveredHostsForTesting([ + DiscoveredSyncHost( + id: "bonjour-host", + serviceName: "ADE Sync Mac 8787", + hostName: "Mac Studio", + hostIdentity: "host-1", + port: 8787, + addresses: ["Aruls-Mac-Studio.local"], + tailscaleAddress: nil, + lastResolvedAt: "2026-04-23T00:00:01.000Z" + ), + ]) + + XCTAssertEqual(service.discoveredHosts, firstPublished) + } + + @MainActor + func testSyncDiscoveredHostsCoalescesDuplicateAnonymousLanRows() { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + + service.applyDiscoveredHostsForTesting([ + DiscoveredSyncHost( + id: "anonymous-8787-a", + serviceName: "ADE Sync MacBook A", + hostName: "MacBook-Pro-567.local", + hostIdentity: nil, + port: 8787, + addresses: ["192.168.1.249"], + tailscaleAddress: "100.80.20.10", + runtimeKind: "daemon", + runtimeVersion: "1.0.0-beta.1", + projectIds: ["project-a"], + projectNames: ["ADE"], + projectCount: 1, + lastResolvedAt: "2026-04-23T00:00:00.000Z" + ), + DiscoveredSyncHost( + id: "anonymous-8787-b", + serviceName: "ADE Sync MacBook B", + hostName: "MacBook-Pro-567.local", + hostIdentity: nil, + port: 8787, + addresses: ["192.168.1.249"], + tailscaleAddress: "100.80.20.10", + runtimeKind: "daemon", + runtimeVersion: "1.0.0-beta.1", + projectIds: ["project-b", "project-a"], + projectNames: ["Versic", "ADE"], + projectCount: 2, + lastResolvedAt: "2026-04-23T00:00:01.000Z" + ), + ]) + + XCTAssertEqual(service.discoveredHosts.count, 1) + XCTAssertEqual(service.discoveredHosts[0].hostName, "MacBook-Pro-567.local") + XCTAssertEqual(service.discoveredHosts[0].addresses, ["192.168.1.249"]) + XCTAssertEqual(service.discoveredHosts[0].projectIds, ["project-b", "project-a"]) + XCTAssertEqual(service.discoveredHosts[0].projectNames, ["Versic", "ADE"]) + } + @MainActor func testSyncUserReconnectCanPreferSavedTailnetOverStaleLanDiscovery() { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) @@ -1243,6 +1649,9 @@ final class ADETests: XCTestCase { try await service.subscribeToChatEvents(sessionId: "session-1") XCTAssertEqual(service.localStateRevision, subscriptionRevision) + try await service.subscribeToChatEvents(sessionId: "session-1", requestSnapshot: true) + XCTAssertEqual(service.localStateRevision, subscriptionRevision) + XCTAssertEqual(service.subscribedChatSessionIds, Set(["session-1", "session-2"])) XCTAssertEqual(service.chatSubscriptionPayloads().compactMap { $0["sessionId"] as? String }.sorted(), ["session-1", "session-2"]) XCTAssertEqual(service.chatSubscriptionPayloads().compactMap { $0["maxBytes"] as? Int }, [2_000_000, 2_000_000]) @@ -1324,6 +1733,27 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.chatEventHistory(sessionId: "session-1"), [original, tail]) } + @MainActor + func testDuplicateChatSubscribeSnapshotDoesNotAdvanceRevision() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + let event = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:00.000Z", + event: .userMessage(text: "Start here", attachments: [], turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil), + sequence: 1, + provenance: nil + ) + + service.recordChatEventEnvelope(event) + XCTAssertEqual(service.chatEventRevision(for: "session-1"), 1) + + service.mergeChatEventHistory(sessionId: "session-1", events: [event]) + service.replaceChatEventHistory(sessionId: "session-1", events: [event]) + + XCTAssertEqual(service.chatEventHistory(sessionId: "session-1"), [event]) + XCTAssertEqual(service.chatEventRevision(for: "session-1"), 1) + } + @MainActor func testCompleteChatSubscribeSnapshotMergesWithExistingLiveHistory() async throws { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) @@ -1793,6 +2223,44 @@ final class ADETests: XCTestCase { database.close() } + @MainActor + func testSyncServiceProjectHomeDeduplicatesCachedRowsByRootAndKeepsActive() throws { + let activeProjectIdKey = "ade.sync.activeProjectId" + let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" + UserDefaults.standard.set("project-active", forKey: activeProjectIdKey) + UserDefaults.standard.set("/tmp/project-one", forKey: activeProjectRootPathKey) + defer { + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + } + + let baseURL = makeTemporaryDirectory() + let database = makeControllerHydrationDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values + ('project-stale', '/tmp/project-one', 'Project One', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T02:00:00.000Z'), + ('project-active', '/tmp/project-one/', 'Project One', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T01:00:00.000Z'); + insert into lanes ( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, folder, + status, created_at, archived_at + ) values + ('lane-one', 'project-active', 'One', null, 'worktree', 'main', 'feature/one', '/tmp/project-one/.ade/worktrees/one', + null, 0, null, null, null, null, null, 'active', '2026-04-22T00:10:00.000Z', null); + """) + + let service = SyncService(database: database) + + XCTAssertEqual(service.projects.map(\.id), ["project-active"]) + XCTAssertEqual(service.projects.first?.laneCount, 1) + XCTAssertEqual(service.activeProjectId, "project-active") + + database.close() + } + @MainActor func testSyncServiceRejectsUncachedProjectSelectionWithoutCatalogSwitch() throws { let activeProjectIdKey = "ade.sync.activeProjectId" @@ -2082,6 +2550,51 @@ final class ADETests: XCTestCase { database.close() } + @MainActor + func testSyncServiceAdoptsRemoteProjectIdWhenStaleCachedDuplicateStillExists() throws { + let activeProjectIdKey = "ade.sync.activeProjectId" + let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" + let activeProjectHostIdentityKey = "ade.sync.activeProjectHostIdentity" + UserDefaults.standard.set("stale-project", forKey: activeProjectIdKey) + UserDefaults.standard.set("/tmp/project-one", forKey: activeProjectRootPathKey) + UserDefaults.standard.set("host-1", forKey: activeProjectHostIdentityKey) + defer { + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + UserDefaults.standard.removeObject(forKey: activeProjectHostIdentityKey) + } + + let database = makeControllerHydrationDatabase(baseURL: makeTemporaryDirectory()) + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values + ('runtime-project', '/tmp/project-one', 'Project One', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T02:00:00.000Z'), + ('stale-project', '/tmp/project-one', 'Project One', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T01:00:00.000Z'); + """) + let service = SyncService(database: database) + XCTAssertEqual(service.activeProjectId, "stale-project") + + service.seedRemoteProjectCatalogForTesting([ + MobileProjectSummary( + id: "runtime-project", + displayName: "Project One", + rootPath: "/tmp/project-one/", + defaultBaseRef: "main", + lastOpenedAt: "2026-04-22T02:00:00.000Z", + laneCount: 2, + isAvailable: true, + isCached: true + ), + ]) + + XCTAssertEqual(service.activeProjectId, "runtime-project") + XCTAssertEqual(service.activeProjectRootPath, "/tmp/project-one") + XCTAssertEqual(database.currentProjectId(), "runtime-project") + + database.close() + } + @MainActor func testSyncServiceSeedsRuntimeProjectRowBeforeHydration() throws { let activeProjectIdKey = "ade.sync.activeProjectId" @@ -2124,6 +2637,65 @@ final class ADETests: XCTestCase { database.close() } + func testDatabaseFindsProofArtifactsAcrossDuplicateProjectRootIds() throws { + let database = makeControllerHydrationDatabase(baseURL: makeTemporaryDirectory()) + XCTAssertNil(database.initializationError) + try database.executeSqlForTesting(""" + create table if not exists computer_use_artifacts ( + id text primary key, + project_id text not null, + artifact_kind text not null, + backend_style text not null, + backend_name text not null, + source_tool_name text, + original_type text, + title text not null, + description text, + uri text not null, + storage_kind text not null, + mime_type text, + metadata_json text not null default '{}', + created_at text not null + ); + create table if not exists computer_use_artifact_links ( + id text primary key, + artifact_id text not null, + project_id text not null, + owner_kind text not null, + owner_id text not null, + relation text not null default 'attached_to', + metadata_json text, + created_at text not null + ); + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values + ('cached-project', '/tmp/project-one', 'Project One', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T01:00:00.000Z'), + ('runtime-project', '/tmp/project-one/', 'Project One', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T02:00:00.000Z'); + insert into computer_use_artifacts ( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, original_type, + title, description, uri, storage_kind, mime_type, metadata_json, created_at + ) values ( + 'artifact-1', 'runtime-project', 'screenshot', 'manual', 'ade-cli', 'proof attach', 'screenshot', + 'Runtime proof', 'Attached while the runtime project id was canonical', 'ade-artifact://project/proof.png', + 'file', 'image/png', '{}', '2026-04-22T02:05:00.000Z' + ); + insert into computer_use_artifact_links ( + id, artifact_id, project_id, owner_kind, owner_id, relation, metadata_json, created_at + ) values ( + 'link-1', 'artifact-1', 'runtime-project', 'chat_session', 'chat-1', 'attached_to', null, '2026-04-22T02:05:00.000Z' + ); + """) + database.setActiveProjectId("cached-project") + + let artifacts = database.fetchComputerUseArtifacts(ownerKind: "chat_session", ownerId: "chat-1") + + XCTAssertEqual(artifacts.map(\.id), ["artifact-1"]) + XCTAssertEqual(artifacts.first?.title, "Runtime proof") + + database.close() + } + func testDatabasePersistsStableSiteIdAcrossReopen() throws { let baseURL = makeTemporaryDirectory() let database = makeDatabase(baseURL: baseURL) @@ -2269,6 +2841,9 @@ final class ADETests: XCTestCase { let changes = source.exportChangesSince(version: 0) XCTAssertFalse(changes.isEmpty) + let lanePrimaryKeys = changes.filter { $0.table == "lanes" }.map(\.pk) + XCTAssertFalse(lanePrimaryKeys.isEmpty) + XCTAssertTrue(lanePrimaryKeys.allSatisfy { $0 == packedDesktopTextPrimaryKey("lane-1") }) let result = try target.applyChanges(changes) XCTAssertGreaterThan(result.appliedCount, 0) @@ -3108,6 +3683,43 @@ final class ADETests: XCTestCase { database.close() } + func testDatabaseReplaceLaneSnapshotsSkipsNoOpNotifications() throws { + let baseURL = makeTemporaryDirectory() + let database = makeLaneHydrationDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + defer { database.close() } + + try insertHydrationProjectGraph(into: database) + var notificationCount = 0 + let token = NotificationCenter.default.addObserver( + forName: .adeDatabaseDidChange, + object: nil, + queue: nil + ) { _ in + notificationCount += 1 + } + defer { NotificationCenter.default.removeObserver(token) } + + let snapshot = makeLaneListSnapshot( + id: "lane-primary", + name: "Primary", + laneType: "primary", + baseRef: "main", + branchRef: "main", + worktreePath: "/tmp/project", + description: nil, + status: LaneStatus(dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false), + runtime: LaneRuntimeSummary(bucket: "none", runningCount: 0, awaitingInputCount: 0, endedCount: 0, sessionCount: 0), + createdAt: "2026-03-17T00:00:00.000Z", + archivedAt: nil + ) + + try database.replaceLaneSnapshots([snapshot.lane], snapshots: [snapshot]) + XCTAssertEqual(notificationCount, 1) + try database.replaceLaneSnapshots([snapshot.lane], snapshots: [snapshot]) + XCTAssertEqual(notificationCount, 1) + } + func testDatabaseReplaceLaneDetailCachesRichLanePayload() throws { let baseURL = makeTemporaryDirectory() let database = makeControllerHydrationDatabase(baseURL: baseURL) @@ -3227,6 +3839,62 @@ final class ADETests: XCTestCase { database.close() } + func testDatabaseReplaceLaneDetailSkipsNoOpNotifications() throws { + let baseURL = makeTemporaryDirectory() + let database = makeControllerHydrationDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + defer { database.close() } + + try insertHydrationProjectGraph(into: database) + var notificationCount = 0 + let token = NotificationCenter.default.addObserver( + forName: .adeDatabaseDidChange, + object: nil, + queue: nil + ) { _ in + notificationCount += 1 + } + defer { NotificationCenter.default.removeObserver(token) } + + let snapshot = makeLaneListSnapshot( + id: "lane-primary", + name: "Primary", + laneType: "primary", + baseRef: "main", + branchRef: "main", + worktreePath: "/tmp/project", + description: nil, + status: LaneStatus(dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false), + runtime: LaneRuntimeSummary(bucket: "none", runningCount: 0, awaitingInputCount: 0, endedCount: 0, sessionCount: 0), + createdAt: "2026-03-17T00:00:00.000Z", + archivedAt: nil + ) + let detail = LaneDetailPayload( + lane: snapshot.lane, + runtime: snapshot.runtime, + stackChain: [], + children: [], + stateSnapshot: nil, + rebaseSuggestion: nil, + autoRebaseStatus: nil, + conflictStatus: nil, + overlaps: [], + syncStatus: nil, + conflictState: nil, + recentCommits: [], + diffChanges: nil, + stashes: [], + envInitProgress: nil, + sessions: [], + chatSessions: [] + ) + + try database.replaceLaneDetail(detail) + XCTAssertEqual(notificationCount, 1) + try database.replaceLaneDetail(detail) + XCTAssertEqual(notificationCount, 1) + } + func testDatabaseReplaceTerminalSessionsHydratesHostSessionProjection() throws { let baseURL = makeTemporaryDirectory() let database = makeControllerHydrationDatabase(baseURL: baseURL) @@ -3326,6 +3994,54 @@ final class ADETests: XCTestCase { database.close() } + func testDatabaseUpdateSessionMetaPersistsRenamePinnedAndManualName() throws { + let baseURL = makeTemporaryDirectory() + let database = makeControllerHydrationDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + + try insertHydrationProjectGraph(into: database) + try database.replaceTerminalSessions([ + TerminalSessionSummary( + id: "session-rename", + laneId: "lane-primary", + laneName: "Primary", + ptyId: nil, + tracked: true, + pinned: false, + manuallyNamed: false, + goal: nil, + toolType: "codex-chat", + title: "Original title", + status: "running", + startedAt: "2026-03-17T00:10:00.000Z", + endedAt: nil, + exitCode: nil, + transcriptPath: "/tmp/session-rename.log", + headShaStart: nil, + headShaEnd: nil, + lastOutputPreview: nil, + summary: nil, + runtimeState: "running", + resumeCommand: "codex", + resumeMetadata: nil, + chatIdleSinceAt: nil + ), + ]) + + try database.updateSessionMeta( + sessionId: "session-rename", + title: "Renamed from phone", + pinned: true, + manuallyNamed: true + ) + + let session = try XCTUnwrap(database.fetchSessions().first) + XCTAssertEqual(session.title, "Renamed from phone") + XCTAssertTrue(session.pinned) + XCTAssertTrue(session.manuallyNamed ?? false) + database.close() + } + func testDatabaseReplaceTerminalSessionsPersistsChatSessionId() throws { let baseURL = makeTemporaryDirectory() let database = makeControllerHydrationDatabase(baseURL: baseURL) @@ -3587,6 +4303,49 @@ final class ADETests: XCTestCase { database.close() } + func testDatabaseReplacePullRequestHydrationScopesPayloadToActiveProject() throws { + let baseURL = makeTemporaryDirectory() + let database = makeControllerHydrationDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + + try insertHydrationProjectGraph(into: database) + try database.replacePullRequestHydration( + PullRequestRefreshPayload( + refreshedCount: 1, + prs: [ + PrSummary( + id: "pr-host-project", + laneId: "lane-primary", + projectId: "host-db-project", + repoOwner: "arul", + repoName: "ade", + githubPrNumber: 99, + githubUrl: "https://github.com/arul/ade/pull/99", + githubNodeId: nil, + title: "Scope to mobile active project", + state: "open", + baseBranch: "main", + headBranch: "ade/scope-pr", + checksStatus: "failing", + reviewStatus: "pending", + additions: 1, + deletions: 0, + lastSyncedAt: nil, + createdAt: "2026-03-17T00:10:00.000Z", + updatedAt: "2026-03-17T00:10:00.000Z" + ), + ], + snapshots: [] + ) + ) + + let prs = database.fetchPullRequests() + XCTAssertEqual(prs.map(\.id), ["pr-host-project"]) + XCTAssertEqual(prs.first?.projectId, "project-1") + + database.close() + } + @MainActor func testDisconnectKeepsCachedLaneDataAvailable() async throws { let baseURL = makeTemporaryDirectory() @@ -3688,7 +4447,7 @@ final class ADETests: XCTestCase { let delivery = try await service.sendChatMessage(sessionId: "chat-1", text: "keep this draft moving") - XCTAssertEqual(delivery, .queued) + XCTAssertEqual(delivery, .queued(steerId: nil)) let queued = service.pendingOperationsForTesting() XCTAssertEqual(service.pendingOperationCount, 1) XCTAssertEqual(queued.count, 1) @@ -3894,6 +4653,77 @@ final class ADETests: XCTestCase { XCTAssertEqual(repoScopedGitHubPullRequests(from: snapshot).map(\.id), ["repo-pr"]) } + func testPrParsedDateHandlesFractionalAndFallbackIsoDates() { + let fractional = prParsedDate("2026-05-14T00:00:00.123Z") + let fallback = prParsedDate("2026-05-14T00:00:00Z") + + XCTAssertNotNil(fractional) + XCTAssertNotNil(fallback) + XCTAssertEqual(fallback, prParsedDate("2026-05-14T00:00:00Z")) + } + + func testPrDetailSidecarFetchPolicySkipsLocalRevisionAfterInitialLoad() { + XCTAssertTrue(shouldFetchPrDetailLiveSidecars(hasLoadedLiveSidecars: false, refreshRemote: false)) + XCTAssertFalse(shouldFetchPrDetailLiveSidecars(hasLoadedLiveSidecars: true, refreshRemote: false)) + XCTAssertTrue(shouldFetchPrDetailLiveSidecars(hasLoadedLiveSidecars: true, refreshRemote: true)) + } + + func testPrChecksSummaryFallsBackToOverallFailingStatus() { + let stats = prChecksSummaryStats(checks: [], overallChecksStatus: "failing") + + XCTAssertEqual(stats, PrChecksSummaryStats(fail: 1, pending: 0, pass: 0, total: 1)) + XCTAssertTrue(prChecksHasFailedSignal(checks: [], overallChecksStatus: "failing")) + XCTAssertEqual(prChecksEmptyStateCopy(overallChecksStatus: "failing").title, "Checks failing") + } + + func testPrChecksSummaryPrefersSyncedCheckRuns() { + let checks = [ + PrCheck( + name: "unit", + status: "completed", + conclusion: "success", + detailsUrl: nil, + startedAt: nil, + completedAt: nil + ), + ] + let stats = prChecksSummaryStats(checks: checks, overallChecksStatus: "failing") + + XCTAssertEqual(stats, PrChecksSummaryStats(fail: 0, pending: 0, pass: 1, total: 1)) + XCTAssertFalse(prChecksHasFailedSignal(checks: checks, overallChecksStatus: "failing")) + } + + func testPrMergeGateDoesNotShowGreenWhenStatusIsMissing() { + let gate = prComputeMergeGate( + status: nil, + checks: [], + summaryChecksStatus: nil, + reviewThreadsUnresolved: 0, + reviewsNeeded: 0, + reviewsHave: 0, + capabilities: nil + ) + + XCTAssertEqual(gate.tone, .amber) + XCTAssertEqual(gate.subline, "Waiting for synced PR status") + } + + func testPrMergeGateUsesSummaryFailingStatusBeforeCheckRowsSync() { + let gate = prComputeMergeGate( + status: nil, + checks: [], + summaryChecksStatus: "failing", + reviewThreadsUnresolved: 0, + reviewsNeeded: 0, + reviewsHave: 0, + capabilities: nil + ) + + XCTAssertEqual(gate.tone, .red) + XCTAssertEqual(gate.subline, "checks failing") + XCTAssertEqual(gate.target, .checks) + } + func testPrLinkLanePreselectionRequiresExactBranchMatch() { func lane(id: String, name: String, branchRef: String) -> LaneSummary { LaneSummary( @@ -3926,6 +4756,8 @@ final class ADETests: XCTestCase { ] XCTAssertEqual(matchedLaneForExactBranch("cursor/windows-port-foundations-ede6", lanes: lanes)?.id, "lane-branch-match") + XCTAssertEqual(matchedLaneForExactBranch("refs/heads/cursor/windows-port-foundations-ede6", lanes: lanes)?.id, "lane-branch-match") + XCTAssertEqual(matchedLaneForExactBranch("origin/cursor/windows-port-foundations-ede6", lanes: lanes)?.id, "lane-branch-match") XCTAssertNil(matchedLaneForExactBranch("automations overhaul", lanes: lanes)) XCTAssertNil(matchedLaneForExactBranch(" ", lanes: lanes)) } @@ -4114,6 +4946,49 @@ final class ADETests: XCTestCase { ) } + func testCompactSyncSummaryUsesRemoteUpstreamState() { + XCTAssertEqual(compactSyncSummary(nil), "Checking remote") + XCTAssertEqual( + compactSyncSummary( + GitUpstreamSyncStatus( + hasUpstream: true, + upstreamRef: "origin/feature", + ahead: 0, + behind: 0, + diverged: false, + recommendedAction: "none" + ) + ), + "In sync with remote" + ) + XCTAssertEqual( + compactSyncSummary( + GitUpstreamSyncStatus( + hasUpstream: true, + upstreamRef: "origin/feature", + ahead: 2, + behind: 0, + diverged: false, + recommendedAction: "push" + ) + ), + "2 ahead remote" + ) + XCTAssertEqual( + compactSyncSummary( + GitUpstreamSyncStatus( + hasUpstream: false, + upstreamRef: nil, + ahead: 0, + behind: 0, + diverged: false, + recommendedAction: "publish" + ) + ), + "No upstream" + ) + } + func testLaneRootEmptyStateGuidesUnpairedUsersWhenNoCacheExists() { let emptyState = laneRootEmptyState( connectionState: .disconnected, @@ -4283,6 +5158,36 @@ final class ADETests: XCTestCase { ) } + func testWorkChatActiveTurnUsesSteerAndClaudeOnlyManualDispatch() { + let activeSummary = makeAgentChatSessionSummary(provider: "codex", status: "active") + XCTAssertTrue(workChatShouldSteerActiveTurn(session: nil, summary: activeSummary)) + + let idleSummary = makeAgentChatSessionSummary(provider: "codex", status: "idle") + XCTAssertFalse(workChatShouldSteerActiveTurn(session: nil, summary: idleSummary)) + + let runningTerminal = makeTerminalSessionSummary(toolType: "codex-chat", runtimeState: "running", status: "running") + XCTAssertTrue(workChatShouldSteerActiveTurn(session: runningTerminal, summary: nil)) + + let claudeSummary = makeAgentChatSessionSummary(provider: "claude", status: "active") + XCTAssertTrue(workChatSupportsManualSteerDispatch(session: nil, summary: claudeSummary)) + XCTAssertTrue(workChatSupportsManualSteerDispatch(session: makeTerminalSessionSummary(toolType: "claude-chat"), summary: nil)) + XCTAssertFalse(workChatSupportsManualSteerDispatch(session: nil, summary: activeSummary)) + XCTAssertFalse(workChatSupportsManualSteerDispatch(session: makeTerminalSessionSummary(toolType: "cursor"), summary: nil)) + } + + func testSyncChatMessageDeliveryParsesQueuedSteerResult() { + XCTAssertEqual(syncChatMessageDelivery(from: ["ok": true, "steerId": "steer-1", "queued": true]), .queued(steerId: "steer-1")) + XCTAssertEqual(syncChatMessageDelivery(from: ["ok": true, "steerId": "steer-1", "queued": false]), .sent) + XCTAssertEqual(syncChatMessageDelivery(from: NSNull()), .sent) + } + + func testWorkChatLiveObservationKeyUsesCoalescedNotificationRevision() { + XCTAssertEqual( + workChatLiveObservationKey(sessionId: "chat-1", chatEventNotificationRevision: 7), + "chat-1-7" + ) + } + func testBuildPullRequestTimelineOrdersStateReviewsAndComments() { let pr = PullRequestListItem( id: "pr-9", @@ -4738,6 +5643,17 @@ final class ADETests: XCTestCase { ) } + func testFilesDetailRefreshDelayOnlyThrottlesWarmContent() throws { + XCTAssertNil(filesDetailRefreshDelay(hasLoadedBlob: false, elapsedSinceLastLoad: 0.1)) + XCTAssertNil(filesDetailRefreshDelay(hasLoadedBlob: true, elapsedSinceLastLoad: 0.9, minimumInterval: 0.75)) + let delayed = try XCTUnwrap(filesDetailRefreshDelay(hasLoadedBlob: true, elapsedSinceLastLoad: 0.2, minimumInterval: 0.75)) + XCTAssertEqual( + delayed, + 0.55, + accuracy: 0.001 + ) + } + func testDatabaseCachesFilesWorkspaceDirectoryBlobDiffAndHistorySnapshots() throws { let database = DatabaseService(baseURL: makeTemporaryDirectory()) XCTAssertNil(database.initializationError) @@ -5133,6 +6049,29 @@ final class ADETests: XCTestCase { let session = makeTerminalSessionSummary(toolType: "codex-chat", runtimeState: "waiting-input", status: "running") XCTAssertEqual(normalizedWorkChatSessionStatus(session: session, summary: nil), "awaiting-input") + + let crdtOnlySession = makeTerminalSessionSummary(toolType: "codex-chat", runtimeState: "exited", status: "awaiting_input") + XCTAssertEqual(normalizedWorkChatSessionStatus(session: crdtOnlySession, summary: nil), "awaiting-input") + + let staleCompletedSummary = makeAgentChatSessionSummary(status: "completed", awaitingInput: false) + XCTAssertEqual(normalizedWorkChatSessionStatus(session: crdtOnlySession, summary: staleCompletedSummary), "awaiting-input") + } + + func testWorkChatComposerPlaceholderDistinguishesMissingPromptDetails() { + XCTAssertTrue(workChatComposerBlocksFreeformInput(pendingInputCount: 0, sessionStatus: "awaiting-input")) + XCTAssertTrue(workChatAwaitingPromptDetailsMissing(pendingInputCount: 0, sessionStatus: "awaiting-input")) + XCTAssertEqual( + workChatComposerPlaceholder(pendingInputCount: 0, sessionStatus: "awaiting-input"), + "Waiting for prompt details..." + ) + XCTAssertEqual( + workChatComposerPlaceholder(pendingInputCount: 1, sessionStatus: "awaiting-input"), + "Answer the prompt above..." + ) + XCTAssertEqual( + workChatComposerPlaceholder(pendingInputCount: 0, sessionStatus: "idle"), + "Type to vibecode..." + ) } func testWorkChatStatusNormalizationFallsBackToSessionRuntimeStateAndTerminalState() { @@ -5145,6 +6084,9 @@ final class ADETests: XCTestCase { let idleSession = makeTerminalSessionSummary(toolType: "codex-chat", runtimeState: "idle", status: "running") XCTAssertEqual(normalizedWorkChatSessionStatus(session: idleSession, summary: nil), "idle") + let staleActiveSummary = makeAgentChatSessionSummary(status: "active", awaitingInput: false) + XCTAssertEqual(normalizedWorkChatSessionStatus(session: idleSession, summary: staleActiveSummary), "idle") + let endedSession = makeTerminalSessionSummary(toolType: "codex-chat", runtimeState: "stopped", status: "exited") XCTAssertEqual(normalizedWorkChatSessionStatus(session: endedSession, summary: nil), "ended") } @@ -5167,6 +6109,88 @@ final class ADETests: XCTestCase { XCTAssertFalse(isChatSession(makeTerminalSessionSummary(toolType: nil))) } + @MainActor + func testSyncActiveSessionsKeepsFailedChatsForAttentionDrawer() throws { + let baseURL = makeTemporaryDirectory() + let database = makeTerminalSessionSyncDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values ( + 'project-1', '/tmp/project', 'ADE', 'main', '2026-04-20T00:00:00.000Z', '2026-04-20T00:00:00.000Z' + ); + insert into lanes ( + id, project_id, name, lane_type, base_ref, branch_ref, worktree_path, status, created_at + ) values ( + 'lane-1', 'project-1', 'Primary', 'primary', 'main', 'main', '/tmp/project', 'active', '2026-04-20T00:00:00.000Z' + ); + """) + + try database.replaceTerminalSessions([ + makeTerminalSessionSummary( + id: "failed-chat", + laneId: "lane-1", + laneName: "Primary", + toolType: "codex-chat", + runtimeState: "exited", + status: "failed", + title: "Mobile failed chat", + lastOutputPreview: "Tool call failed", + startedAt: "2026-04-20T00:01:00.000Z" + ), + makeTerminalSessionSummary( + id: "completed-chat", + laneId: "lane-1", + laneName: "Primary", + toolType: "codex-chat", + runtimeState: "exited", + status: "completed", + title: "Completed chat", + startedAt: "2026-04-20T00:02:00.000Z" + ), + makeTerminalSessionSummary( + id: "awaiting-chat", + laneId: "lane-1", + laneName: "Primary", + toolType: "codex-chat", + runtimeState: "exited", + status: "awaiting_input", + title: "Mobile awaiting chat", + lastOutputPreview: "Approval needed", + startedAt: "2026-04-20T00:02:30.000Z" + ), + makeTerminalSessionSummary( + id: "failed-shell", + laneId: "lane-1", + laneName: "Primary", + toolType: "shell", + runtimeState: "exited", + status: "failed", + title: "Failed shell", + startedAt: "2026-04-20T00:03:00.000Z" + ), + ]) + + let service = SyncService(database: database) + service.refreshActiveSessionsAndSnapshot() + + let failed = try XCTUnwrap(service.activeSessions.first(where: { $0.sessionId == "failed-chat" })) + XCTAssertEqual(failed.status, "failed") + XCTAssertEqual(failed.title, "Mobile failed chat") + XCTAssertEqual(failed.preview, "Tool call failed") + XCTAssertFalse(failed.awaitingInput) + let awaiting = try XCTUnwrap(service.activeSessions.first(where: { $0.sessionId == "awaiting-chat" })) + XCTAssertEqual(awaiting.status, "awaiting_input") + XCTAssertEqual(awaiting.title, "Mobile awaiting chat") + XCTAssertTrue(awaiting.awaitingInput) + XCTAssertEqual(service.awaitingInputSessionsCount, 1) + XCTAssertFalse(service.activeSessions.contains(where: { $0.sessionId == "completed-chat" })) + XCTAssertFalse(service.activeSessions.contains(where: { $0.sessionId == "failed-shell" })) + database.close() + } + func testTerminalResumeTargetDetectionMatchesDesktopResumeAvailability() { XCTAssertFalse(terminalSessionHasResumeTarget(makeTerminalSessionSummary( toolType: "shell", @@ -5222,6 +6246,7 @@ final class ADETests: XCTestCase { "codexSandbox": "workspace-write", "codexConfigSource": "host", "opencodePermissionMode": "edit", + "droidPermissionMode": "auto-low", "cursorModeSnapshot": [ "currentModeId": "ask", "availableModeIds": ["agent", "ask", "manual"], @@ -5261,6 +6286,7 @@ final class ADETests: XCTestCase { "lastOutputPreview": "Working...", "summary": "Primary chat session", "awaitingInput": true, + "pendingInputItemId": "pending-item-1", "threadId": "thread-1", "requestedCwd": "apps/ios/ADE", ] @@ -5270,6 +6296,7 @@ final class ADETests: XCTestCase { XCTAssertEqual(summary.sessionId, "chat-1") XCTAssertEqual(summary.provider, "cursor") + XCTAssertEqual(summary.droidPermissionMode, "auto-low") XCTAssertEqual(summary.cursorModeId, "ask") XCTAssertEqual(summary.cursorModeSnapshot, .object([ "currentModeId": .string("ask"), @@ -5279,6 +6306,7 @@ final class ADETests: XCTestCase { XCTAssertEqual(summary.cursorConfigValues?["temperature"], .number(0.5)) XCTAssertEqual(summary.completion?.artifacts?.first?.reference, "docs/transcript.md") XCTAssertTrue(summary.awaitingInput ?? false) + XCTAssertEqual(summary.pendingInputItemId, "pending-item-1") XCTAssertEqual(summary.requestedCwd, "apps/ios/ADE") } @@ -5584,6 +6612,109 @@ final class ADETests: XCTestCase { XCTAssertEqual(messages.filter { $0.role == "assistant" }.map(\.markdown), ["I'm Codex, based on GPT-5."]) } + func testPreferredWorkTranscriptReplacesTrimmedLiveTailWithFullFallbackText() { + let fullText = (1...200).map(String.init).joined(separator: "\n") + let tailText = (121...200).map(String.init).joined(separator: "\n") + let fallback = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:02.000Z", + sequence: nil, + event: .assistantText(text: fullText, turnId: "turn-1", itemId: nil) + ), + ] + let liveTail = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:02.000Z", + sequence: 98, + event: .assistantText(text: tailText, turnId: "turn-1", itemId: "msg-1") + ), + ] + + let preferred = preferredWorkTranscript( + current: liveTail, + fallback: fallback, + eventTranscript: liveTail + ) + let messages = buildWorkChatMessages(from: preferred) + + XCTAssertEqual(preferred.count, 1) + XCTAssertEqual(messages.filter { $0.role == "assistant" }.map(\.markdown), [fullText]) + } + + func testPreferredWorkTranscriptDoesNotBackfillQueuedSteerAsPlainUserMessage() { + let fallback = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:01.000Z", + sequence: nil, + event: .userMessage(text: "ship it", turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) + ), + ] + let live = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:01.000Z", + sequence: 1, + event: .userMessage(text: "ship it", turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) + ), + ] + + let preferred = preferredWorkTranscript( + current: live, + fallback: fallback, + eventTranscript: live + ) + + XCTAssertEqual(buildWorkChatMessages(from: preferred).map(\.markdown), []) + XCTAssertEqual(derivePendingWorkSteers(from: preferred).map(\.id), ["steer-1"]) + } + + func testLiveActiveTranscriptPreventsFallbackFromMaskingQueuedSteers() { + let fallback = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:01.000Z", + sequence: nil, + event: .assistantText(text: "old canonical reply", turnId: "turn-old", itemId: nil) + ), + ] + let live = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:02.000Z", + sequence: 2, + event: .status(turnStatus: "started", message: nil, turnId: "turn-active") + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:03.000Z", + sequence: 3, + event: .userMessage(text: "keep this staged", turnId: "turn-active", steerId: "steer-1", deliveryState: "queued", processed: nil) + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:04.000Z", + sequence: 4, + event: .systemNotice(kind: "info", message: "Message queued (#1) — will be sent after the current turn.", detail: nil, turnId: "turn-active", steerId: "steer-1") + ), + ] + + XCTAssertFalse(workChatShouldPreferFallbackTranscript( + fallbackTranscript: fallback, + sessionStatus: "idle", + liveTranscript: live + )) + + let preferred = preferredWorkTranscript( + current: [], + fallback: fallback, + eventTranscript: live + ) + XCTAssertEqual(derivePendingWorkSteers(from: preferred).map(\.id), ["steer-1"]) + } + func testPendingWorkInputItemIdsTracksResolvedApprovalAndQuestionEvents() { let transcript = [ WorkChatEnvelope( @@ -5803,6 +6934,12 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:00:03.000Z", sequence: 3, + event: .systemNotice(kind: "info", message: "Message queued (#1) — will be sent after the current turn.", detail: nil, turnId: "turn-1", steerId: "steer-1") + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:04.000Z", + sequence: 4, event: .userMessage(text: "also run tests", turnId: "turn-1", steerId: "steer-2", deliveryState: "queued", processed: nil) ), ] @@ -5917,6 +7054,41 @@ final class ADETests: XCTestCase { XCTAssertEqual(visibleWorkTimelineEntries(from: entries, visibleCount: 10).map(\.id), entries.map(\.id)) } + func testAssistantMessagePreviewBoundsHugeResponses() { + let markdown = (1...5000).map { "\($0). Line \($0)" }.joined(separator: "\n") + + let firstPage = workAssistantMessagePreview( + markdown, + lineBudget: workAssistantMessageInitialLineBudget, + characterBudget: workAssistantMessageCharacterBudget(forLineBudget: workAssistantMessageInitialLineBudget) + ) + + XCTAssertTrue(firstPage.isTruncated) + XCTAssertEqual(firstPage.visibleLineCount, 48) + XCTAssertEqual(firstPage.totalLineCount, 5000) + XCTAssertTrue(firstPage.text.contains("48. Line 48")) + XCTAssertFalse(firstPage.text.contains("49. Line 49")) + + let secondPageBudget = workAssistantMessageInitialLineBudget + workAssistantMessageLineBudgetStep + let secondPage = workAssistantMessagePreview( + markdown, + lineBudget: secondPageBudget, + characterBudget: workAssistantMessageCharacterBudget(forLineBudget: secondPageBudget) + ) + + XCTAssertEqual(secondPage.visibleLineCount, 96) + XCTAssertTrue(secondPage.text.contains("96. Line 96")) + XCTAssertFalse(secondPage.text.contains("97. Line 97")) + } + + func testWorkChatAccessibilityPreviewCapsHugeMessages() { + let text = String(repeating: "x", count: workChatAccessibilityPreviewLimit + 50) + let preview = workChatAccessibilityPreview(text) + + XCTAssertEqual(preview.count, workChatAccessibilityPreviewLimit + 3) + XCTAssertTrue(preview.hasSuffix("...")) + } + func testParseMarkdownBlocksUsesStableIdsAcrossRepeatedCalls() { let markdown = """ # Heading @@ -6010,6 +7182,16 @@ final class ADETests: XCTestCase { XCTAssertEqual(ADEColor.reasoningTiers(for: "gpt-5.5"), ["low", "medium", "high", "xhigh"]) } + func testMobileComposerReasoningTiersMirrorDesktopRegistry() { + XCTAssertEqual(ADEColor.reasoningTiers(for: "anthropic/claude-opus-4-7"), ["low", "medium", "high", "xhigh", "max"]) + XCTAssertEqual(ADEColor.reasoningTiers(for: "claude-opus-4-7"), ["low", "medium", "high", "xhigh", "max"]) + XCTAssertEqual(ADEColor.reasoningTiers(for: "opus"), ["low", "medium", "high", "xhigh", "max"]) + XCTAssertEqual(ADEColor.reasoningTiers(for: "anthropic/claude-sonnet-4-6"), ["low", "medium", "high"]) + XCTAssertNil(ADEColor.reasoningTiers(for: "claude-haiku-4-5")) + XCTAssertEqual(ADEColor.reasoningTiers(for: "openai/gpt-5.3-codex-spark"), ["low", "medium", "high", "xhigh"]) + XCTAssertEqual(ADEColor.reasoningTiers(for: "gpt-5.2"), ["low", "medium", "high", "xhigh"]) + } + func testDynamicWorkModelCatalogBuildsFromLiveHostModels() { let groups = workModelCatalogGroups( availableModelsByProvider: [ @@ -6234,6 +7416,48 @@ final class ADETests: XCTestCase { XCTAssertEqual(filtered.map(\.id), ["terminal-1"]) } + func testWorkFilteredSessionsMatchesLiveTerminalOutputTail() { + let terminalSession = makeTerminalSessionSummary( + id: "terminal-live", + laneId: "lane-1", + laneName: "release", + toolType: "codex-chat", + runtimeState: "idle", + title: "Phone terminal" + ) + let outputSearch = workSessionOutputSearchIndexBySessionId(buffers: [ + "terminal-live": "\u{001B}[2m• \u{001B}[0mMOBILE_OK\r\n\u{001B}[1m›\u{001B}[0m", + ]) + + let filtered = workFilteredSessions( + [terminalSession], + chatSummaries: [:], + archivedSessionIds: [], + selectedStatus: .running, + selectedLaneId: "all", + searchText: "mobile_ok", + outputSearchBySessionId: outputSearch + ) + + XCTAssertEqual(filtered.map(\.id), ["terminal-live"]) + } + + func testCtoLiveReloadThrottleSkipsBurstySyncRevisions() { + let baseline = Date(timeIntervalSince1970: 1_800_000_000) + + XCTAssertTrue(shouldRunCtoLiveReload(lastReloadAt: nil, now: baseline)) + XCTAssertFalse(shouldRunCtoLiveReload(lastReloadAt: baseline, now: baseline.addingTimeInterval(0.5))) + XCTAssertTrue(shouldRunCtoLiveReload(lastReloadAt: baseline, now: baseline.addingTimeInterval(2.0))) + } + + func testStoppableRuntimeSessionIncludesLiveAndIdleTerminalRows() { + XCTAssertTrue(isStoppableRuntimeSession(makeTerminalSessionSummary(toolType: "shell", runtimeState: "running", status: "running"))) + XCTAssertTrue(isStoppableRuntimeSession(makeTerminalSessionSummary(toolType: "shell", runtimeState: "idle", status: "running"))) + XCTAssertTrue(isStoppableRuntimeSession(makeTerminalSessionSummary(toolType: "shell", runtimeState: "waiting-input", status: "running"))) + XCTAssertFalse(isStoppableRuntimeSession(makeTerminalSessionSummary(toolType: "shell", runtimeState: "stopped", status: "exited"))) + XCTAssertFalse(isStoppableRuntimeSession(makeTerminalSessionSummary(toolType: "codex-chat", runtimeState: "running", status: "running"))) + } + func testWorkFilteredSessionsHidesRunOwnedRowsLikeDesktop() { let chatSession = makeTerminalSessionSummary( id: "chat-1", @@ -6632,6 +7856,26 @@ final class ADETests: XCTestCase { XCTAssertTrue(filesDiffHasChanges(deleted)) } + func testFilesDiffTreatsTruncatedSidesAsUnsafeToMarkClean() { + let truncated = FileDiff( + path: "large.txt", + mode: "modified", + original: DiffSide(exists: true, text: "same visible prefix", size: 196_690, isTruncated: true), + modified: DiffSide(exists: true, text: "same visible prefix", size: 762_000, isTruncated: true), + isBinary: false, + language: "text" + ) + + XCTAssertTrue(filesDiffHasChanges(truncated)) + XCTAssertEqual( + filesDiffPreviewLimit(diff: truncated), + FilesPreviewLimit( + title: "Diff preview paused", + message: "This diff is too large to compare fully on iPhone. Open the file from ADE on your machine or inspect a smaller diff before rendering it on iPhone." + ) + ) + } + func testWorkDisplayLeavesCleanRepeatedLettersAloneEvenWithManyDoubles() { // Real text with many legitimate double letters must NOT get collapsed. let natural = "Committee will assess the bookkeeping across all accounts, noting success, progress, commitment." @@ -7593,7 +8837,9 @@ final class ADETests: XCTestCase { title: String? = nil, status: String, awaitingInput: Bool? = nil, - lastActivityAt: String = recentIso8601Fixture() + archivedAt: String? = nil, + lastActivityAt: String = recentIso8601Fixture(), + pendingInputItemId: String? = nil ) -> AgentChatSessionSummary { AgentChatSessionSummary( sessionId: sessionId, @@ -7629,10 +8875,12 @@ final class ADETests: XCTestCase { idleSinceAt: nil, startedAt: "2026-03-25T00:00:00.000Z", endedAt: nil, + archivedAt: archivedAt, lastActivityAt: lastActivityAt, lastOutputPreview: nil, summary: nil, awaitingInput: awaitingInput, + pendingInputItemId: pendingInputItemId, threadId: nil, requestedCwd: nil ) @@ -7649,7 +8897,8 @@ final class ADETests: XCTestCase { lastOutputPreview: String? = nil, startedAt: String = recentIso8601Fixture(), resumeCommand: String? = nil, - resumeMetadata: TerminalResumeMetadata? = nil + resumeMetadata: TerminalResumeMetadata? = nil, + archivedAt: String? = nil ) -> TerminalSessionSummary { TerminalSessionSummary( id: id, @@ -7665,6 +8914,7 @@ final class ADETests: XCTestCase { status: status, startedAt: startedAt, endedAt: nil, + archivedAt: archivedAt, exitCode: nil, transcriptPath: "", headShaStart: nil, @@ -7678,6 +8928,42 @@ final class ADETests: XCTestCase { ) } + private func makeLaneSummary( + id: String, + name: String, + laneType: String, + branchRef: String + ) -> LaneSummary { + LaneSummary( + id: id, + name: name, + description: nil, + laneType: laneType, + baseRef: "main", + branchRef: branchRef, + worktreePath: "/tmp/\(id)", + attachedRootPath: nil, + parentLaneId: nil, + childCount: 0, + stackDepth: 0, + parentStatus: nil, + isEditProtected: false, + status: LaneStatus( + dirty: false, + ahead: 0, + behind: 0, + remoteBehind: 0, + rebaseInProgress: false + ), + color: nil, + icon: nil, + tags: [], + folder: nil, + createdAt: "2026-03-25T00:00:00.000Z", + archivedAt: nil + ) + } + private func jsonDictionary(from value: T) throws -> [String: Any] { let data = try JSONEncoder().encode(value) let raw = try JSONSerialization.jsonObject(with: data, options: []) @@ -8267,6 +9553,63 @@ final class ADETests: XCTestCase { XCTAssertEqual(timestamps, timestamps.sorted(), "Timeline must sort chronologically.") } + func testBuildWorkTimelineKeepsResolvedStructuredQuestionReadable() { + let detail = """ + { + "request": { + "itemId": "ap-1", + "kind": "structured_question", + "title": "Mobile question fixture", + "body": "Pick a mobile verification path.", + "questions": [ + { + "id": "flow", + "header": "Flow", + "question": "Which Work prompt flow should continue?", + "options": [ + {"label":"Question flow","value":"question_flow"}, + {"label":"Approval flow","value":"approval_flow"} + ] + }, + { + "id": "notes", + "header": "Notes", + "question": "Add an optional note for the mobile audit." + } + ] + } + } + """ + let transcript: [WorkChatEnvelope] = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:01.000Z", + sequence: 1, + event: .approvalRequest(description: "Mobile question fixture", detail: detail, itemId: "ap-1", turnId: "t-1") + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:02.000Z", + sequence: 2, + event: .pendingInputResolved(itemId: "ap-1", resolution: "accepted", turnId: "t-1") + ), + ] + + let snapshot = buildWorkChatTimelineSnapshot( + transcript: transcript, + fallbackEntries: [], + artifacts: [], + localEchoMessages: [] + ) + let questionCard = snapshot.eventCards.first { $0.kind == "question" } + XCTAssertEqual(questionCard?.title, "Question asked") + XCTAssertEqual(questionCard?.body, "Mobile question fixture\nPick a mobile verification path.") + XCTAssertEqual(questionCard?.bullets.first, "Flow: Which Work prompt flow should continue? Options: Question flow, Approval flow") + XCTAssertEqual(questionCard?.bullets.last, "Notes: Add an optional note for the mobile audit. Freeform response allowed.") + XCTAssertFalse(snapshot.eventCards.contains { $0.kind == "approval" }) + XCTAssertFalse(snapshot.eventCards.flatMap { [$0.body ?? ""] + $0.bullets }.contains { $0.contains("{") || $0.contains("\"request\"") }) + } + func testBuildWorkTimelineEmitsInlinePermissionAndSuppressesGenericEventCard() { let detail = """ {"request":{"itemId":"perm-1","kind":"permissions","tool":"functions.GitHub","description":"Allow GitHub MCP"}} @@ -8293,6 +9636,38 @@ final class ADETests: XCTestCase { XCTAssertTrue(hasPendingPermission, "Expected an inline .pendingPermission timeline entry.") } + func testBuildWorkTimelineKeepsResolvedPermissionReadable() { + let detail = """ + {"request":{"itemId":"perm-1","kind":"permissions","tool":"functions.GitHub","description":"Allow GitHub MCP"}} + """ + let transcript: [WorkChatEnvelope] = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:01.000Z", + sequence: 1, + event: .approvalRequest(description: "Allow", detail: detail, itemId: "perm-1", turnId: "t-1") + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:02.000Z", + sequence: 2, + event: .pendingInputResolved(itemId: "perm-1", resolution: "declined", turnId: "t-1") + ), + ] + + let snapshot = buildWorkChatTimelineSnapshot( + transcript: transcript, + fallbackEntries: [], + artifacts: [], + localEchoMessages: [] + ) + let permissionCard = snapshot.eventCards.first { $0.kind == "permission" } + XCTAssertEqual(permissionCard?.title, "Permission requested") + XCTAssertEqual(permissionCard?.body, "Allow\nAllow GitHub MCP") + XCTAssertEqual(permissionCard?.metadata, ["functions.GitHub"]) + XCTAssertFalse(snapshot.eventCards.flatMap { [$0.body ?? ""] + $0.bullets }.contains { $0.contains("{") || $0.contains("\"request\"") }) + } + func testBuildWorkTimelineSuppressesRawToolCardWhenPermissionRequestIsPending() { let detail = """ {"request":{"itemId":"perm-1","kind":"permissions","tool":"functions.GitHub","description":"Allow GitHub MCP"}} diff --git a/apps/ios/ADETests/AttentionDrawerModelTests.swift b/apps/ios/ADETests/AttentionDrawerModelTests.swift index d37394b49..69347bbfe 100644 --- a/apps/ios/ADETests/AttentionDrawerModelTests.swift +++ b/apps/ios/ADETests/AttentionDrawerModelTests.swift @@ -48,6 +48,7 @@ final class AttentionDrawerModelTests: XCTestCase { lastActivityAt: now, elapsedSeconds: 30, preview: nil, + pendingInputItemId: "pending-approval-1", progress: nil, phase: nil, toolCalls: 0 @@ -138,6 +139,7 @@ final class AttentionDrawerModelTests: XCTestCase { let awaiting = try? XCTUnwrap(model.items.first) XCTAssertEqual(awaiting?.sessionId, "s-awaiting") + XCTAssertEqual(awaiting?.itemId, "pending-approval-1") XCTAssertEqual(awaiting?.deepLink, URL(string: "ade://session/s-awaiting")) XCTAssertEqual(awaiting?.subtitle, "Approval needed") @@ -226,6 +228,84 @@ final class AttentionDrawerModelTests: XCTestCase { XCTAssertGreaterThan(stored, 0, "markAllSeen should persist the new lastSeenAt") } + func testClearVisibleItemsHidesCurrentCardsAndPersistsDismissal() { + let model = AttentionDrawerModel(defaults: defaults) + let now = Date() + let snapshot = WorkspaceSnapshot( + generatedAt: now, + agents: [], + prs: [ + PrSnapshot( + id: "pr-1", + number: 9101, + title: "Mobile attention CI failing", + checks: "failing", + review: "approved", + state: "open", + mergeReady: false + ) + ], + connection: "connected" + ) + + model.rebuild(from: snapshot) + XCTAssertEqual(model.items.map(\.id), ["ci:pr-1"]) + + model.clearVisibleItems() + + XCTAssertTrue(model.items.isEmpty) + XCTAssertEqual(model.unreadCount, 0) + XCTAssertEqual( + Set(defaults.stringArray(forKey: AttentionDrawerModel.dismissedItemIDsKey) ?? []), + ["ci:pr-1"] + ) + + let freshModel = AttentionDrawerModel(defaults: defaults) + freshModel.rebuild(from: snapshot) + XCTAssertTrue(freshModel.items.isEmpty, "persisted dismissals should hide the same still-active attention") + } + + func testClearedItemsReappearAfterBackingStateClears() { + let model = AttentionDrawerModel(defaults: defaults) + let now = Date() + let failing = WorkspaceSnapshot( + generatedAt: now, + agents: [], + prs: [ + PrSnapshot( + id: "pr-1", + number: 9101, + title: "Mobile attention CI failing", + checks: "failing", + review: "approved", + state: "open", + mergeReady: false + ) + ], + connection: "connected" + ) + + model.rebuild(from: failing) + model.clearVisibleItems() + model.rebuild(from: failing) + XCTAssertTrue(model.items.isEmpty) + + model.rebuild(from: .init( + generatedAt: now.addingTimeInterval(1), + agents: [], + prs: [], + connection: "connected" + )) + model.rebuild(from: .init( + generatedAt: now.addingTimeInterval(2), + agents: [], + prs: failing.prs, + connection: "connected" + )) + + XCTAssertEqual(model.items.map(\.id), ["ci:pr-1"]) + } + func testBadgeCapsAtNinePlus() { let model = AttentionDrawerModel(defaults: defaults) let now = Date() diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 567667030..856b6efce 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -295,7 +295,7 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. | Channel | Direction | Purpose | |---|---|---| -| `ade.agentChat.list` | invoke | List sessions with optional `includeIdentity`, `includeAutomation`. | +| `ade.agentChat.list` | invoke | List sessions with optional `includeIdentity`, `includeAutomation`, `includeArchived` (defaults to `true`; pass `false` to filter out archived rows). | | `ade.agentChat.getSummary` | invoke | Fetch `AgentChatSessionSummary` for a single session. | | `ade.agentChat.getEventHistory` | invoke | Return `AgentChatEventHistorySnapshot` for a session. `sessionFound: false` is the explicit stale-session signal used by renderer surfaces to clear dead locked panes. | | `ade.agentChat.create` | invoke | Create a new session; returns the `AgentChatSession`. Accepts `codexFastMode?: boolean` for Codex sessions to start with the `serviceTier: "fast"` default. | @@ -340,6 +340,26 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. guard: when `getRecentEntries` is called, the service flushes pending buffered text first so transcript reads always reflect the latest streamed content. +- **Transcript read merges streaming text fragments.** The + `MAX_TRANSCRIPT_READ_CHARS` budget is `120_000` (was `40_000`) and + the transcript reader collapses consecutive assistant text events + that share a `messageId` or `turnId` into one row instead of + emitting one row per fragment. The merge runs in two paths: a + keyed map indexed by `message:` / `turn:` for streamed + fragments that carry an id, and a running `assistantDraft` for + fragments that share state across events without an explicit id. + Both flush back to plain `AgentChatTranscriptEntry` rows before + returning so the on-wire shape is unchanged. +- **`AgentChatSessionSummary.pendingInputItemId` is the addressable + pending input.** When a session is awaiting input, the service + resolves the latest pending item id from the live runtime's + approval / permission / structured-question maps and, as a + fallback, replays the last 512 events looking for an unresolved + `approval_request` / `structured_question`. The same id is mirrored + into `TerminalSessionSummary.pendingInputItemId` for sync clients + that key off the terminal session row. iOS uses it to back + Approve/Deny/Reply intents in the Attention Drawer without opening + the chat. - **Steer delivery vs. turn completion.** `deliverNextQueuedSteer()` is invoked on every turn-end code path (success, failure, interrupt, Claude SDK error). Missing any path can strand a queued steer. diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 06e921ade..1de4ded7f 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -631,6 +631,17 @@ project scope split. wire format is identical; cr-sqlite feature parity is **not** guaranteed — any desktop-only cr-sqlite feature that ADE grows to depend on must also be implementable in SQL triggers on iOS. +- **iOS sends unpacked primary keys; the desktop daemon repacks + them.** The iOS emulation captures `crsql_changes.pk` as the raw + scalar (a string, integer, or already-bytes value) instead of the + cr-sqlite packed type-tagged byte string desktop emits. On the + receive side, `apps/desktop/src/main/services/state/kvDb.ts` + applies `normalizeIncomingCrsqlChange` to every inbound row before + the `crsql_changes` insert: bytes that already look packed are + passed through, while raw strings / ints / `0` / `1` are wrapped + into the matching `packedCrsqlPrimaryKey` byte layout the native + cr-sqlite extension expects. Skipping this step is how phone-side + edits silently fail to apply on the desktop. - **Controller command queues replay on reconnect.** If the host advertises `chat.send` as queueable and the user sends while the desktop is reconnecting, the iOS app stores the command locally with diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 0b33c21c1..2cb026755 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -29,7 +29,13 @@ apps/ios/ │ │ ├── ADEApp.swift # SwiftUI app entry │ │ ├── AppDelegate.swift # APNs registration, notification-category │ │ │ # setup, response/action routing, deep-link dispatch -│ │ ├── ContentView.swift # slim 6-tab TabView +│ │ ├── ContentView.swift # 5-tab TabView with a custom +│ │ │ # `ADERootBottomTabBar` overlay +│ │ │ # (Work/Lanes/PRs/Files/CTO + Work +│ │ │ # running-chat badge); the system tab +│ │ │ # strip is hidden and individual screens +│ │ │ # can hide the custom bar via +│ │ │ # `adeRootTabBarHidden()` │ │ ├── DeepLinkRouter.swift # ade://session/ + ade://pr/ URL handler │ │ │ # plus notification userInfo dispatch │ │ │ # (sessionId / prId / prNumber → prId via @@ -58,8 +64,12 @@ apps/ios/ │ │ ├── LiveActivityIntentsForward.swift # ADEIntentCommandKind, ADEIntentCommandRegistry │ │ └── WidgetAppIntents.swift # OpenADEIntent, ToggleMutePushIntent (iOS 18+) │ ├── Views/ -│ │ ├── Components/ # ADEDesignSystem (incl. ADEConnectionDot), -│ │ │ # haptics, shimmer, mobile primitives +│ │ ├── Components/ # ADEDesignSystem (incl. ADEConnectionDot, +│ │ │ # ADEUIKitAppearance.configureTabBar(), +│ │ │ # ADERootTabBarHiddenPreferenceKey), +│ │ │ # haptics, ADEMobilePrimitives (incl. +│ │ │ # ADEOptionButton for selection rows) +│ │ │ # — `ADEStreamingShimmer.swift` was retired │ │ ├── Cto/ # CtoRootScreen, CtoSessionDestinationView │ │ ├── Lanes/ # LaneDetailScreen, LaneActionsCard, │ │ │ # LaneAdvancedScreen (gearshape destination), @@ -456,7 +466,13 @@ over Apple Push Notification service. The full stack is implemented: Also sends Live Activity `liveactivity` pushes to per-activity update tokens when the notification maps to an attention value (chat awaiting input / failed, PR CI failing / review requested / merge - ready). + ready). `sendTestPush` has an explicit fallback: when APNs is not + configured but the target device is currently connected, the bus + delivers an in-app `system` notification and returns + `{ ok: true, reason: "in_app_only" }`, and the `ade serve` runtime's + `syncHostService` does the same when no notification bus is wired at + all, so "Send test push" on the phone always produces a visible + confirmation when the WebSocket is alive. **iOS client side**: @@ -589,8 +605,56 @@ extension without importing the main app's heavier renderer code. - `UIImpactFeedbackGenerator` and `UINotificationFeedbackGenerator` on message send, intervention approval, mission launch, PR merge. +### Attention Drawer + +Source: `apps/ios/ADE/Views/AttentionDrawer/`. + +The attention drawer is a single global sheet (`AttentionDrawerSheet`) +opened from the navigation bar bell. `AttentionDrawerModel` rebuilds the +roster from the App Group `WorkspaceSnapshot` whenever +`SyncService.activeSessions` or `workspaceSnapshotRevision` changes, and +projects each row into an `AttentionItem` that carries the originating +session/PR ids plus an optional `itemId` lifted from +`AgentChatSessionSummary.pendingInputItemId` / `AgentSnapshot.pendingInputItemId`. + +Each row renders inline actions sourced from the same surface the +notification banners use: + +- **Awaiting input** — when an `itemId` is present, the row shows + Approve / Deny buttons backed by `ApproveSessionIntent` / + `DenySessionIntent`; otherwise the primary action is "Open session" + (which still routes through `Reply`-style behaviour via deep link). +- **Failed** — "Open agent" plus a `RestartSessionIntent` chip. +- **CI failing** — "Open #N" plus `RetryCheckIntent` to rerun checks. +- **Review requested / merge ready** — "Review" / "Merge" / + "View" entries that deep-link into the PR detail surface. + +`AttentionDrawerModel.clearVisibleItems()` snapshots the current set of +ids into `dismissedItemIDs` (persisted under +`ade.attention.dismissedItemIDsKey` in App Group `UserDefaults`) and +prunes the in-memory list. The pruning step in +`pruneDismissedItems(activeIDs:)` runs on every rebuild, so a future +regression — a chat re-entering awaiting-input, a PR going red again — +re-surfaces the card automatically. The "Clear all" toolbar button calls +this method; the cards do not silently come back until the underlying +attention recurs. + ## Tab structure +The root shell is a `TabView` whose system tab bar is suppressed +(`toolbar(.hidden, for: .tabBar)`) in favour of a hand-rolled +`ADERootBottomTabBar` injected as a bottom safe-area inset. The custom +bar exposes the five shipped tabs (Work / Lanes / PRs / Files / CTO), +renders a per-tab selection highlight, and shows a red `Capsule` badge on +the Work tab driven by `SyncService.runningChatSessionCount` +(`min(count, 99)`). Detail screens that should claim the full height — +new-chat / model-setup / advanced flows — opt out by emitting an +`ADERootTabBarHiddenPreferenceKey` value via the `.adeRootTabBarHidden()` +modifier. `ADEUIKitAppearance.configureTabBar()` (called from +`ContentView.onAppear`) also tunes the underlying UIKit `UITabBar` +appearance so any system surface that still falls through (sheets, +push-controllers built from UIKit) matches the SwiftUI chrome. + Before the tabs render, `ProjectHomeView` can take over the root screen when no active project is selected or the user taps the Projects toolbar button. It merges the host-provided catalog with projects already present @@ -621,10 +685,10 @@ duplicate. Project list dedup runs as a final pass |---|---|---|---| | **Lanes** | `square.stack.3d.up` | `/lanes` | Full lane surface: search/filter chips, open/create/attach/manage, multi-attach for unregistered worktrees, stack canvas, git/diff/rebase/conflicts, template-backed environment setup progress, lane-scoped sessions and AI chats. `devicesOpen` presence chips show which other devices currently have the lane open. The lane gear opens `LaneAdvancedScreen`, a single page that groups Manage / Switch branch / Stash and the destructive git escape hatches (rebase lane, rebase descendants, rebase + push, force push) with an inline description per row and an offline disabled banner. The commit sheet (`LaneCommitSheet`) renders staged + unstaged file lists with per-file stage / unstage / discard / restore / open-diff / open-files actions, a "Suggest" AI button gated by host capability, and a setup-hint card surfaced when the host returns "AI commit messages are off". | | **Files** | `doc.text` | `/files` | Lane-backed workspace picker, live file tree/search/read, protected-workspace read-only parity. `mobileReadOnly` on the workspace payload gates mutating file actions on the phone via `ensureMobileFileMutationsAllowed`; quick-open and text-search result lists cap visible rows at 40 and ask the user to refine when more matches exist. | -| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, character-by-character terminal input (Termius-style: each typed glyph forwards a single `terminal_input` byte and the field clears so PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid / shell), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the host (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **ADE chat** and **CLI session** via a segmented picker; the CLI mode submits `work.startCliSession` with the chosen provider, permission mode, and an optional opening message that the host types into the spawned PTY for non-shell providers. The terminal viewer (`WorkTerminalEmulatorView`) is a UIKit-backed monospaced screen that drives a `WorkTerminalScreen` model, computes its viewport in (cols, rows) from the rendered glyph cell, forwards each viewport change as `terminal_resize`, and unsubscribes via `terminal_unsubscribe` when the screen disappears. The earlier "activity feed" section was retired — running chats are surfaced through the session list and the live-count chip. | +| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, character-by-character terminal input (Termius-style: each typed glyph forwards a single `terminal_input` byte and the field clears so PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid / shell), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the host (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **ADE chat** and **CLI session** via a segmented picker; in CLI mode a `workCliProviderOptions` row picker exposes each supported provider explicitly. CLI mode submits `work.startCliSession` with the chosen provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the host types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`WorkTerminalEmulatorView`) is a UIKit-backed monospaced screen that drives a `WorkTerminalScreen` model, computes its viewport in (cols, rows) from the rendered glyph cell, forwards each viewport change as `terminal_resize`, and unsubscribes via `terminal_unsubscribe` when the screen disappears. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, workflow cards (queue / integration / rebase) rendered from `PrWorkflowCard`, per-PR action capabilities. | -| **CTO** | `sparkles` | `/cto` | CTO snapshot: Chat / Team / Workflows segments, with the mobile workflows screen mirroring the desktop workflow policy/dashboard and preserving the shared glass navigation chrome. Drills into per-worker chat sessions via `CtoSessionDestinationView`. | -| **Settings** | `gearshape` | `/settings` (sync subset) | PIN pairing (`SettingsPinSheet`), notification preferences (`NotificationsCenterView`), quiet hours, per-session overrides, appearance, diagnostics, connection header with QR payload and address candidates, reconnect, forget. | +| **CTO** | `brain.head.profile` | `/cto` | CTO snapshot: Chat / Team / Workflows segments, with the mobile workflows screen mirroring the desktop workflow policy/dashboard and preserving the shared glass navigation chrome. Drills into per-worker chat sessions via `CtoSessionDestinationView`. | +| **Settings** | `gearshape` | `/settings` (sync subset) | PIN pairing (`SettingsPinSheet`), notification preferences (`NotificationsCenterView`), quiet hours, per-session overrides, appearance, diagnostics, connection header with QR payload and address candidates, reconnect, forget. `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`) instead of having them reach into `SyncService` directly. `sendTestPush` is now `async` and returns a `SyncSendTestPushResult` (`ok`, `message`); the Notifications section renders that message verbatim so APNs-not-configured / in-app-only / wire failure cases all surface to the user. | ### Planned @@ -864,7 +928,52 @@ reflected in the phone's UI on the next descriptor read. — those come from the shared `apps/desktop/src/shared/cliLaunch.ts`. Adding a sixth provider means updating both the host registry and the phone's - `workCliProviderOptions` together. + `workCliProviderOptions` together. `SyncStartCliSessionArgs` also + carries an optional `reasoningEffort` field that the host forwards + to `buildTrackedCliLaunchCommand`, so the phone can launch a Codex + / Claude CLI session at a non-default effort tier without going + through the desktop. +- **Codex CLI launches receive the initial prompt as argv, not PTY + echo.** Other providers still receive `initialInput` as bytes typed + into the spawned PTY (`writeBySessionId(sessionId, "${input}\\r")`), + but Codex receives it as the final positional argv on `codex` via + `buildTrackedCliLaunchCommand` so the model sees a clean first turn + instead of a typed shell line. Plain "Shell" launches go through + `resolveCleanShellLaunchFields` so the spawned shell never reads the + user's profile / rc / config files. +- **Pending-input item id flows out through chat summaries.** Both + `AgentChatSessionSummary.pendingInputItemId` and + `TerminalSessionSummary.pendingInputItemId` are populated by the + host whenever a session is in `awaitingInput`, derived from the + live runtime's pending input map and (as fallback) from the recent + event history. iOS reads it into `AgentSnapshot.pendingInputItemId` + and `AttentionItem.itemId`, which is the value the AppIntents-backed + Approve / Deny / Reply buttons need to address a specific approval — + the phone can decide an awaiting-input row at the source instead of + forcing the user to open the session. +- **`AttentionDrawerModel.clearVisibleItems()` persists dismissals + scoped to the active id set.** Ids are stored under + `ade.attention.dismissedItemIDs` and pruned on every rebuild + against the live active set, so a chat that re-enters + awaiting-input or a PR that goes red again resurfaces automatically. + Do not turn this into a permanent allowlist; recurrence visibility + is the whole point. +- **`NotificationPreferences.save(to:)` writes the pruned struct.** + Per-session overrides with both switches off are equivalent to no + override; the iOS save path calls + `pruningInactivePerSessionOverrides` before encoding so toggling + agents on and then back off does not bloat the App Group + `UserDefaults` payload. The same pruning happens on the desktop + side in `normalizeNotificationPreferences` to keep both ends in + agreement after a round-trip. +- **The runtime daemon's iOS sync wants `ADE_PROJECT_ROOT` for + preferred project.** `ade serve` reads `ADE_PROJECT_ROOT` and + pre-registers the project through `ProjectRegistry.add` so the sync + host opens with that project as the preferred one + (`scopeRegistry.ensureSyncHost(preferredSyncProjectId)`). Without + it, the daemon still starts the host but does not pin a project, + and the phone has to wait for the desktop to switch projects before + it can issue project-scoped commands. - **Continuing an ended agent CLI row goes through `work.sendToSession`.** The phone keeps the transcript visible, collects the user's next message, and sends it with the durable `sessionId`. The host writes diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index dac650f33..3ff760c4c 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -111,7 +111,7 @@ runtime-scoped registrations are explicit. Listed in order of appearance in the registry: **Lanes** (`lanes.*`) -- `list`, `refreshSnapshots`, `getDetail` +- `list`, `refreshSnapshots`, `getDetail`, `listUnregisteredWorktrees` - `create`, `createChild`, `createFromUnstaged`, `importBranch`, `attach`, `adoptAttached` - `rename`, `reparent`, `updateAppearance` @@ -183,13 +183,26 @@ both via `SyncService.dispatchChatSteer` / **PRs** (`prs.*`) - `list`, `refresh`, `getDetail`, `getStatus` - `getChecks`, `getReviews`, `getComments`, `getFiles` -- `createFromLane`, `draftDescription`, `land`, `close`, `reopen`, - `requestReviewers`, `rerunChecks`, `addComment` +- `createFromLane`, `createQueue`, `draftDescription`, `land`, + `close`, `reopen`, `requestReviewers`, `rerunChecks`, `addComment` +- `simulateIntegration`, `commitIntegration`, + `listIntegrationWorkflows`, `updateIntegrationProposal`, + `deleteIntegrationProposal`, `startIntegrationResolution`, + `recheckIntegrationStep` +- `landQueueNext`, `startQueueAutomation`, `pauseQueueAutomation`, + `resumeQueueAutomation`, `cancelQueueAutomation` - `getMobileSnapshot` — aggregate read that returns `PrMobileSnapshot` (summaries, stacks, per-PR capabilities, create-PR eligibility, workflow cards). Consumed by the iOS PRs tab; see `ios-companion.md` for the shape. +**CTO** (`cto.*`) +- `removeAgent` — drop a worker from the team and trigger a + `workerHeartbeatService.syncFromConfig` resync so the live + roster reflects the removal immediately. Phone-driven CTO + management uses this in tandem with `setAgentStatus`, + `triggerAgentWakeup`, and `rollbackAgentRevision`. + The canonical list is typed as `SyncRemoteCommandAction` in `apps/desktop/src/shared/types/sync.ts`. diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index c748ad687..a65279722 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -288,7 +288,31 @@ Renderer surfaces: inverse it relies on for round-tripping. `resolveLaunchFields` is the atomic-override helper that mixes a caller's `command`/`args`/`startupCommand`/`env` with the profile defaults - (only when the caller passed nothing). + (only when the caller passed nothing). `TRACKED_CLI_PERMISSION_MODES` + now includes `auto` (Claude only; mapped onto the SDK + `permissionMode: "auto"`); `validateLaunchProfilePermissionMode` + rejects `auto` for any non-Claude provider and rejects `config-toml` + for any non-Codex provider. Codex CLI launches that pass an + `initialPrompt` receive it as the final argv positional on `codex`, + not as PTY-typed bytes, so the prompt is treated as a real first + turn and does not become a half-typed shell line; Codex argv also + appends `codexNoisyLocalMcpDisableFlags` (`-c + mcp_servers.unityMCP.enabled=false`, `-c + mcp_servers.xcode.enabled=false`) for every non-`config-toml` + launch so unbundled local MCP servers do not auto-spawn under ADE. + Plain "shell" launches and `resolveCleanShellLaunchFields({ + platform, shell, comSpec })` together produce a deterministic + argv/env per OS that skips the user's profile / rc / config files + (zsh `-f` with `ZDOTDIR=/var/empty`, bash `--noprofile --norc` with + `BASH_ENV=""`, fish `--no-config`, PowerShell `-NoLogo -NoProfile`, + `cmd.exe /d`); `ptyService.resolveShellCandidates({ clean: true })` + uses the same recipe for interactive shell sessions launched + without a startup command. `deriveTrackedCliInitialInputSessionMeta` + seeds the session title and `goal` field from the first prompt + (sanitised + clipped to ~72 chars) when the caller did not supply a + manual title, so tracked CLI rows render with a meaningful name + instead of "Codex" / "Claude" while still letting providers like + Shell fall back to the generic profile title. - `apps/desktop/src/renderer/components/terminals/cliLaunch.ts` — thin re-export of `apps/desktop/src/shared/cliLaunch.ts` so existing renderer callers keep their import path. diff --git a/docs/features/terminals-and-sessions/pty-and-processes.md b/docs/features/terminals-and-sessions/pty-and-processes.md index 1cd7f1ab8..a5cd37513 100644 --- a/docs/features/terminals-and-sessions/pty-and-processes.md +++ b/docs/features/terminals-and-sessions/pty-and-processes.md @@ -188,7 +188,17 @@ Each live PTY has an entry in the `ptys` map keyed by `ptyId` with: when Claude/Codex are only resolvable through `~/.zshrc` shims. - Otherwise iterate the shell candidate list (`/bin/zsh`, `/bin/bash`, `/bin/sh`, or Windows equivalents), retrying across - candidates if the first spawn fails. + candidates if the first spawn fails. Plain interactive shell + sessions (`toolType === "shell"` with no direct command and no + startup command) opt into the **clean shell** candidate set: + `resolveShellCandidates({ clean: true })` returns the same shell + binaries but pinned to `args` + `env` overlays that skip user + init files (zsh `-f` with `ZDOTDIR=/var/empty`, bash + `--noprofile --norc` with `BASH_ENV=""`, fish `--no-config`, + PowerShell `-NoLogo -NoProfile`, `cmd.exe /d`). The overlays are + applied per candidate so an `args.env` from the caller is + overlaid first, then the clean-shell `env` block, before + `ptyLib.spawn`. 10. If the spawn ended up in a shell (no direct launch, or direct launch fell back), type `args.startupCommand` into the PTY so the shell executes the CLI. Direct launches that succeeded skip this — diff --git a/docs/perf/ios-mobile-action-inventory.md b/docs/perf/ios-mobile-action-inventory.md new file mode 100644 index 000000000..28b03993b --- /dev/null +++ b/docs/perf/ios-mobile-action-inventory.md @@ -0,0 +1,303 @@ +# iOS mobile action inventory + +This pass adapts `.agents/skills/ade-autoresearch/SKILL.md` to the ADE iOS +simulator. The audit uses the real iOS app on an iPhone 17 simulator, connected +directly to the headless ADE runtime for `/Users/admin/Projects/perf pass`. +Desktop Electron is not the source of truth for this pass. + +This inventory is intentionally strict. Rows marked `measured` were driven in +the simulator with visible UI evidence. Rows marked `prompt-only` were opened to +the preflight/prompt boundary. Rows marked `deferred` name the concrete fixture, +external-boundary, or unavailable-service reason that blocked safe execution in +this pass. + +## Environment + +- Lane under test: `/Users/admin/Projects/ADE/.ade/worktrees/huge-ade-mobile-pass-831383b3` +- Fixture project: `/Users/admin/Projects/perf pass` +- Latest audit runtime socket: `/tmp/ade-mobile-audit/ade.sock` (headless `ade serve`; stopped after the 2026-05-19 continuation cleanup) +- Simulator: `2CD8BD1C-C5F5-4B9D-B446-803488E4F559` (`iPhone 17`, iOS 26.3.1) +- Bundle: `com.ade.ios` +- Current accepted trace: `/tmp/ade-ios-mobile-pass-traces/pr-date-cache.trace` +- Rejected trace: `/tmp/ade-ios-mobile-pass-traces/bonjour-dedupe.trace` + +## Fixed during pass + +| Area | Status | Evidence | +| --- | --- | --- | +| Headless runtime project selection | fixed | `ade serve` registers the `ADE_PROJECT_ROOT` project so the phone syncs `/Users/admin/Projects/perf pass`, not the most recent desktop project. | +| Project picker idle flicker | fixed | Computer Use caught the project picker showing duplicate cached `perf pass` rows and idle reconnect churn. The app now deduplicates cached projects by normalized root while keeping the active project first, ignores timestamp-only discovery refreshes for published host lists, and stops scanning Bonjour `.local` hosts across the whole fallback port window. CUA verified the same screen now shows one 4-lane `perf pass` row, and recent simulator logs contain no `ne_tracker_check_is_hostname_blocked` spam. | +| Notification preference mirror | fixed | Codex Computer Use cycled category toggles, quiet-hours enable/edit/clear, and per-session override mute/awaiting-only controls. iOS now prunes inactive per-session override rows before saving/sending, desktop normalization drops inactive legacy rows, and reconnect uploads saved notification preferences so offline toggle changes clear stale host metadata. | +| Send test push feedback | fixed | Computer Use tapped `Send test push` on the real Notifications screen. The previous hidden `notification_bus_unavailable` host failure is now surfaced, and the headless throwaway runtime delivers a foreground in-app ADE notification over the sync WebSocket when APNs is unavailable. CUA verified both the banner and the success text: `Test notification delivered in app. APNs is not wired in this runtime.` | +| Settings sheet flicker and appearance | fixed | Codex Computer Use reproduced the live Settings sheet while paired to the throwaway runtime. Settings now reads a throttled Equatable presentation snapshot instead of observing every `SyncService` publish, and fresh `lastSyncAt` churn renders as stable `just now`. CUA cycled Light/Dark/System appearance; Light and Dark now apply to the modal itself. Repeated simulator captures after the fix were pixel-identical (`magick compare -metric AE` returned `0`). | +| Settings discovery row churn | fixed | CUA reproduced the disconnected Settings discovery sheet showing duplicate service rows for the same LAN machine (`lappy` / `MacBook-Pro-567.local` at `192.168.1.249`) and a raw-count label. Discovery display now coalesces duplicate live services by primary displayed route, keeps distinct LAN machines separate even when secondary/Tailscale metadata overlaps, and the Settings button uses the same coalesced count as the sheet. CUA verified `2 nearby machines found` and two unique rows (`Mac.lan`, `MacBook-Pro-567.local`); repeated sheet captures compared at `0` content-pixel delta below the status bar. | +| Desktop apply of mobile CRDT changes | fixed | Desktop normalizes legacy text/numeric single-column primary keys before writing `crsql_changes`. | +| iOS export of local CRDT changes | fixed | iOS exports single-column primary keys in packed cr-sqlite format. | +| Files quick-open/text-search empty states | fixed | Quick-open `main` and text-search `perf` showed result rows without stale empty text. | +| Files browser row actions and reload churn | fixed | Browser rows no longer reload on every global sync revision. File/directory rows now expose a visible actions button plus swipe/accessibility copy actions; Computer Use copied `README.md` and `/Users/admin/Projects/perf pass/README.md` from the real simulator row. | +| CTO Settings crash | fixed | Settings reopened after sequential load; no new crash report or `SIGABRT`. | +| CTO Workflows partial load | fixed | Workflows render with `Linear not connected` when no credential service is present. | +| CTO live reload churn | fixed | CTO root/team reloads now throttle bursty `localStateRevision` sync ticks instead of refetching CTO state on every database bump. Computer Use stayed on CTO Team while a host shell session created a sync revision; the worker card stayed visible without flashing back to skeleton/loading chrome. | +| Lane detail stale refresh | fixed | Lane detail shows Stashes, Recent commits, and Advanced after live local-revision refresh. | +| Work chat routing | fixed | Existing and newly-created chat sessions reopen without `Session unavailable`. | +| Work prompt history payloads | fixed | Codex Computer Use answered and declined live structured question prompts in `MOBILE_Q_PROMPT_FIXTURE`. After resolution, the timeline previously rendered the raw `approval_request` JSON as an `Approval needed` card. Resolved structured-question, plan-approval, and permission request events now reuse the pending-input parsers and render readable history cards. CUA verified `Question asked` + `Input resolved · Accepted/Declined` cards with no raw JSON, and repeated captures of the fixed screen compared at `0` pixel delta. | +| Work chat composer overlap | fixed | CUA caught `MOBILE_APPROVAL_FIXTURE` with the pending-input composer banner overlapping transcript/proof content. The large redundant composer banner was removed because approval/question/permission/plan cards already render inline, and non-interactive top/bottom scrims now keep chat content from visually bleeding under navigation/composer chrome. CUA verified proof cards, command cards, plan transcript cards, the bottom composer, and the custom back button after the fix. | +| Work chat long response performance | fixed | User/CUA caught the 5,000-line Claude response path making the Simulator unstable. The live assistant-bubble gloss/shimmer path is removed, long assistant responses now render as a bounded plain-text preview instead of full Markdown/accessibility trees, expansion is capped in 96-line pages, and `Copy full` preserves access to the full text without rendering it. CUA reopened `MOBILE_STAGE_BUTTON_REAL`, saw `96 of 5,000 lines`, expanded to `192 of 5,000 lines`, and simulator `pbpaste` verified the copied response ends at `5000. Done`. | +| Root tab bar glass bleed and Files detail refresh flicker | fixed | CUA caught the iOS 26 floating tab bar refracting live Work row text through the bottom controls and Files detail reloading on broad `localStateRevision` churn. ADE now renders an app-owned opaque root tab bar, lets pushed chat/CTO/detail screens hide it through a SwiftUI preference, and coalesces Files detail refreshes while preserving visible history/metadata during reload. CUA verified Work root no longer ghosts `CURRENT_CHAT_OK` through the bar, Work chat hides the bar, Files root switches tabs cleanly, and `mobile-history.txt` detail keeps its footer visible above the bottom chrome. | +| Attention drawer clear-all, failed/awaiting routing, and Work shell bloom | fixed | CUA caught the half-sheet showing visible attention cards while `Clear all` was disabled because opening the sheet auto-marked items seen. The drawer now persists local dismissals for visible attention IDs, prunes those dismissals once the backing condition clears, keeps failed and awaiting-input chat sessions in the workspace snapshot, and routes `ade://session/` into the Work detail stack instead of only posting an unhandled notification. The Work root also removes the duplicate live chip and replaces the worst blur/material stacks with stable fills to stop the visible flicker/crowding on the live screen. CUA verified `MOBILE_FAILED_ATTENTION_FIXTURE` opens the failed chat detail, `MOBILE_AWAITING_ATTENTION_FIXTURE` appears as `NEEDS INPUT`, the drawer shows an Awaiting input card with compact actions, and the awaiting card opens a detail screen with an explicit `Prompt details syncing` notice instead of pointing at an invisible prompt. | +| Work CLI terminal input | fixed | Terminal input uses a visible line buffer and submits full lines on Return; Computer Use drove the Simulator composer and got `CLEAN_CLI_OK` back from a live Codex TUI. | +| Work CLI terminal hydration | fixed | Visible terminal buffers now use snapshot backstops when live output is empty or stale, so newly opened CLI sessions hydrate without waiting on a missed live chunk. | +| Work CLI terminal resize | fixed | Mobile terminal view re-sends its viewport after `terminal_subscribe` succeeds, so the host does not drop the first resize. CLI launches now start at phone-sized `48x24`, and viewport reporting keeps a two-column safety margin for the text container. | +| Work CLI terminal flicker | fixed | Terminal buffer revisions no longer force a `UITextView.attributedText` replacement when the transcript is unchanged, and duplicate terminal snapshots no longer bump the terminal revision. CUA verified live phone typing in `MOBILE_INTERACTIVE_SHELL_REAL`, and repeated post-fix simulator captures compared at `0` pixel delta. | +| Managed Codex CLI startup warnings | fixed | Managed Codex terminal launches disable stale local `unityMCP`/`xcode` MCP entries unless the user selects raw `config-toml`; the fresh phone-driven Codex transcript contained no `MCP startup incomplete` warning. | +| Work search live terminal output | fixed | Searching `MOBILE_OK` finds live terminal output after buffer hydration. | +| Model picker accessibility | fixed | Provider/model/reasoning controls remain reachable without duplicated model-row labels. | +| Model picker Codex handoff | fixed | Computer Use selected the Codex provider tab and a `GPT-5.5` reasoning option from the real sheet; the launched Codex TUI reported `gpt-5.5` with the selected reasoning in `/Users/admin/Projects/perf pass`. | +| Mobile runtime model picker latency | fixed | The phone no longer blocks the sheet on a slow paired-runtime model catalog request. `chat.models`/`chat.modelCatalog` use a short non-disconnecting timeout, and the iOS model picker renders the curated runtime catalog immediately while the live catalog refreshes. CUA first reproduced the stuck `Loading models from paired machine...` state, then verified immediate CLAUDE/CODEX/CURSOR/DROID/OPEN tabs after rebuild. | +| Mobile runtime steering parity | fixed | Native segmented pickers/menus for provider, access mode, reasoning, sort/mode choices, PR merge strategy, CTO worker status, and Files/Lanes workspace choices have been replaced with visible direct controls that mirror the desktop/TUI action surfaces. CUA verified Work active-session access chips (`Default`, `Auto`, `Plan`, `Auto edit`, `Bypass`), reasoning chips (`Low`, `Medium`, `High`), New Chat provider chips, Files workspace chips, Work filters, PR path pipeline options, and CTO worker status options as individually tappable controls. | +| Work New Chat direct CLI runtime | fixed | CUA launched a fresh CLI session from the mobile New Chat screen without starting ADE desktop, selected the Shell provider, typed through the phone terminal, tapped `Send Return`, and verified live runtime output `MOBILE_CLI_DIRECT_RUNTIME_OK_0202` in the terminal. | +| Work Go to lane routing | fixed | Codex Computer Use reproduced the long-press `CHAT_FINAL_OK_X` -> `Go to lane` failure. The app now resolves stale archived lane ids to the active lane with the same identity and opens the Primary lane detail from the Work context menu. | +| Work runtime stop actions | fixed | Shell/terminal rows now use one shared `isStoppableRuntimeSession` predicate for row and bulk actions, and expose `Stop runtime` through swipe/accessibility actions. Computer Use selected `MOBILE_STOP_ONE` and `MOBILE_STOP_TWO`, tapped `Stop 2`, and both host/simulator DB rows moved to ended states with `pty_id` cleared. | +| PR attention hydration | fixed | iOS PR hydration now scopes host PR payload rows to the active phone project id, writes the App Group workspace snapshot after PR refresh, flushes shared defaults, and refreshes the attention drawer on snapshot revision changes. | +| CTO worker detail actions | fixed | Core memory now routes through `workerAgentService.getCoreMemory`; worker dismissal has a reliable alert flow and calls the new `cto.removeAgent` remote command before dismissing the detail screen. | +| Lane form accessibility | fixed | Add/manage lane text fields are plain editable fields that accept simulator input. | +| Lane branch picker layout | fixed | Long branch menus no longer overlap the sheet controls. | +| Lane commit destructive actions | fixed | CUA reproduced the row-level `Discard` button failing from the half-sheet. Destructive confirmations now live inside `LaneCommitSheet`, staged/unstaged row actions wrap instead of clipping offscreen, and CUA verified Discard and Discard staged remove the files from the throwaway worktree. | +| PR list date parsing | fixed | `prParsedDate` caches parsed ISO dates; cross-tab sweep hang improved from `931.35 ms` to `896.04 ms` on the measured PR transition. | +| PR detail live sidecar churn | fixed | PR detail local-revision reloads no longer re-run live sidecar commands (`prs.getReviewThreads`, action runs, activity, deployments, AI summary, issue inventory, pipeline settings, mobile snapshot) after the initial sidecar load. Pull-to-refresh and successful PR actions still force live sidecar refresh. | +| PR link-to-lane sheet handoff | fixed | CUA reproduced `PR details` -> `Link to lane` dropping back to the PR list without presenting the lane picker. The detail sheet now queues the lane-link request until the first sheet finishes dismissing. CUA verified the lane-link sheet appears, the row-level Link action opens the same sheet, `Open on GitHub` launches Safari, and PR #18 refreshed to `ADE` after linking/importing the matching `perf/rebase-4` lane in the throwaway repo. The mobile sheet now only offers lanes whose branch matches the PR head branch, mirroring the host `prs.linkToLane` policy. | +| PR create sheet geometry and flicker | fixed | CUA caught the Create PR wizard opening with a long `Not eligible` wall and the title editor buried under the rounded sheet bottom. The wizard now presents as a large scroll-first sheet, snapshots lane eligibility while open, collapses blocked lanes behind `Show N more`, and leaves more bottom scroll room. CUA verified Single reveal/collapse, Queue lane selection, Integration two-lane selection, bottom Review scrolling, and repeated simulator captures of the fixed wizard compared at `0` pixel delta. | +| PR Activity composer occlusion | fixed | CUA caught the PR Activity tab with the global bottom `Merge` CTA covering the activity composer area. The sticky merge bar is now suppressed on Activity because that tab owns the comment/reply composer. After rebuild, CUA verified the timeline, AI resolver card, and `Comment on PR…` composer are visible above the tab bar. | +| PR checks summary fallback | fixed | CUA caught PR #9101 from the attention drawer showing `Ready to merge` / `All checks green` while the drawer reported failing CI and individual check rows had not synced yet. The merge gate and Checks tab now fall back to the PR summary status: PR #9101 shows `Merge blocked` / `checks failing`, Fail 1 / Total 1, summary-specific empty copy, visible AI resolver, and enabled `Rerun failed checks`. | +| Files large diff truncation | fixed | CUA caught `mobile-large-text.txt` marked `MODIFIED` in Files while its detail Diff tab said `No working tree changes`. The host had truncated both diff sides to the same visible prefix; iOS now decodes truncation metadata and shows `Diff preview paused` rather than falsely marking the diff clean. | +| Mobile PR workflow sync commands | fixed | Continuation audit found iOS sending PR workflow commands that the headless sync router did not register. The runtime now registers `prs.createQueue`, `prs.simulateIntegration`, `prs.commitIntegration`, and `prs.startQueueAutomation`, the shared remote-command union includes them, and the mobile AI resolver uses the supported `prs.pathToMerge.start` / `prs.pathToMerge.stop` commands instead of unsupported `prs.aiResolutionStart` / `prs.aiResolutionStop`. The PR create wizard also sends the visible default target branch (`main`) for Queue and Integration submits instead of omitting it when the user leaves the target unchanged. | + +## Perf evidence + +| Run | Trace | Result | +| --- | --- | --- | +| Cross-tab sweep before PR date cache | `/tmp/ade-ios-mobile-pass-traces/current-postfix.trace` | One main-thread hang: `931.35 ms` at `00:24.163`. Trace samples pointed at repeated PR date parsing and PR list recomputation. | +| Cross-tab sweep after PR date cache | `/tmp/ade-ios-mobile-pass-traces/pr-date-cache.trace` | One main-thread hang: `896.04 ms` at `00:02.691`, a `3.8%` improvement. Remaining samples mostly show Bonjour/sync receive and profile persistence work. | +| Bonjour route-dedupe attempt | `/tmp/ade-ios-mobile-pass-traces/bonjour-dedupe.trace` | Rejected. Hang regressed to `963.53 ms`, so the code change was reverted. | + +## Coverage matrix + +| Surface | Action group | Status | Evidence or gap | +| --- | --- | --- | --- | +| Pairing/settings | Discover saved host, reconnect to `Mac.lan`, connected state | measured | App connected directly to the headless runtime; no desktop renderer required. | +| Pairing/settings | Manual host/PIN entry over Tailscale route | measured | After the full test run relaunched the app with no saved pairing, CUA opened `Enter machine details`, typed `100.75.20.63`, first verified bad PIN `000000` shows `Incorrect PIN.`, then entered correct PIN `445600`, tapped `Connect`, and Settings returned to `Connected` / `Mac.lan`. Host `sync status --json` showed one authenticated iPhone peer at `remoteAddress: "100.75.20.63"`. | +| Pairing/settings | Disconnect/reconnect failure variants | measured/deferred | CUA tapped `Disconnect`, verified Settings returned to `Not connected` with `Saved machine · Tailscale route ready`, then tapped `Reconnect` and returned to `Connected`; runtime status moved from no peers back to one authenticated iPhone peer. Stale-saved-host and unreachable-saved-host variants are deferred because they require mutating the simulator's persisted host profile away from the active `Mac.lan` runtime; fixture needed: an isolated app-container profile seed with a stale `hostIdentity` and a non-routable saved address. | +| Pairing/settings | Discovery sheet duplicate and count stability | measured | CUA opened `Discover on network` while disconnected. Before the fix, the sheet showed duplicate `MacBook-Pro-567.local` rows and later `lappy` + `MacBook-Pro-567.local` for the same `192.168.1.249` route; after the fix, the Settings row says `2 nearby machines found` and the sheet has two unique LAN machines. | +| Settings | Notifications center | measured | CUA opened Notifications, toggled category rows, enabled/edited/cleared quiet hours, drove per-session mute/awaiting-only overrides, reconnected the phone, verified host/local preference cleanup, and tapped `Send test push`; the Simulator displayed an ADE notification banner and the screen showed the returned success message. | +| Settings | Appearance, diagnostics/about | measured | CUA cycled Light, Dark, and System on the live Settings sheet. The modal now follows the selected color scheme, About shows ADE version, paired machine, stable `Last sync: just now`, and device id, and two post-fix captures of the same Settings surface were pixel-identical. | +| Project picker | Select fixture project and idle stability | measured | `perf pass` selected and visible with fixture lanes. After the flicker fix, Codex Computer Use shows one active 4-lane `perf pass` row on the project picker instead of duplicate stale cached rows; recent simulator logs show no repeated `.local` hostname check spam while idle. | +| Project picker | Empty/error project states | deferred | Deferred to a clean-container fixture. The active simulator intentionally has the throwaway `/Users/admin/Projects/perf pass` project selected and paired; exercising the empty-recents and unreachable-root states would require erasing or reseeding the app database/profile store and would invalidate the live direct-runtime audit lane. Fixture needed: a separate simulator/container seed with no `projects` rows, plus a saved project row pointing at a deleted path. | +| Attention drawer | Open/close empty drawer | measured | Drawer opened with 0 items. | +| Attention drawer | PR attention items and action buttons | measured | After fixture hydration, toolbar showed `Attention items: 3`; drawer rendered `CI failing`, `Review requested`, and `Merge ready` sections with `Open #9101`, `Review #9102`, `Merge #9103`, and `View`. Opening the drawer cleared unread count to 0. | +| Attention drawer | Clear-all and non-PR deep-link routing | measured/deferred | Clear-all is measured in CUA for PR and failed-agent attention: tapping it clears visible cards, shows the empty state, and leaves the root bell at 0. Failed-chat routing is measured with `MOBILE_FAILED_ATTENTION_FIXTURE`: `Open agent` lands on the Work chat detail. Awaiting-input routing is measured again on 2026-05-19 with `MOBILE_AWAITING_ATTENTION_FIXTURE`: Work root reports `24 Work sessions live, 1 waiting for input`, the row is `NEEDS INPUT`, and the detail opens to `Prompt details syncing`. Drawer-level Approve/Deny/Reply side effects are deferred because this drawer fixture has no synced `pendingInputItemId`; fixture needed: an unresolved drawer card with a real pending approval/question item id. | +| Work root | Session list, groups, collapse/expand, filter panel, search | measured | Root shows live/ended sessions; search found chat and terminal output. | +| Work root | Row secondary actions: rename, pin, archive, stop, delete, copy, go-to-lane | measured | Rename, archive/restore, pin/unpin, copy session ID, stop runtime, go-to-lane, and delete chat were executed through Codex Computer Use on the simulator. Connected pin/unpin updated both simulator DB and host DB. Copy session ID put `024ee654-eba9-44c2-a9dc-5cdcf284589a` on the simulator pasteboard. Stop runtime moved fresh Codex session `dcab108b-189e-4130-adb0-005457ce9534` from `running` to `disposed`. Disposable chat `f2a54bab-1eb1-4350-b14f-66574691d5dd` (`MOBILE_DELETE_ME`) was deleted from both host and simulator DBs. | +| Work root | Bulk archive/restore/delete/export | measured | Disposable chats `MOBILE_BULK_ONE` (`ff00d707-08e8-45c9-8c53-f76e8c7ff5aa`) and `MOBILE_BULK_TWO` (`76084d91-ac0c-4ae4-bae6-cda58cbaaf42`) were selected in the phone UI. `Archive 2` wrote `archived_at` on host/simulator DBs, `Restore 2` cleared it on both, `Export` opened a share sheet for `ade-sessions-2026-05-18...` text document, and confirmed `Delete 2` removed both rows from host/simulator DBs. | +| Work root | Bulk stop runtime | measured | Computer Use selected shell sessions `MOBILE_STOP_ONE` (`594235b6-b422-4253-82d9-c64924c248a0`) and `MOBILE_STOP_TWO` (`6ab33104-7e73-4a4a-bc1c-2151270d54af`) together, then tapped `Stop 2`. Host DB shows both `disposed` with `pty_id` cleared; simulator DB shows both `disposed/killed`; original `/bin/zsh` PIDs `90103` and `90150` no longer exist. | +| Work chat | New chat, provider tabs, model/reasoning selection, access mode, send/follow-up | measured | Computer Use selected Codex `GPT-5.5` with `xhigh` reasoning in the Simulator; `CHAT_FINAL_OK` returned in UI and host chat history recorded provider `codex`, model `gpt-5.5`, reasoning `xhigh`. The latest direct-runtime pass also verified the model sheet opens immediately with CLAUDE/CODEX/CURSOR/DROID/OPEN tabs, active-session access/reasoning controls are real individual buttons, and a fresh mobile message `MOBILE_CHAT_DIRECT_RUNTIME_SMOKE_0203 reply ACK only` received `ACK`. Earlier `CHAT_OK`/`CHAT_FOLLOWUP_OK` follow-up paths also returned. | +| Work chat | Approval/question/permission prompts, queued steer edit/send/cancel, rich-card actions, long responses | measured/deferred | CUA drove a live structured question prompt end to end: selected `Question flow`, typed `CUA note OK`, sent answers, and the host returned `outcome: "answered"`. CUA then drove a second prompt through `Decline`, and the host returned `outcome: "declined"`. The resolved timeline now shows readable `Question asked` cards and `Input resolved · Accepted/Declined` with no raw JSON. CUA also opened `MOBILE_TOOL_PERMISSION_REAL`, expanded/copy-tested the completed command output (`MOBILE_PERMISSION_OK` on the simulator pasteboard), opened `MOBILE_APPROVAL_FIXTURE` to catch and fix the composer/proof overlap, verified command-card expand/collapse plus plan transcript layout, and stress-tested `MOBILE_STAGE_BUTTON_REAL` with a 5,000-line response. Focused tests cover queued steer edit/cancel merging (`testDerivePendingWorkSteersTracksQueuedEditsAndCancellations`, `testMergeWorkChatTranscriptsReplacesQueuedSteerEditInPlace`). Fresh unresolved permission prompts and the remaining rich-card side effects are deferred pending a live unresolved permission fixture that exposes current `itemId` values without starting a real long-running agent turn. | +| Work CLI | New CLI session, provider/access picker, model handoff, terminal input, Return submit, stop runtime, interrupt | measured | Computer Use launched Codex CLI from the phone with `GPT-5.5`; the TUI rendered `OpenAI Codex (v0.130.0)`, `gpt-5.5 xhigh fast`, and `~/Projects/perf pass`. `/status` typed through the phone terminal composer reached the live Codex TUI and host scrollback. The latest direct-runtime pass launched Shell from New Chat, typed `printf 'MOBILE_CLI_DIRECT_RUNTIME_OK_0202\n'`, tapped `Send Return`, and verified the output in the mobile terminal. The same fresh session was stopped through the Work row context menu and host DB ended it. Earlier `CLEAN_CLI_OK`, `MOBILE_OK`/`FINAL_PHONE_OK`, and `^C` paths also worked. | +| Work proof/artifacts | Empty proof drawer | measured | Empty proof state visible. | +| Work proof/artifacts | Artifact open/share/refresh with real screenshot/video | measured | Seeded screenshot/video proof artifacts through the throwaway runtime. CUA opened the chat image proof fullscreen, opened the Work proof drawer with 2 artifacts, and opened the iOS share sheet for the video artifact (`ade-work-artifact-748f5b03-...`, `Video · 31 KB`). | +| Lanes root | List, add-lane sheet, all six add modes visible | measured | New lane and mode sheet driven; `Mobile audit throwaway` created. | +| Lanes create/manage | New lane form, color save, branch picker filter | measured | Text entry and Emerald appearance save worked; branch filter narrowed to `perf/normal-1`. | +| Lanes create/manage | From branch, child lane, rescue, attach worktree, attach multiple forms | measured | CUA drove every add-mode form against `/Users/admin/Projects/perf pass`: imported `ade/mobile-ui-20260518151543-import`; created `Mobile stacked 151543` with parent `Mobile hi new 151543` and runtime `parentLaneId=70f7a616-b3f9-43ee-ac8b-7c40c3682f4a`, `stackDepth=1`; rescued Primary dirty work into `Mobile rescue 151543` and verified Primary clean; attached single fixture worktree `mobile-ui-20260518151543-attach-one`; found and fixed missing mobile remote command `lanes.listUnregisteredWorktrees`; then CUA verified bulk discovery of exactly two remaining unregistered worktrees, Select all, progress, dismissal, simulator rows, and runtime `attached` lanes for `attach-multi-a` and `attach-multi-b`. | +| Lanes detail | Stashes, recent commits, commit files, advanced, manage, switch branch | measured | Commit file preview and switch-branch filter opened. | +| Lanes git/file actions | Revert, push, fetch/pull prompt, stash empty state | measured/prompt-only | Revert and push executed on throwaway lane; destructive flows otherwise opened to prompt/safe state. | +| Lanes git/file actions | Stage/unstage/discard/restore, stash create/apply/pop/drop/clear, conflict continue/abort | measured | CUA staged and unstaged `mobile-audit-discard.txt`, then confirmed Discard and Discard staged from the patched sheet; host `git status --short` went clean. Stash fixtures were applied, popped, deleted, cleared, created from dirty files, and deleted again. Two real rebase conflicts exercised Continue after manual resolution and Abort while unresolved. | +| Lanes git/file actions | Amend, suggest commit message, commit | measured/prompt-only | CUA opened the commit sheet for a staged file, tapped `Suggest commit message`, and verified the host-disabled setup notice. CUA typed `Mobile final amend commit`, toggled `Amend last commit`, tapped `Amend commit`, and host git history shows the amended commit `f052c4c Mobile final amend commit` with a clean worktree. | +| PRs root | GitHub list, refresh, filters entry point, search, Workflows tab | measured | 18 PRs loaded; root search/filter/workflows surfaces visible. | +| PRs create | Single/Queue/Integration wizard modes | measured/prompt-only | CUA opened the rebuilt Create PR wizard from the PRs root. Single mode shows the eligible lanes, a collapsed `Not eligible (9)` section with `Show 6 more`, and the title editor above the bottom edge. CUA expanded/collapsed the blocked list, switched to Queue, selected one lane and saw the final Queue action become available, switched to Integration, selected two source lanes, and scrolled to the Review card without bottom chrome overlap. | +| PRs create | Actually opening PRs, queue creation, integration creation | prompt-only/deferred | CUA reopened the rebuilt wizard on 2026-05-19: Single shows eligible lanes plus collapsed blocked lanes; Queue selecting `vm audit retry mac` enables the final `Queue` button and Review shows target `main`; Integration selecting two source lanes shows source count `2`, integration branch `integration/1779168839`, and target `main`. Submission is deferred at the GitHub/external mutation boundary. Code fix: runtime now registers `prs.createQueue`, `prs.simulateIntegration`, and `prs.commitIntegration`, and the wizard passes the visible default target branch instead of omitting it. | +| PRs rows/detail | PR detail, link-to-lane sheet, row Link action | measured | CUA opened PR #18 details, tapped `Link to lane`, verified the queued lane-link sheet appears after the detail sheet dismisses, opened the row-level `Link` action, selected lanes in the picker, and refreshed the list until #18 showed `ADE` against the imported matching `perf/rebase-4` lane (`d7a01e6b-3fdb-4a32-9735-65ef25d16cb8`). | +| PRs rows/detail | Review, copy URL, close/reopen, Open in GitHub, activity replies, checks rerun, AI resolver | measured/prompt-only/deferred | `Open on GitHub` was executed from the mobile PR sheet and launched Safari to `github.com`. In the full PR detail screen, CUA opened the action menu, tapped `Copy URL`, saw `PR action complete`, and simulator pasteboard contained `https://github.com/arul28/perf-pass/pull/18`. CUA then tapped `Close PR`, verified the card moved to `CLOSED` with merge blocked, tapped `Reopen PR`, and verified it returned to `OPEN`. The 2026-05-19 CUA pass reopened PR #18 Activity and verified an already-posted review timeline item (`Mobile audit review comment OK`), the visible `Comment on PR…` composer, and the AI Resolver `Launch` sheet. Review/activity reply/check rerun final submission and AI resolver launch are deferred at the external/worker boundary; code fix routes mobile AI resolver launch through `prs.pathToMerge.start` and stop through `prs.pathToMerge.stop`. | +| PRs rows/detail | Edit title/body/labels prompts | prompt-only/deferred | Edit title was opened and canceled earlier. CUA opened Edit description and canceled it without saving; then opened Set labels, typed `mobile-audit`, and canceled without saving or mutating GitHub metadata. Save execution is deferred at the external GitHub metadata mutation boundary; fixture needed: disposable throwaway PR metadata seeded specifically for title/body/label mutation and restoration. | +| Files root | Workspace card, browser, refresh, quick open, text search, proof empty | measured | Browser/README/detail/quick-open/text-search/proof empty states driven. | +| Files root | Hidden-files toggle | measured | `Show hidden files` toggled to `Hide hidden files`; `.ade` appeared in the browser. | +| Files root | Workspace switching | measured | Workspace picker opened; switched from `Primary` to `Mobile audit throwaway`, updating the workspace path to the lane worktree. | +| Files root | Directory/file row actions and copy path | measured | Computer Use verified stable Files rows after removing the hot `localStateRevision` reload trigger. `Copy Relative Path` on `README.md` set simulator pasteboard to `README.md`; `Copy Path` set it to `/Users/admin/Projects/perf pass/README.md`. Rows also expose visible action buttons, swipe actions, and accessibility actions. | +| Files detail | File preview, diff empty state, metadata/history sheet | measured | `README.md` opened from lane workspace; preview, diff `No working tree changes`, details metadata, and history section rendered. | +| Files detail | Proof open/copy/refresh with real screenshot/video | measured | Files proof section rendered the seeded lane screenshot/video. CUA opened `Mobile lane screenshot proof`, copied its reference path, verified simulator pasteboard `.ade/artifacts/mobile-proof-fixtures/mobile-proof-screenshot.jpg`, and tapped refresh with artifacts still present. | +| Files detail | History, binary/image/error fallbacks, preview limits | measured | Seeded `mobile-file-fixtures` in `/Users/admin/Projects/perf pass`: binary file preview shows `Binary file`, binary Diff shows `Binary diff`, PNG renders inline, large text preview shows `Preview paused`, dirty large Diff now shows `Diff preview paused`, and the details sheet for `mobile-history.txt` shows metadata plus the two fixture commits. The 2026-05-19 forced-read fixture `mobile-read-error-denied.txt` was created with mode `000`; CUA opened it from Files and the detail rendered `EACCES: permission denied, open '/Users/admin/Projects/perf pass/mobile-read-error-denied.txt'` with a visible `Retry` action. | +| CTO Team | Team, hire worker sheet, CTO chat send | measured | CTO chat returned `CTO_OK`; hire sheet opened. | +| CTO Team | Worker detail/core memory/dismiss | measured | Fixture worker detail opened, core memory rendered through the remote route, and dismiss completed through the new `cto.removeAgent` command with host row soft-deleted. | +| CTO Team | Worker edit/wake/rollback/context actions | measured | CUA drove the `Mobile Fixture Worker` row on 2026-05-19. `Wake` returned visible feedback (`Woke Mobile Fixture Worker.`) and the detail activity recorded a failed wake row: `Headless ADE mode does not support worker-backed Linear targets yet.` The worker edit sheet saved status `Paused`, the detail showed `Status: paused` / `Resume`, then `Rollback to v2` restored the worker to `Status: idle` and added a new current revision. Core memory/context chips remained visible in the detail (`Drive real Simulator UI`, `Use throwaway perf pass repo`, `CTO worker detail`, `Mobile actions coverage`). | +| CTO Workflows | Workflow definitions and disconnected Linear state | measured | Workflows render with `Linear not connected`. | +| CTO Workflows | Linear sync now, recent events, partial failures, workflow authoring actions | deferred | CUA and XcodeBuildMCP both reached the Workflows tab on 2026-05-19; the live screen is blocked at `Linear not connected` / `Connect from the ADE machine CTO Workflows tab.` Linear token status in Settings is `off`. Final Linear sync, recent-event, partial-failure, and authoring actions are deferred because this throwaway headless runtime has no safe Linear sandbox/stub configured. Fixture needed: a fake Linear credential/service stub or sandbox workspace explicitly marked safe for mobile mutation. | +| CTO Settings | Identity editor, core memory, budget/integrations/advanced render | measured | Settings reopened after crash fix. | +| CTO Settings | Identity save/cancel, brief editor save/cancel, Linear sync from settings | measured/deferred | CUA opened `Edit identity` and tapped `Save edit identity`; XcodeBuildMCP opened `Project summary, captured` and tapped `Save edit brief`, returning to Settings both times. Linear sync from Settings is deferred because the live Settings surface shows `Linear token not configured` / `Status: off` and exposes no safe sync control in this headless runtime. | + +## Current findings + +1. The mobile app can connect to the headless runtime without desktop Electron. +2. The app still has a measurable main-thread hang during cross-tab sweeps; the accepted PR date cache improved it but did not eliminate it. +3. PR create-mode coverage is now stronger: Single, Queue, and Integration modes all render; queue/integration correctly show disabled final actions until enough lanes are selected. +4. Files coverage is stronger: hidden-file toggle, workspace switching, file preview, diff empty state, and details/history sheet all have simulator evidence. +5. The high-risk phone CLI path is now stronger: blank Codex sessions launch without ADE injecting a positional prompt, managed Codex sessions no longer show stale local MCP startup failures, and live phone typing/Return works against the real TUI. +6. The latest CLI continuation verified Codex model selection and terminal typing with Computer Use: `GPT-5.5` was selected on-device, Codex launched inside `/Users/admin/Projects/perf pass`, and `/status` submitted from the phone terminal field produced the live Codex status panel. +7. Connected Work row metadata now has fresh simulator evidence: pin/unpin changed both local and host DBs, copy session ID updated the simulator pasteboard, and Stop runtime disposed the newly launched Codex terminal session. +8. Work destructive flows now have simulator evidence on disposable rows: single `Delete chat` removed `MOBILE_DELETE_ME`, and bulk archive/restore/export/delete worked on `MOBILE_BULK_ONE`/`MOBILE_BULK_TWO`. +9. Bulk Work stop is now measured on real non-chat shell sessions. Before the fix, live shell rows did not expose a reliable `Stop runtime` action through the accessible row action surface. The current build exposes it and bulk stop correctly disposes both host ptys. +10. PR attention is no longer blocked by host/mobile project-id mismatch: host PR fixtures hydrate under the active phone project and feed the shared workspace snapshot observed by the drawer. +11. Work Go-to-lane had two separate issues found by simulator driving: the cross-tab action could stop at Lanes root, and `CHAT_FINAL_OK_X` pointed at an archived stale Primary lane id. The current build resolves that stale id to the active Primary lane and presents Primary detail from the context menu. +12. Files browser flicker was real: the directory view was reloading on every global sync/database revision and replacing rows with skeletons. The current build scopes directory reloads to workspace/path/hidden/live/manual-refresh changes and keeps loaded rows visible during refresh. +13. CTO had a related live-reload churn path: the root shell and Team tab reacted to every global sync revision. The current build throttles those live reloads, and the simulator kept the worker row visible across a forced host revision. +14. PR detail had another sync-tick storm: local database revisions could re-run several live PR sidecar commands. The current code keeps local revision reloads local after the first sidecar load; explicit refresh/actions still fetch live PR sidecars. +15. The project picker flicker report was real enough to find idle home-screen churn: duplicate cached project rows plus timestamp-only discovery publications and `.local` fallback port scans could re-render the landing screen while disconnected. The current build deduplicates the row, suppresses timestamp-only discovery publishes, and only tries the advertised Bonjour port for `.local` routes. +16. Notifications had two real bugs found through Simulator driving: stale inactive per-session override rows persisted across reconnects, and `Send test push` silently discarded the host result. The current build prunes inactive overrides, uploads saved prefs on reconnect, awaits the test-push result, and visibly reports success/failure. +17. Settings sheet flicker was real on the live paired screen: broad `SyncService` observation plus frequent sync publishes caused glass-heavy Settings surfaces to repaint. The current build narrows Settings to a throttled presentation snapshot, stabilizes fresh last-sync copy, and applies appearance selection to the modal itself. +18. Settings discovery churn was also real while disconnected: raw Bonjour service rows could duplicate the same LAN machine and the parent Settings row counted raw services instead of display rows. The current build coalesces duplicate live services by primary route, keeps distinct LAN machines separate, and uses the coalesced count in both places. +19. Work prompt history had a real post-resolution rendering bug: structured question approvals looked correct while pending, but after answer/decline the generic approval timeline card dumped raw JSON. The current build maps resolved structured-question, plan-approval, and permission approval events into readable event cards. +20. The terminal flicker report was real: duplicate transcript snapshots and viewport-only revisions were still causing the terminal text view to be reassigned. The current build skips duplicate transcript revisions and skips terminal text re-rendering when the raw text has not changed. +21. Lane destructive row actions had a real half-sheet bug: the confirmation alert was attached to the parent detail while the sheet was presented, and the row action strip could clip the destructive button on phone width. The current build owns destructive alerts inside the sheet and wraps row actions. +22. Stash and conflict controls now have destructive throwaway evidence: CUA drove apply, pop, hold-delete, hold-clear, create stash, conflict Continue, and conflict Abort against real git state in `/Users/admin/Projects/perf pass`. +23. The PR create sheet geometry issue is fixed in the current build: the wizard uses a large scroll-first sheet, collapses blocked lanes, and CUA verified Single/Queue/Integration flows plus a `0` pixel-delta idle capture. +24. Direct mobile-to-runtime operation is now verified again without launching ADE desktop: the simulator showed `Connected to Mac.lan`, the New Chat model picker opened from fallback catalog immediately, Shell CLI input returned `MOBILE_CLI_DIRECT_RUNTIME_OK_0202`, and a live chat send returned `ACK`. +25. After merging `origin/main`, the Simulator AX smoke still shows direct runtime connection, immediate model-picker tabs, Codex GPT reasoning buttons, Shell CLI launch, terminal toolbar/input, and runtime output `MOBILE_POST_MERGE_DIRECT_RUNTIME_OK_0303`. Codex Computer Use was attempted three times after the merge but the Computer Use MCP timed out on both `get_app_state` and `list_apps`; no Ghost fallback was used. +26. The source-derived gap report has no remaining `open`/`partial` rows after the 2026-05-19 continuation. Remaining non-executed actions are explicitly `deferred` or `prompt-only/deferred` with a fixture or external-boundary reason, so future passes should start from those deferred fixture requirements instead of treating the whole surface as untriaged. +27. The 2026-05-19 continuation found one additional sync contract bug: mobile PR Queue/Integration and queue automation actions were present in iOS but missing from the headless remote-command registry/shared action union. The runtime registration/tests now cover `prs.createQueue`, `prs.simulateIntegration`, `prs.commitIntegration`, and `prs.startQueueAutomation`; mobile AI resolver launch/stop now uses Path-to-Merge commands. +28. CUA was available again after the earlier post-merge timeout. It verified direct runtime connection after the rebuild, PR create Queue/Integration prompt boundaries, PR Activity review/AI-resolver/composer surfaces, Files forced read failure, CTO worker wake/edit/rollback, and final `Machine connection · Connected to Mac.lan`. XcodeBuildMCP fallback was used only when CUA temporarily timed out or when the iOS test runner had shut down the Simulator. + +## Validation commands and artifacts + +- 2026-05-19 continuation direct-runtime setup: started headless `ade serve` for `/Users/admin/Projects/perf pass` on the audit socket `/tmp/ade-mobile-audit/ade.sock` and LAN sync port `8787`; no ADE desktop/Electron launch was used. CUA opened Settings, paired to `Mac.lan` at `192.168.1.240 · :8787`, and later final CUA state showed `Machine connection · Connected to Mac.lan` on Work root with `24 Work sessions live, 1 waiting for input`. +- 2026-05-19 runtime status proof: `ADE_RPC_SOCKET_PATH=/tmp/ade-mobile-audit/ade.sock ADE_PROJECT_ROOT='/Users/admin/Projects/perf pass' npm --prefix apps/ade-cli run dev -- --socket sync status --json` showed one authenticated iPhone peer (`deviceName: "iPhone 17"`, `remoteAddress: "100.75.20.63"`, `latencyMs: 1`) connected to brain `Mac.lan` on port `8787`. +- 2026-05-19 CUA proof for PR create/actions: CUA reopened PRs -> Create PR after rebuild. Queue mode with `vm audit retry mac` selected enabled `Queue` and showed target `main`; Integration with two source lanes showed `Source lanes 2`, `integration/1779168839`, and target `main`. CUA opened PR #18 Activity and verified the timeline review item `Mobile audit review comment OK`, the `Comment on PR…` composer, the AI Resolver card, and the `Launch` preflight sheet. Final GitHub-mutating submissions were deferred at the external-boundary. +- 2026-05-19 CUA proof for Files forced read failure: created `mobile-read-error-denied.txt` in `/Users/admin/Projects/perf pass` with mode `000`; CUA opened the row from Files and the detail showed `EACCES: permission denied, open '/Users/admin/Projects/perf pass/mobile-read-error-denied.txt'` plus `Retry`. +- 2026-05-19 CUA/Xcode proof for CTO: CUA tapped `Wake Mobile Fixture Worker` and saw `Woke Mobile Fixture Worker.`; worker detail showed the failed wake activity row for headless ADE worker-backed Linear targets. CUA saved worker status through the Edit sheet and verified `Status: paused`, then tapped rollback and verified the worker returned to `Status: idle` with a new current revision. CUA opened `Edit identity` and saved; XcodeBuildMCP opened `Project summary, captured`, tapped `Save edit brief`, and returned to Settings. CTO Workflows and Settings Linear actions remain deferred because the live runtime shows `Linear not connected` / `Linear token not configured`. +- 2026-05-19 CUA timeout/fallback note: after successful CUA passes, CUA briefly returned `timeoutReached` / `noWindowsAvailable` while the Simulator was shut down by the iOS test runner or stuck on the macOS Window menu. XcodeBuildMCP simulator AX was used as fallback for CTO Settings/brief checks, then CUA was retried and recovered; final CUA state again showed `Connected to Mac.lan`. +- 2026-05-19 PR sync contract focused test: `npm --prefix apps/desktop exec -- vitest run src/main/services/sync/syncRemoteCommandService.test.ts` — `164 passed`, covering `prs.createQueue`, `prs.simulateIntegration`, `prs.commitIntegration`, and `prs.startQueueAutomation` routing/parsing. +- 2026-05-19 desktop validation: `npm --prefix apps/desktop run typecheck` — passed; `npm --prefix apps/desktop run build` — passed with existing Vite large-chunk warnings. +- 2026-05-19 ADE CLI focused validation: `npm --prefix apps/ade-cli exec -- vitest run src/services/sync/syncHostService.test.ts src/adeRpcServer.test.ts src/cli.test.ts` — `306 passed`; `npm --prefix apps/ade-cli run typecheck` — passed. +- 2026-05-19 ADE CLI build: `npm --prefix apps/ade-cli run build` — passed. +- 2026-05-19 iOS rebuild/run after PR mobile patches: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; latest runtime log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/com.ade.ios_2026-05-19T05-42-45-778Z_helperpid30049_ownerpid3375_d72fb260.log`. +- 2026-05-19 iOS focused tests: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testDerivePendingWorkSteersTracksQueuedEditsAndCancellations", "-only-testing:ADETests/ADETests/testMergeWorkChatTranscriptsReplacesQueuedSteerEditInPlace", "-only-testing:ADETests/ADETests/testFilesHistoryFallbackPrefersEntriesAndExplicitErrors", "-only-testing:ADETests/ADETests/testPrCreateCapabilitiesFilterEligibleLanesAndKeepBlockedVisible", "-only-testing:ADETests/ADETests/testCtoLiveReloadThrottleSkipsBurstySyncRevisions", "-only-testing:ADETests/AttentionDrawerModelTests/testMixedSnapshotBuildsAwaitingFailedCiAndMergeItems"] })` — `6 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-19T05-41-06-192Z_pid3375_905dd034.log`. +- 2026-05-19 diff hygiene: `git diff --check` — clean. +- 2026-05-19 cleanup: restored the pre-audit `~/.ade/secrets/sync-pin.json` from `/tmp/ade-mobile-audit/sync-pin.before.json` (`cmp` matched) and stopped only the headless audit runtime on `18787`/LAN `8787`. A later port check showed the long-running pre-existing `bun` listener on `127.0.0.1:8787` plus an unrelated `/Users/admin/Projects/ADE/.ade/worktrees/automations-fixes-619117dc` `ade-cli serve` process on `*:8787`; both were outside this audit lane and were left untouched. +- `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` +- Latest direct-runtime mobile parity build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; runtime log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/com.ade.ios_2026-05-19T04-10-42-282Z_helperpid41530_ownerpid61834_1b3185f6.log` +- Latest direct-runtime mobile focused tests: `mcp__xcodebuildmcp__.test_sim({ extraArgs: ["-quiet", "-only-testing:ADETests/AttentionDrawerModelTests/testMixedSnapshotBuildsAwaitingFailedCiAndMergeItems", "-only-testing:ADETests/ADETests/testAgentChatSessionSummaryDecodesCursorAndControlFields", "-only-testing:ADETests/ADETests/testDerivePendingWorkInputsReturnsApprovalsAndQuestionsInRequestOrder", "-only-testing:ADETests/ADETests/testDerivePendingWorkInputsRemovesResolvedItems", "-only-testing:ADETests/ADETests/testDerivePendingWorkInputsParsesStructuredQuestionApprovalDetail", "-only-testing:ADETests/ADETests/testAssistantMessagePreviewBoundsHugeResponses", "-only-testing:ADETests/ADETests/testMobileRuntimeModeOptionsMirrorDesktopAndTuiProviders"] })` — `7 passed`, `0 failed`. +- Latest direct-runtime CUA verification: Simulator connected to `Mac.lan`; model picker opened immediately with CLAUDE/CODEX/CURSOR/DROID/OPEN tabs; active Work chat exposed individual access/reasoning buttons; mobile chat send returned `ACK`; New Chat Shell session returned `MOBILE_CLI_DIRECT_RUNTIME_OK_0202`; Files workspace, Work filters, PR pipeline settings, and CTO worker status all showed direct option controls. +- Latest desktop focused validation: `npm --prefix apps/desktop run test -- syncRemoteCommandService.test.ts agentChatService.test.ts ptyService.test.ts cliLaunch.test.ts` — `616 passed`; `npm --prefix apps/desktop run typecheck` — passed; `npm --prefix apps/desktop run build` — passed with existing Vite chunk-size warnings; `npm --prefix apps/desktop run lint` — `0 errors`, existing warnings only. +- Latest ADE CLI focused validation: `npm --prefix apps/ade-cli run test -- adeRpcServer.test.ts cli.test.ts` — `295 passed`; `npm --prefix apps/ade-cli run typecheck` — passed; `npm --prefix apps/ade-cli run build` — passed. +- Post-`origin/main` merge iOS build: `mcp__xcodebuildmcp__.build_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_sim_2026-05-19T04-19-56-150Z_pid61834_2a6fca30.log` +- Post-`origin/main` merge iOS focused tests: `mcp__xcodebuildmcp__.test_sim({ extraArgs: ["-quiet", "-only-testing:ADETests/AttentionDrawerModelTests/testMixedSnapshotBuildsAwaitingFailedCiAndMergeItems", "-only-testing:ADETests/ADETests/testAgentChatSessionSummaryDecodesCursorAndControlFields", "-only-testing:ADETests/ADETests/testDerivePendingWorkInputsReturnsApprovalsAndQuestionsInRequestOrder", "-only-testing:ADETests/ADETests/testDerivePendingWorkInputsRemovesResolvedItems", "-only-testing:ADETests/ADETests/testDerivePendingWorkInputsParsesStructuredQuestionApprovalDetail", "-only-testing:ADETests/ADETests/testAssistantMessagePreviewBoundsHugeResponses", "-only-testing:ADETests/ADETests/testMobileRuntimeModeOptionsMirrorDesktopAndTuiProviders"] })` — `7 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-19T04-20-32-951Z_pid61834_aaf9c753.log` +- Post-`origin/main` merge relaunch: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; runtime log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/com.ade.ios_2026-05-19T04-21-37-456Z_helperpid50797_ownerpid61834_07dacbf8.log` +- Post-`origin/main` merge simulator UI smoke: `mcp__xcodebuildmcp__.snapshot_ui` verified `Machine connection · Connected to Mac.lan`; New Chat model picker showed `Claude`, `Codex`, `Cursor`, `Droid`, `OpenCode`, `GPT-5.5`, and Low/Medium/High/XHigh reasoning buttons; Shell CLI launched from New Chat, typed through `Terminal input`, tapped `Send Return`, and displayed `MOBILE_POST_MERGE_DIRECT_RUNTIME_OK_0303`. +- Post-`origin/main` merge desktop/CLI validation: desktop focused tests `626 passed`, desktop typecheck passed, desktop build passed with existing Vite chunk-size warnings, desktop lint exited `0 errors` with `262 warnings`; ADE CLI focused tests `296 passed`, typecheck passed, and build passed. +- Post-`origin/main` merge Computer Use note: `mcp__computer_use__.get_app_state({"app":"Simulator"})` timed out twice and `mcp__computer_use__.list_apps()` timed out once, so the post-merge UI smoke used XcodeBuildMCP simulator AX instead. Earlier CUA-driven mobile evidence remains above. +- Latest Files action/flicker build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T15-04-09-421Z_pid61834_b8d3d221.log` +- Latest Work bulk-stop build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T15-20-14-554Z_pid61834_be81edee.log` +- Latest Work bulk-stop focused test: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testStoppableRuntimeSessionIncludesLiveAndIdleTerminalRows"] })` — `1 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T15-19-08-225Z_pid61834_0858e77a.log` +- Latest CTO throttling + Work stop focused test: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testCtoLiveReloadThrottleSkipsBurstySyncRevisions", "-only-testing:ADETests/ADETests/testStoppableRuntimeSessionIncludesLiveAndIdleTerminalRows"] })` — `2 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T15-23-59-914Z_pid61834_9ed407bf.log` +- Latest CTO throttling build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T15-24-49-280Z_pid61834_8ef79b07.log` +- Latest rebuild after simulator-device recovery: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T15-32-17-373Z_pid61834_256ba6ab.log` +- Latest full iOS pass after Work stop + CTO throttle changes: `mcp__xcodebuildmcp__.test_sim({ progress: true, extraArgs: ["-quiet"] })` — `274 passed`, `0 failed`, `0 skipped`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T15-32-48-036Z_pid61834_83a2130d.log` +- Latest PR detail sidecar focused test: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testPrDetailSidecarFetchPolicySkipsLocalRevisionAfterInitialLoad"] })` — `1 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T15-35-55-753Z_pid61834_34297af5.log` +- Latest build after PR detail sidecar fix: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T15-37-15-676Z_pid61834_b124539a.log` +- Latest project-picker flicker focused test: `mcp__xcodebuildmcp__.test_sim({ progress: true, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testSyncConnectPortCandidatesDoNotScanBonjourHostnameFallbackWindow", "-only-testing:ADETests/ADETests/testSyncDiscoveredHostIgnoresTimestampOnlyRefreshForPublishedList", "-only-testing:ADETests/ADETests/testSyncServiceProjectHomeDeduplicatesCachedRowsByRootAndKeepsActive"] })` — `3 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T15-42-49-375Z_pid61834_47582dba.log` +- Latest project-picker flicker build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T15-43-48-635Z_pid61834_51cd961a.log` +- Codex Computer Use verification after flicker fix: Simulator project picker shows one `perf pass` row (`4 lanes`) and the machine banner remains idle at `No machine attached`. +- Simulator log verification after flicker fix: `xcrun simctl spawn 2CD8BD1C-C5F5-4B9D-B446-803488E4F559 log show --last 2m --style compact --predicate 'process == "ADE" AND eventMessage CONTAINS "ne_tracker_check_is_hostname_blocked"'` — no matching ADE events. +- Latest full iOS pass after project-picker flicker fix: `mcp__xcodebuildmcp__.test_sim({ progress: true, extraArgs: ["-quiet"] })` — `278 passed`, `0 failed`, `0 skipped`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T15-44-50-318Z_pid61834_83a9527f.log` +- Latest Notifications reconnect/test-push build: `mcp__xcodebuildmcp__.build_run_sim({})` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T16-38-28-081Z_pid61834_55d1f492.log` +- Codex Computer Use verification after Notifications fixes: Simulator paired to the throwaway runtime, local app-group prefs showed `{ perSessionOverrides: {}, keys: [] }`, host device metadata had no `notificationPreferences.perSessionOverrides`, `Send test push` displayed an ADE notification banner, and the screen showed `Test notification delivered in app. APNs is not wired in this runtime.` +- Latest Notifications focused iOS tests: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testNotificationPreferencesSavePrunesInactivePerSessionOverrides", "-only-testing:ADETests/ADETests/testSyncNotificationPrefsPayloadOmitsInactivePerSessionOverrides", "-only-testing:ADETests/ADETests/testNotificationStaleOverrideIdsKeepSavedOverridesVisible", "-only-testing:ADETests/ADETests/testSyncConnectPortCandidatesDoNotScanBonjourHostnameFallbackWindow", "-only-testing:ADETests/ADETests/testSyncDiscoveredHostIgnoresTimestampOnlyRefreshForPublishedList", "-only-testing:ADETests/ADETests/testSyncServiceProjectHomeDeduplicatesCachedRowsByRootAndKeepsActive", "-only-testing:ADETests/ADETests/testSyncServiceAdoptsRemoteProjectIdWhenStaleCachedDuplicateStillExists"] })` — `7 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T16-42-14-625Z_pid61834_e76b5f10.log` +- Latest full iOS pass after Notifications fixes: `mcp__xcodebuildmcp__.test_sim({ progress: true, extraArgs: ["-quiet"] })` — `281 passed`, `0 failed`, `0 skipped`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T16-43-12-001Z_pid61834_3eb019da.log` +- Latest Settings flicker/appearance build: `mcp__xcodebuildmcp__.build_run_sim({})` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T16-56-34-652Z_pid61834_96e27adc.log` +- Codex Computer Use verification after Settings flicker/appearance fix: Simulator Settings showed `Connected`, `Mac.lan`, `Tailscale 100.75.20.63 · :8787`, `Last sync: just now`; CUA cycled Light, Dark, and System. Screenshots `/tmp/ade-settings-post-1.png` and `/tmp/ade-settings-post-2.png` compared with `magick compare -metric AE` returned `0`. +- Codex Computer Use verification for manual Tailscale pairing: With the Simulator relaunched into `Not connected`, CUA typed host `100.75.20.63`, PIN `388722`, tapped `Connect`, and Settings returned to `Connected`. Runtime status showed one authenticated iPhone peer with `remoteAddress: "100.75.20.63"`. +- Latest full iOS pass after Settings flicker/appearance fix: `mcp__xcodebuildmcp__.test_sim({ progress: true, extraArgs: ["-quiet"] })` — `281 passed`, `0 failed`, `0 skipped`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T16-57-31-130Z_pid61834_3d2c1c01.log` +- Latest final full iOS pass after Settings snapshot cleanup: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet"] })` — `281 passed`, `0 failed`, `0 skipped`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T17-02-38-910Z_pid61834_3ca2ac0a.log` +- Codex Computer Use verification for Settings bad PIN/disconnect/reconnect: CUA typed bad PIN `000000` and saw `Incorrect PIN.`, paired with PIN `445600`, tapped `Disconnect`, verified `Not connected`, then tapped `Reconnect` and returned to connected runtime status. +- Latest Settings discovery focused tests: `mcp__xcodebuildmcp__.test_sim({ progress: true, extraArgs: ["-only-testing:ADETests/ADETests/testDiscoveredHostsDisplayCoalescesDuplicateLiveRoutes", "-only-testing:ADETests/ADETests/testDiscoveredHostsDisplayCoalescesDuplicateLiveRoutesAcrossPorts", "-only-testing:ADETests/ADETests/testDiscoveredHostsDisplayKeepsDistinctLanHostsWithSharedTailnetMetadata", "-only-testing:ADETests/ADETests/testSyncDiscoveredHostsCoalescesDuplicateAnonymousLanRows"] })` — `4 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T17-27-40-677Z_pid61834_f00bacd8.log` +- Latest Settings discovery build: `mcp__xcodebuildmcp__.build_run_sim({})` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T17-31-04-690Z_pid61834_80cb65ee.log` +- Codex Computer Use verification for Settings discovery dedupe/count: CUA opened Settings and saw `Discover on network, 2 nearby machines found`; CUA opened the sheet and saw two unique rows (`Mac.lan` at `192.168.1.240`, `MacBook-Pro-567.local` at `192.168.1.249`) with no duplicate row for the `192.168.1.249` machine. +- Settings discovery flicker check: repeated simulator sheet captures below the status bar compared with `magick compare -metric AE` returned `0`. +- Latest full iOS pass after Settings discovery dedupe/count fix: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet"] })` — `285 passed`, `0 failed`, `0 skipped`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T17-33-11-445Z_pid61834_4fea39a5.log` +- Latest Work prompt history focused tests: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testBuildWorkTimelineKeepsResolvedStructuredQuestionReadable", "-only-testing:ADETests/ADETests/testBuildWorkTimelineKeepsResolvedPermissionReadable", "-only-testing:ADETests/ADETests/testBuildWorkTimelineEmitsInlinePendingQuestionAndSuppressesGenericApprovalCard", "-only-testing:ADETests/ADETests/testBuildWorkTimelineEmitsInlinePermissionAndSuppressesGenericEventCard"] })` — `4 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T17-47-49-223Z_pid61834_89c69c1d.log` +- Latest Work prompt history build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T17-48-39-961Z_pid61834_ab7e062b.log` +- Latest Work chat composer overlap build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T19-53-19-669Z_pid61834_83c7362f.log` +- Codex Computer Use verification after Work chat composer overlap fix: Simulator `MOBILE_APPROVAL_FIXTURE` no longer shows the large pending-input banner over the proof cards/composer. `MOBILE_COMMAND_APPROVAL_REAL` command-card expand/collapse worked, `MOBILE_PLAN_APPROVAL_REAL` transcript layout stayed readable, and the custom Work back button remained tappable after replacing the blocked toolbar-background attempt with a non-interactive navigation scrim. +- Latest Work chat long-response focused tests: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testAssistantMessagePreviewBoundsHugeResponses", "-only-testing:ADETests/ADETests/testWorkChatAccessibilityPreviewCapsHugeMessages"] })` — `2 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T22-55-16-950Z_pid61834_96aa1281.log` +- Latest Work chat long-response build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T22-53-03-533Z_pid61834_b234fec6.log` +- Codex Computer Use verification for Work chat long-response performance: CUA opened `MOBILE_STAGE_BUTTON_REAL` after the 5,000-line Claude response had previously destabilized Simulator. The screen rendered a single bounded preview with `96 of 5,000 lines`, exposed `Copy full` and `Show more` as real controls, expanded to `192 of 5,000 lines` without crashing or hiding controls, and `xcrun simctl pbpaste` confirmed the copied full response ended with `5000. Done`. +- Latest PR link-to-lane build before branch-filter tightening: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T19-57-59-359Z_pid61834_9485aff3.log` +- Codex Computer Use verification for PR link-to-lane: Simulator paired to `/Users/admin/Projects/perf pass`, opened PR #18 details, tapped `Link to lane`, and verified the lane-link sheet appears after the detail sheet dismisses. The row-level `Link` action opened the same sheet. Selecting a mismatched lane exposed the host branch-match policy, so the sheet now filters to PR-head matching lanes. After importing the matching `perf/rebase-4` lane, CUA refreshed PRs and #18 displayed `ADE`; host `prs list` shows lane `d7a01e6b-3fdb-4a32-9735-65ef25d16cb8`. +- Latest PR lane-link branch-filter focused test: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testPrLinkLanePreselectionRequiresExactBranchMatch"] })` — `1 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T20-12-25-750Z_pid61834_a0fc5603.log` +- Latest PR lane-link branch-filter build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T20-13-46-755Z_pid61834_36e53293.log` +- Codex Computer Use verification after PR branch-filter fix: Simulator opened PR #17 `Link to lane`; the sheet no longer listed unrelated lanes and showed `Create or import a lane on perf/rebase-3, then refresh PRs.` plus disabled `Link to lane`. +- Codex Computer Use verification for PR detail actions: Simulator opened linked PR #18, used the top-right action menu to `Copy URL` (`PR action complete`; simulator pasteboard `https://github.com/arul28/perf-pass/pull/18`), then executed `Close PR` and `Reopen PR` against the throwaway GitHub repo. The detail card visibly changed to `CLOSED` / merge blocked and then back to `OPEN` / ready to merge. +- Latest PR Activity composer layout build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T20-29-07-974Z_pid61834_49263e75.log` +- Codex Computer Use verification for PR Activity composer layout: after pairing the rebuilt Simulator back to `/Users/admin/Projects/perf pass`, CUA reopened PR #18 Activity, scrolled to the bottom, and verified the AI resolver card plus `Comment on PR…` composer sit above the tab bar with no overlapping green merge bar. +- Codex Computer Use verification for PR AI resolver and metadata prompts: CUA opened the Activity `Launch` AI Resolver sheet, switched reasoning to High, typed `gpt-5.5` in the optional model field, and closed without launching. CUA also opened Edit description and Set labels, typed `mobile-audit` into Set labels, and canceled without saving. +- Latest PR checks fallback focused tests: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testPrChecksSummaryFallsBackToOverallFailingStatus", "-only-testing:ADETests/ADETests/testPrChecksSummaryPrefersSyncedCheckRuns", "-only-testing:ADETests/ADETests/testPrMergeGateDoesNotShowGreenWhenStatusIsMissing", "-only-testing:ADETests/ADETests/testPrMergeGateUsesSummaryFailingStatusBeforeCheckRowsSync"] })` — `4 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T20-49-28-863Z_pid61834_12a9f308.log` +- Latest PR checks fallback build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T20-51-24-813Z_pid61834_80ed302e.log` +- Codex Computer Use verification for PR checks fallback: Simulator paired to `/Users/admin/Projects/perf pass`, opened attention drawer item `PR #9101 · Mobile attention CI failing`, and verified the detail shows `Merge blocked` / `checks failing`. CUA opened CI / Checks, saw Fail 1, Pending 0, Pass 0, Total 1, summary-only failing copy, the AI resolver row, and a visible enabled `Rerun failed checks` button. +- Codex Computer Use verification for Work structured-question prompts: CUA selected `Question flow`, typed `CUA note OK`, sent answers, and the host returned `outcome: "answered"` with both answers. CUA then tapped `Decline` on a second prompt and the host returned `outcome: "declined"`. +- Codex Computer Use verification after Work prompt history fix: Simulator `MOBILE_Q_PROMPT_FIXTURE` showed readable `Question asked` cards with `Input resolved · Accepted` and `Input resolved · Declined`; the CUA state tree contained no raw `approval_request` JSON in the visible cards. +- Work prompt history flicker check: repeated simulator captures of `MOBILE_Q_PROMPT_FIXTURE` compared with `magick compare -metric AE` returned `0`. +- Latest chat snapshot focused tests: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testChatSubscriptionStateSurvivesDisconnectAndReplaysPayloads", "-only-testing:ADETests/ADETests/testDuplicateChatSubscribeSnapshotDoesNotAdvanceRevision"] })` — `2 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T18-02-45-564Z_pid61834_7def8517.log` +- Latest terminal flicker focused tests: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testTerminalEmulatorSkipsDuplicateRevisionRenders", "-only-testing:ADETests/ADETests/testTerminalViewportRevisionOnlyUpdateDoesNotRerender"] })` — `2 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T18-19-08-613Z_pid61834_7c4678f8.log` +- Latest terminal flicker build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T18-20-08-445Z_pid61834_50b27858.log` +- Codex Computer Use verification for terminal typing/flicker: Simulator opened `MOBILE_INTERACTIVE_SHELL_REAL`, phone input sent `echo MOBILE_TERMINAL_INPUT_OK`, host terminal output returned `MOBILE_TERMINAL_INPUT_OK`, and repeated optimized simulator captures after the fix compared at `0` pixel delta. +- Latest lane destructive-action focused tests: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testLaneDiscardAllUsesExplicitDestructiveConfirmationCopy", "-only-testing:ADETests/ADETests/testLaneDiscardAllSingularizesMessageForOneFile", "-only-testing:ADETests/ADETests/testLaneFileConfirmationSingleFileCasesExposeCorrectCopyAndSource"] })` — `3 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T18-34-19-606Z_pid61834_53108ae5.log` +- Latest lane action layout focused test: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testLaneFileConfirmationSingleFileCasesExposeCorrectCopyAndSource"] })` — `1 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T18-39-52-952Z_pid61834_7ac7398a.log` +- Latest lane action build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T18-40-49-411Z_pid61834_cc2a133d.log` +- Codex Computer Use verification for Work/Files proof artifacts: CUA opened chat screenshot proof fullscreen, opened the Work proof drawer, opened the iOS share sheet for the video proof, opened Files proof detail, copied the screenshot reference, and verified simulator pasteboard `.ade/artifacts/mobile-proof-fixtures/mobile-proof-screenshot.jpg`. +- Latest Files large-diff focused tests: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testFilesDiffHasChangesDetectsTextAndExistenceEdits", "-only-testing:ADETests/ADETests/testFilesDiffTreatsTruncatedSidesAsUnsafeToMarkClean"] })` — `2 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T21-07-47-992Z_pid61834_45c2eb48.log` +- Latest Files large-diff build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T21-09-05-712Z_pid61834_a881fdce.log` +- Codex Computer Use verification for Files detail fallbacks: after creating `mobile-file-fixtures` in the throwaway repo, CUA opened the folder and verified binary preview fallback, binary diff fallback, inline PNG rendering, large text preview limit, large dirty diff limit, and the details/history sheet showing commits `69e78f6` and `7d62674`. +- Latest root-tab / Files-detail flicker focused tests: `mcp__xcodebuildmcp__.test_sim({ progress: true, extraArgs: ["-only-testing:ADETests/ADETests/testFilesDetailRefreshDelayOnlyThrottlesWarmContent", "-only-testing:ADETests/ADETests/testFilesDiffTreatsTruncatedSidesAsUnsafeToMarkClean", "-only-testing:ADETests/ADETests/testFilesHistoryFallbackPrefersEntriesAndExplicitErrors"] })` — `3 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T21-32-20-650Z_pid61834_b97176f0.log` +- Latest root-tab / Files-detail flicker build: `mcp__xcodebuildmcp__.build_run_sim({})` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T21-31-00-124Z_pid61834_4da7941d.log` +- Codex Computer Use verification for root-tab / Files-detail flicker: CUA verified the ADE-owned bottom tab bar on Work no longer shows live row text through the bar, Work chat detail hides the root bar above the composer, Files root switches via the custom Files tab, and `mobile-history.txt` detail renders the footer above the bottom chrome. +- Latest attention drawer focused tests: `mcp__xcodebuildmcp__.test_sim({ progress: true, extraArgs: ["-only-testing:ADETests/AttentionDrawerModelTests"] })` — `10 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T21-38-30-432Z_pid61834_fa032a27.log` +- Latest attention drawer build: `mcp__xcodebuildmcp__.build_run_sim({})` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T21-39-31-073Z_pid61834_3de93276.log` +- Codex Computer Use verification for attention drawer clear-all: CUA opened the live drawer with `CI failing`, `Review requested`, and `Merge ready`; `Clear all` was enabled, the card bloom was reduced, tapping it showed `No pending items`, and the root bell badge cleared. +- Latest attention/Work shell build after failed-session routing and flicker cleanup: `mcp__xcodebuildmcp__.build_run_sim({})` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T21-58-14-691Z_pid61834_41d436d7.log` +- Latest attention focused tests after failed-session routing: `mcp__xcodebuildmcp__.test_sim({ progress: true, extraArgs: ["-only-testing:ADETests/ADETests/testDeepLinkRouterRequestsWorkSessionNavigation", "-only-testing:ADETests/ADETests/testSyncActiveSessionsKeepsFailedChatsForAttentionDrawer", "-only-testing:ADETests/AttentionDrawerModelTests"] })` — `12 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T22-00-37-671Z_pid61834_f0a31dce.log` +- Codex Computer Use verification for failed-session attention routing and clear-all: CUA opened the rebuilt Work root, verified the duplicate lower live chip is gone, opened the drawer, tapped `Open agent` for `MOBILE_FAILED_ATTENTION_FIXTURE`, landed on the failed Work chat detail, backed out, reopened the drawer, tapped `Clear all`, saw `No pending items`, dismissed the sheet, and verified `Attention items: 0`. +- Latest attention/awaiting focused tests: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testWorkChatComposerPlaceholderDistinguishesMissingPromptDetails", "-only-testing:ADETests/ADETests/testWorkChatStatusNormalizationPrefersAwaitingInputAndIdle", "-only-testing:ADETests/ADETests/testSyncActiveSessionsKeepsFailedChatsForAttentionDrawer", "-only-testing:ADETests/ADETests/testDeepLinkRouterRequestsWorkSessionNavigation", "-only-testing:ADETests/AttentionDrawerModelTests"] })` — `14 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T22-19-55-592Z_pid61834_deba887c.log` +- Latest attention/awaiting build: `mcp__xcodebuildmcp__.build_run_sim({})` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T22-20-36-179Z_pid61834_54fdac66.log` +- Codex Computer Use verification for awaiting-input attention routing: CUA verified Work root reads `11 Work sessions live, 1 waiting for input`, `MOBILE_AWAITING_ATTENTION_FIXTURE` reads `NEEDS INPUT`, the drawer renders one Awaiting input card with compact `Approve`, `Deny`, and `Reply`, and tapping the card opens the Work detail. The detail now shows `Prompt details syncing` plus `Waiting for prompt details from the machine.` and the composer placeholder is `Waiting for prompt details...`, so status-only awaiting rows no longer point at an invisible prompt. +- Awaiting detail flicker check: repeated simulator captures `/tmp/ade-awaiting-detail-post-1.png` and `/tmp/ade-awaiting-detail-post-2.png` compared with `magick compare -metric AE` returned `0`. +- Latest PR create sheet geometry build: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T22-27-19-007Z_pid61834_22e5d0a8.log` +- Codex Computer Use verification for PR create sheet geometry: CUA opened PRs -> Create PR on the rebuilt Simulator, verified the large sheet grabber, collapsed `Not eligible (9)` to three rows plus `Show 6 more`, expanded and collapsed it, switched to Queue and selected a lane, switched to Integration and selected two lanes, then scrolled to the Integration Review card without bottom chrome overlap. +- PR create sheet flicker check: repeated optimized simulator captures `/var/folders/ck/qnm27lyn4d3865_9s0xt26y80000gn/T/screenshot_optimized_c23bc4fa-f462-4566-a020-492d2299a7a9.jpg` and `/var/folders/ck/qnm27lyn4d3865_9s0xt26y80000gn/T/screenshot_optimized_76b7ac8b-34a2-49ce-a86b-e9f6f9129bfb.jpg` compared with `magick compare -metric AE` returned `0`. +- Codex Computer Use verification for lane git fixtures: CUA staged/unstaged, confirmed Discard and Discard staged from the commit sheet, then drove stash apply/pop/hold-delete/hold-clear/create/delete and real rebase conflict Continue/Abort against `/Users/admin/Projects/perf pass/.ade/worktrees/vm-audit-retry-mac-20260517-37ca2dc4`. +- Latest full iOS pass after terminal, proof, and lane-action fixes: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet"] })` — `290 passed`, `0 failed`, `0 skipped`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T18-54-57-947Z_pid61834_9aaf6d97.log` +- Latest diff hygiene after terminal, proof, and lane-action fixes: `git diff --check` — clean. +- Latest relaunch after full iOS pass: `mcp__xcodebuildmcp__.build_run_sim({ extraArgs: ["-quiet"] })` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T18-58-52-722Z_pid61834_6af7b6ea.log` +- Codex Computer Use verification for lane suggest/amend/commit: after pairing back to the throwaway runtime, CUA staged `mobile-final-amend.txt`, verified `Suggest needs setup` copy, typed `Mobile final amend commit`, toggled `Amend last commit`, tapped `Amend commit`, and host git showed `f052c4c Mobile final amend commit` with no staged files left. +- Latest desktop notification/sync focused tests: `npm --prefix apps/desktop exec -- vitest run src/main/services/notifications/notificationEventBus.test.ts src/main/services/sync/deviceRegistryService.test.ts` — `19 passed`. +- Latest ADE CLI validation after test-push host fallback: `npm --prefix apps/ade-cli run typecheck`, `npm --prefix apps/ade-cli exec -- vitest run src/services/sync/syncHostService.test.ts` — `10 passed`, `npm --prefix apps/ade-cli run test` — `681 passed`, `npm --prefix apps/ade-cli run build` — succeeded. +- Latest desktop validation after shared sync type changes: `npm --prefix apps/desktop run typecheck` and `npm --prefix apps/desktop run build` — succeeded; build kept existing Vite large-chunk warnings. +- Latest diff hygiene: `git diff --check` — clean. +- `git diff --check` — clean after the latest iOS changes. +- Latest continuation build after PR attention and terminal fixes: `mcp__xcodebuildmcp__.build_run_sim({})` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T12-34-03-701Z_pid61834_560cd30a.log` +- `mcp__xcodebuildmcp__.test_sim({ extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testSyncServiceAdoptsRemoteProjectIdWhenStaleCachedDuplicateStillExists"] })` — passed. +- `mcp__xcodebuildmcp__.test_sim({ extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testDatabaseReplacePullRequestHydrationScopesPayloadToActiveProject"] })` — passed. +- `npm --prefix apps/desktop exec -- vitest run src/main/services/sync/syncRemoteCommandService.test.ts` — `153 passed`. +- `mcp__xcodebuildmcp__.test_sim({ extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testPrParsedDateHandlesFractionalAndFallbackIsoDates"] })` +- `mcp__xcodebuildmcp__.test_sim({ extraArgs: ["-quiet"] })` — `263 passed`, `0 failed`, `0 skipped` +- Latest full iOS pass: `mcp__xcodebuildmcp__.test_sim({ progress: true, extraArgs: ["-quiet"] })` — `266 passed`, `0 failed`, `0 skipped`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T12-38-10-597Z_pid61834_443f8be7.log` +- Latest focused Work/Lanes continuation: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testDatabaseUpdateSessionMetaPersistsRenamePinnedAndManualName", "-only-testing:ADETests/ADETests/testResolvedWorkArchivedSessionIdsKeepsLocalOverrideForKnownChat", "-only-testing:ADETests/ADETests/testResolvedWorkArchivedSessionIdsReadsHydratedTerminalArchiveState", "-only-testing:ADETests/ADETests/testResolvedWorkNavigationLaneIdKeepsKnownLaneId", "-only-testing:ADETests/ADETests/testResolvedWorkNavigationLaneIdMapsStalePrimarySessionToActivePrimary", "-only-testing:ADETests/ADETests/testResolvedWorkNavigationLaneIdFallsBackToMatchingNameOrBranch"] })` — `6 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T14-36-14-813Z_pid61834_a450675d.log` +- `npm --prefix apps/desktop run typecheck` +- `npm --prefix apps/desktop run lint` — exits `0` with existing warnings +- `npm --prefix apps/desktop run build` — exits `0` with Vite large-chunk warnings +- `npm --prefix apps/ade-cli run typecheck` +- `npm --prefix apps/ade-cli run test` — `681 passed` +- `npm --prefix apps/ade-cli run build` +- `cd apps/desktop && npx vitest run --shard=N/8` for `N=1..8` + - Passing shard totals after reruns: shard 1 `634 passed`; shard 2 `693 passed`, `2 skipped`; shard 3 `647 passed`; shard 4 `708 passed`; shard 5 `497 passed`; shard 6 `562 passed`; shard 7 `916 passed`; shard 8 `965 passed`. + - Shard 3 initially hit a Vitest worker IPC timeout when run alongside three other shards; it passed when rerun alone. + - Shard 4 initially failed two `localRuntimeConnectionPool` daemon restart assertions while overlapping other shards; it passed when rerun alone. + - Shard 5 initially caught a stale sync-host test double around Codex initial input metadata; the host test now includes `sessionService.updateMeta`, verifies the Codex seed text is passed through launch args, verifies phone seed metadata, and verifies it is not duplicated as raw PTY input. The focused test and full shard pass. +- `xcrun xctrace export --input /tmp/ade-ios-mobile-pass-traces/pr-date-cache.trace --toc` +- `xcrun xctrace export --input /tmp/ade-ios-mobile-pass-traces/pr-date-cache.trace --xpath '/trace-toc/run[@number="1"]/data/table[@schema="potential-hangs"]'` +- Latest clean simulator log check: no `FOREIGN KEY`, `incoming message failed`, `SIGABRT`, crash, workflow, Linear credential, or fatal matches in the recent ADE OS log window.