diff --git a/apps/desktop/native/ios-sim-helpers/README.md b/apps/desktop/native/ios-sim-helpers/README.md index c54145800..0e77dbb1f 100644 --- a/apps/desktop/native/ios-sim-helpers/README.md +++ b/apps/desktop/native/ios-sim-helpers/README.md @@ -8,9 +8,11 @@ streaming and touch input on macOS. `[u32 big-endian length][jpeg bytes]` frames to stdout. It accepts `--fps` and `--quality` so ADE can cap renderer load without changing callers. - `sim-input.m` opens SimulatorKit's private Indigo HID client and accepts - newline-delimited JSON input commands on stdin. Touch input is sent through - Indigo; unsupported keyboard/text operations are reported as typed failures so - ADE can fall back to idb for that method. + newline-delimited JSON input commands on stdin. Touch input uses point-space + coordinates and screen dimensions, with the Xcode 26 9-argument Indigo mouse + event path when available and the legacy 5-argument path on older supported + Xcode versions; unsupported keyboard/text operations are reported as typed + failures so ADE can fall back to idb for that method. - `build.sh` compiles both helpers lazily into `build/xcode--/`. Set `ADE_IOS_SIM_HELPER_BUILD_ROOT` to place that cache somewhere else; packaged ADE builds use this to keep generated binaries outside the signed diff --git a/apps/desktop/native/ios-sim-helpers/sim-input.m b/apps/desktop/native/ios-sim-helpers/sim-input.m index 2568c579c..ef1ec2e34 100644 --- a/apps/desktop/native/ios-sim-helpers/sim-input.m +++ b/apps/desktop/native/ios-sim-helpers/sim-input.m @@ -1,6 +1,7 @@ #import #import #import +#import #import #import #import @@ -75,16 +76,41 @@ #define ButtonEventTargetHardware 0x33 #define ButtonEventTypeDown 0x1 #define ButtonEventTypeUp 0x2 +#define TouchDigitizerTarget 0x32 +#define MouseEventDown 0x1 +#define MouseEventUp 0x2 +#define MouseEventDragged 0x6 +#define MouseDirectionDown 0x1 +#define MouseDirectionMove 0x0 +#define MouseDirectionUp 0x2 typedef IndigoMessage *(*IndigoButtonFn)(int keyCode, int op, int target); -typedef IndigoMessage *(*IndigoMouseFn)(CGPoint *point0, CGPoint *point1, int target, int eventType, BOOL extra); +typedef IndigoMessage *(*IndigoMouse5Fn)(CGPoint *point0, CGPoint *point1, int target, int eventType, BOOL extra); +typedef IndigoMessage *(*IndigoMouse9Fn)( + CGPoint *point0, + CGPoint *point1, + unsigned int target, + unsigned int eventType, + unsigned int direction, + double unused1, + double unused2, + double widthPoints, + double heightPoints +); +typedef IndigoMessage *(*IndigoServiceFn)(void); static NSString *gDeviceUdid = nil; static id gHidClient = nil; static IndigoButtonFn gButtonFn = NULL; -static IndigoMouseFn gMouseFn = NULL; +static IndigoMouse5Fn gMouse5Fn = NULL; +static IndigoMouse9Fn gMouse9Fn = NULL; +static IndigoServiceFn gCreatePointerServiceFn = NULL; +static IndigoServiceFn gCreateMouseServiceFn = NULL; +static BOOL gUseModernMousePath = NO; static dispatch_queue_t gSendQueue; +static BOOL sendIndigo(IndigoMessage *message, NSString **errorOut); + static void elog(NSString *fmt, ...) { va_list args; va_start(args, fmt); @@ -174,6 +200,26 @@ static id bootedDevice(id deviceSet) { return nil; } +static NSInteger selectedXcodeMajor(void) { + NSTask *task = [NSTask new]; + task.launchPath = @"/usr/bin/xcodebuild"; + task.arguments = @[@"-version"]; + NSPipe *pipe = [NSPipe pipe]; + task.standardOutput = pipe; + @try { + [task launch]; + [task waitUntilExit]; + } @catch (id ignored) { + return 0; + } + NSString *raw = [[NSString alloc] initWithData:pipe.fileHandleForReading.readDataToEndOfFile encoding:NSUTF8StringEncoding]; + NSArray *parts = [raw componentsSeparatedByCharactersInSet:[[NSCharacterSet decimalDigitCharacterSet] invertedSet]]; + for (NSString *part in parts) { + if (part.length > 0) return part.integerValue; + } + return 0; +} + static BOOL loadIndigoSymbolsOnly(void) { if (!dlopen("/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator", RTLD_NOW)) { elog(@"[sim-input] FAIL dlopen CoreSimulator: %s", dlerror()); @@ -186,14 +232,34 @@ static BOOL loadIndigoSymbolsOnly(void) { return NO; } gButtonFn = (IndigoButtonFn)dlsym(kit, "IndigoHIDMessageForButton"); - gMouseFn = (IndigoMouseFn)dlsym(kit, "IndigoHIDMessageForMouseNSEvent"); - if (!gButtonFn || !gMouseFn) { - elog(@"[sim-input] FAIL Indigo dlsym button=%p mouse=%p", gButtonFn, gMouseFn); + void *mouseSymbol = dlsym(kit, "IndigoHIDMessageForMouseNSEvent"); + gMouse5Fn = (IndigoMouse5Fn)mouseSymbol; + gMouse9Fn = (IndigoMouse9Fn)mouseSymbol; + gCreatePointerServiceFn = (IndigoServiceFn)dlsym(kit, "IndigoHIDMessageToCreatePointerService"); + gCreateMouseServiceFn = (IndigoServiceFn)dlsym(kit, "IndigoHIDMessageToCreateMouseService"); + gUseModernMousePath = selectedXcodeMajor() >= 26; + if (!gButtonFn || !mouseSymbol) { + elog(@"[sim-input] FAIL Indigo dlsym button=%p mouse=%p", gButtonFn, mouseSymbol); return NO; } return YES; } +static void warmIndigoService(IndigoServiceFn serviceFn, NSString *name) { + if (!serviceFn) return; + NSString *error = nil; + IndigoMessage *message = serviceFn(); + if (!message) { + elog(@"[sim-input] %@ warmup returned NULL", name); + return; + } + if (!sendIndigo(message, &error)) { + elog(@"[sim-input] %@ warmup failed: %@", name, error ?: @"unknown error"); + return; + } + usleep(20000); +} + static BOOL ensureHID(void) { if (gHidClient) return YES; if (!loadIndigoSymbolsOnly()) return NO; @@ -223,6 +289,8 @@ static BOOL ensureHID(void) { } gHidClient = client; gSendQueue = dispatch_queue_create("app.ade.ios-sim.input", DISPATCH_QUEUE_SERIAL); + warmIndigoService(gCreatePointerServiceFn, @"pointer service"); + warmIndigoService(gCreateMouseServiceFn, @"mouse service"); elog(@"[sim-input] HID client ready dev=%@ udid=%@", [device valueForKey:@"name"], deviceUDID(device)); return YES; } @@ -256,10 +324,43 @@ static BOOL sendIndigo(IndigoMessage *message, NSString **errorOut) { return YES; } -static BOOL sendTouch(double xRatio, double yRatio, BOOL down, NSString **errorOut) { +static double positiveOrDefault(id value, double fallback) { + double parsed = [value respondsToSelector:@selector(doubleValue)] ? [value doubleValue] : fallback; + return isfinite(parsed) && parsed > 0 ? parsed : fallback; +} + +static double clampUnit(double value) { + if (!isfinite(value)) return 0; + if (value < 0) return 0; + if (value > 1) return 1; + return value; +} + +static BOOL sendModernTouch(double x, double y, double width, double height, unsigned int eventType, unsigned int direction, NSString **errorOut) { + if (!gMouse9Fn) { + if (errorOut) *errorOut = @"Modern Indigo mouse event builder is not available."; + return NO; + } + CGPoint point = CGPointMake(clampUnit(x / width), clampUnit(y / height)); + IndigoMessage *message = gMouse9Fn(&point, NULL, TouchDigitizerTarget, eventType, direction, 1.0, 1.0, width, height); + if (!message) { + if (errorOut) *errorOut = @"Indigo mouse event builder returned NULL."; + elog(@"[sim-input] %@", errorOut ? *errorOut : @"MouseFn returned NULL"); + return NO; + } + return sendIndigo(message, errorOut); +} + +static BOOL sendLegacyTouch(double x, double y, double width, double height, unsigned int eventType, NSString **errorOut) { + if (!gMouse5Fn) { + if (errorOut) *errorOut = @"Legacy Indigo mouse event builder is not available."; + return NO; + } + double xRatio = clampUnit(x / width); + double yRatio = clampUnit(y / height); CGPoint point = CGPointMake(xRatio, yRatio); - int eventType = down ? ButtonEventTypeDown : ButtonEventTypeUp; - IndigoMessage *seed = gMouseFn(&point, NULL, 0x32, eventType, NO); + int legacyEventType = eventType == MouseEventUp ? ButtonEventTypeUp : ButtonEventTypeDown; + IndigoMessage *seed = gMouse5Fn(&point, NULL, TouchDigitizerTarget, legacyEventType, NO); if (!seed) { if (errorOut) *errorOut = @"Indigo mouse event builder returned NULL."; elog(@"[sim-input] %@", errorOut ? *errorOut : @"MouseFn returned NULL"); @@ -285,6 +386,13 @@ static BOOL sendTouch(double xRatio, double yRatio, BOOL down, NSString **errorO return sendIndigo(message, errorOut); } +static BOOL sendTouch(double x, double y, double width, double height, unsigned int eventType, unsigned int direction, NSString **errorOut) { + if (gUseModernMousePath) { + return sendModernTouch(x, y, width, height, eventType, direction, errorOut); + } + return sendLegacyTouch(x, y, width, height, eventType, errorOut); +} + static BOOL sendButton(NSString *name, BOOL down, NSString **errorOut) { int source = ButtonEventSourceHomeButton; if ([name isEqualToString:@"home"]) source = ButtonEventSourceHomeButton; @@ -307,17 +415,28 @@ static BOOL processEvent(NSDictionary *event, NSString **errorOut) { return NO; } NSString *type = event[@"type"]; + double width = positiveOrDefault(event[@"width"], 1); + double height = positiveOrDefault(event[@"height"], 1); if ([type isEqualToString:@"touch"]) { NSString *phase = event[@"phase"] ?: @"down"; - return sendTouch([event[@"x"] doubleValue], [event[@"y"] doubleValue], ![phase isEqualToString:@"up"], errorOut); + unsigned int eventType = MouseEventDown; + unsigned int direction = MouseDirectionDown; + if ([phase isEqualToString:@"move"]) { + eventType = MouseEventDragged; + direction = MouseDirectionMove; + } else if ([phase isEqualToString:@"up"]) { + eventType = MouseEventUp; + direction = MouseDirectionUp; + } + return sendTouch([event[@"x"] doubleValue], [event[@"y"] doubleValue], width, height, eventType, direction, errorOut); } if ([type isEqualToString:@"tap"]) { double x = [event[@"x"] doubleValue]; double y = [event[@"y"] doubleValue]; int hold = event[@"hold"] ? [event[@"hold"] intValue] : 45; - if (!sendTouch(x, y, YES, errorOut)) return NO; + if (!sendTouch(x, y, width, height, MouseEventDown, MouseDirectionDown, errorOut)) return NO; usleep((useconds_t)(MAX(0, hold) * 1000)); - return sendTouch(x, y, NO, errorOut); + return sendTouch(x, y, width, height, MouseEventUp, MouseDirectionUp, errorOut); } if ([type isEqualToString:@"swipe"]) { double startX = [event[@"startX"] doubleValue]; @@ -326,13 +445,21 @@ static BOOL processEvent(NSDictionary *event, NSString **errorOut) { double endY = [event[@"endY"] doubleValue]; int durationMs = event[@"durationMs"] ? [event[@"durationMs"] intValue] : 180; int steps = MAX(4, MIN(30, durationMs / 16)); - if (!sendTouch(startX, startY, YES, errorOut)) return NO; + if (!sendTouch(startX, startY, width, height, MouseEventDown, MouseDirectionDown, errorOut)) return NO; for (int i = 1; i < steps; i++) { double progress = (double)i / (double)steps; - if (!sendTouch(startX + ((endX - startX) * progress), startY + ((endY - startY) * progress), YES, errorOut)) return NO; + if (!sendTouch( + startX + ((endX - startX) * progress), + startY + ((endY - startY) * progress), + width, + height, + MouseEventDragged, + MouseDirectionMove, + errorOut + )) return NO; usleep((useconds_t)(MAX(1, durationMs / steps) * 1000)); } - return sendTouch(endX, endY, NO, errorOut); + return sendTouch(endX, endY, width, height, MouseEventUp, MouseDirectionUp, errorOut); } if ([type isEqualToString:@"button"]) { NSString *phase = event[@"phase"] ?: @"down"; diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index 1fc3d20a2..0c77f66b6 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -43,6 +43,31 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("use exitPlanMode to request implementation approval"); }); + it("keeps Codex plan context aligned with native app-server plan mode", () => { + const result = buildCodingAgentSystemPrompt({ + cwd: "/x", + permissionMode: "plan", + runtime: "codex-cli", + }); + + expect(result).toContain("Native Codex Plan Mode controls planning and approval"); + expect(result).toContain("proposed-plan mechanism"); + expect(result).toContain("Do not use TodoWrite, update_plan, or exitPlanMode"); + }); + + it("does not tell non-interactive Codex plan sessions to ask blocking questions", () => { + const result = buildCodingAgentSystemPrompt({ + cwd: "/x", + permissionMode: "plan", + runtime: "codex-cli", + interactive: false, + }); + + expect(result).toContain("Native Codex Plan Mode controls planning and approval"); + expect(result).toContain("make the safest reasonable assumptions"); + expect(result).not.toContain("use request_user_input"); + }); + it("includes full-auto permission description", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x", permissionMode: "full-auto" }); expect(result).toContain("Autonomous mode"); diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index 5191870a5..b85b0b731 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -146,11 +146,18 @@ export function buildCodingAgentSystemPrompt(args: { ? `Available tools: ${toolNames.join(", ")}.` : "Use the available tools deliberately and only when they move the task forward.", ...(guardedLocalReadOnly - ? [ - "Plan mode is read-only. Do not attempt editFile, writeFile, bash, or other mutating actions.", - "Inspect only the concrete files needed to form a plan. Do not keep broad-searching once you have enough context.", - "When the plan is clear, write or update a short TodoWrite plan, ask one clarifying question if needed, then use exitPlanMode to request implementation approval.", - ] + ? runtime === "codex-cli" + ? [ + interactive + ? "Native Codex Plan Mode controls planning and approval. Preserve that built-in flow: stay read-only, use request_user_input for important clarifications when needed, and publish the final plan through Codex's proposed-plan mechanism." + : "Native Codex Plan Mode controls planning and approval. Preserve that built-in flow: stay read-only, make the safest reasonable assumptions when clarification would otherwise be needed, and publish the final plan through Codex's proposed-plan mechanism.", + "Do not use TodoWrite, update_plan, or exitPlanMode as the plan-approval path in native Codex Plan Mode.", + ] + : [ + "Plan mode is read-only. Do not attempt editFile, writeFile, bash, or other mutating actions.", + "Inspect only the concrete files needed to form a plan. Do not keep broad-searching once you have enough context.", + "When the plan is clear, write or update a short TodoWrite plan, ask one clarifying question if needed, then use exitPlanMode to request implementation approval.", + ] : [ "Prefer the smallest search/list/read pass before editing so you operate on the right files the first time.", "Batch related discovery work only when the runtime can use it without repeating the same scope.", diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index f69544ae3..d2aa6150a 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4303,6 +4303,76 @@ describe("createAgentChatService", () => { ])); }); + it("emits immediate startup activity for Codex before turn/start resolves", async () => { + const events: AgentChatEventEnvelope[] = []; + mockState.delayedCodexMethods.add("turn/start"); + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Resolve the PR comments.", + }, { awaitDispatch: true }); + let sendResolved = false; + void sendPromise.then(() => { + sendResolved = true; + }); + + const startedEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "status" + && event.event.turnStatus === "started" + && !("turnId" in event.event), + ); + const startupActivity = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "activity" + && !("turnId" in event.event) + && (event.event.activity === "thinking" || event.event.activity === "working"), + ); + + expect(startedEvent.event.turnStatus).toBe("started"); + expect(startupActivity.event.detail).toBeTruthy(); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + await Promise.resolve(); + expect(sendResolved).toBe(false); + const noTurnStartedCount = events.filter((event) => + event.event.type === "status" + && event.event.turnStatus === "started" + && !("turnId" in event.event) + ).length; + expect(noTurnStartedCount).toBe(1); + + mockState.flushCodexResponses(); + await sendPromise; + expect(sendResolved).toBe(true); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && "turnId" in event.event + && event.event.turnId === "turn-1", + ); + }); + it("ignores unsolicited Codex turn notifications when no turn is active", async () => { const events: Array<{ type: string; turnId?: string; text?: string }> = []; const { service } = createService({ @@ -5583,11 +5653,13 @@ describe("createAgentChatService", () => { approvalPolicy?: unknown; sandboxPolicy?: { type?: unknown; networkAccess?: unknown; access?: { type?: unknown } }; effort?: unknown; + input?: Array<{ type?: unknown; text?: unknown }>; collaborationMode?: Record; } | undefined; const collaborationMode = params?.collaborationMode as | { mode?: unknown; settings?: { model?: unknown; reasoning_effort?: unknown; developer_instructions?: unknown } } | undefined; + const textInputs = (params?.input ?? []).filter((item) => item.type === "text"); expect(params?.approvalPolicy).toBe("untrusted"); expect(params?.sandboxPolicy?.type).toBe("readOnly"); @@ -5595,7 +5667,11 @@ describe("createAgentChatService", () => { expect(collaborationMode?.mode).toBe("plan"); expect(collaborationMode?.settings?.model).toBe("gpt-5.4"); expect(collaborationMode?.settings?.reasoning_effort).toBe("medium"); - expect(collaborationMode?.settings?.developer_instructions).toBe("system prompt"); + expect(collaborationMode?.settings?.developer_instructions).toBeNull(); + expect(textInputs.at(-2)?.text).toContain("System context (ADE runtime guidance"); + expect(textInputs.at(-2)?.text).toContain("system prompt"); + expect(textInputs.at(-1)?.text).toContain("User request:"); + expect(textInputs.at(-1)?.text).toContain("Ask one planning question before coding."); expect(vi.mocked(buildCodingAgentSystemPrompt)).toHaveBeenCalledWith( expect.objectContaining({ cwd: expect.stringContaining(path.basename(tmpRoot)), @@ -5606,6 +5682,226 @@ describe("createAgentChatService", () => { ); }); + it("turns native Codex plan items into an implementation approval request", async () => { + vi.mocked(mapPermissionToCodex).mockImplementation((mode) => { + if (mode === "edit") return { approvalPolicy: "untrusted", sandbox: "workspace-write" }; + if (mode === "full-auto") return { approvalPolicy: "never", sandbox: "danger-full-access" }; + if (mode === "config-toml") return null; + return { approvalPolicy: "on-request", sandbox: "read-only" }; + }); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + codexApprovalPolicy: "untrusted", + codexSandbox: "read-only", + codexConfigSource: "flags", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Plan the fix before coding.", + }, { awaitDispatch: true }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + id: "codex-plan-1", + type: "plan", + text: "\n## Summary\n- Inspect the app-server wiring.\n- Patch the native plan handoff.\n", + }, + }, + }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "plan_text" + && event.event.itemId === "codex-plan-1" + && event.event.text.includes("Inspect the app-server wiring"), + ); + const approvalEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "approval_request" + && ((event.event.detail as { request?: { kind?: string } } | undefined)?.request?.kind === "plan_approval"), + ); + const request = (approvalEvent.event.detail as { request?: { description?: string } } | undefined)?.request; + + expect(request?.description).toContain("## Summary"); + expect(request?.description).toContain("Patch the native plan handoff"); + expect(request?.description).not.toContain(""); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + turn: { + id: "turn-1", + status: "completed", + }, + }, + }); + await vi.waitFor(async () => { + expect((await service.getSessionSummary(session.id))?.status).toBe("idle"); + }); + + const turnStartCountBeforeApproval = mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start").length; + await service.respondToInput({ + sessionId: session.id, + itemId: approvalEvent.event.itemId, + decision: "accept", + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start").length) + .toBeGreaterThan(turnStartCountBeforeApproval); + }); + expect((await service.getSessionSummary(session.id))?.permissionMode).toBe("edit"); + }); + + it("keeps native Codex plan deltas under a stable fallback item id", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + codexApprovalPolicy: "untrusted", + codexSandbox: "read-only", + codexConfigSource: "flags", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Plan with streaming deltas.", + }, { awaitDispatch: true }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/plan/delta", + params: { + turnId: "turn-1", + delta: "1. Inspect the service\n", + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/plan/delta", + params: { + turnId: "turn-1", + delta: "2. Patch the handoff", + }, + }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "plan_text" + && event.event.itemId === `codex-plan:${session.id}:turn-1` + && event.event.text.includes("Patch the handoff"), + ); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + turn: { + id: "turn-1", + status: "completed", + }, + }, + }); + + const approvalEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "approval_request" + && ((event.event.detail as { request?: { kind?: string } } | undefined)?.request?.kind === "plan_approval"), + ); + const request = (approvalEvent.event.detail as { request?: { description?: string } } | undefined)?.request; + expect(request?.description).toContain("1. Inspect the service"); + expect(request?.description).toContain("2. Patch the handoff"); + }); + + it("does not request native Codex plan approval after a failed turn", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + codexApprovalPolicy: "untrusted", + codexSandbox: "read-only", + codexConfigSource: "flags", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Plan but fail.", + }, { awaitDispatch: true }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/plan/delta", + params: { + turnId: "turn-1", + itemId: "codex-plan-failed", + delta: "1. This should not be approvable.", + }, + }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "plan_text" && event.event.itemId === "codex-plan-failed", + ); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + turn: { + id: "turn-1", + status: "failed", + error: { message: "Plan mode crashed" }, + }, + }, + }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "status" && event.event.turnStatus === "failed", + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(events.some((event) => + event.event.type === "approval_request" + && ((event.event.detail as { request?: { kind?: string } } | undefined)?.request?.kind === "plan_approval") + )).toBe(false); + }); + it("sends Codex default collaboration mode on turn start outside plan mode", async () => { const { service } = createService(); const session = await service.createSession({ @@ -5640,12 +5936,15 @@ describe("createAgentChatService", () => { effort?: unknown; collaborationMode?: Record; } | undefined; - const collaborationMode = params?.collaborationMode as { mode?: unknown } | undefined; + const collaborationMode = params?.collaborationMode as + | { mode?: unknown; settings?: { developer_instructions?: unknown } } + | undefined; expect(params?.approvalPolicy).toBe("on-request"); expect(params?.sandboxPolicy?.type).toBe("workspaceWrite"); expect(params?.effort).toBe("medium"); expect(collaborationMode?.mode).toBe("default"); + expect(collaborationMode?.settings?.developer_instructions).toBeNull(); }); it("preserves Codex edit sessions as untrusted workspace-write", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 094846aa2..0d8ea9ae4 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -394,6 +394,7 @@ type CodexRuntime = { commandOutputByItemId: Map; fileDeltaByItemId: Map; fileChangesByItemId: Map>; + planTextByItemId: Map; activeSubagents: Map; interruptedTurnIds: Set; ignoredTurnIds: Set; @@ -1130,6 +1131,7 @@ type PreparedSendMessage = { turnId?: string; optimisticCursorTurnStart?: boolean; optimisticAcpTurnStart?: boolean; + optimisticCodexTurnStart?: boolean; runtime?: AgentChatRuntime; cloudOverrides?: AgentChatCloudOverrides; }; @@ -2902,13 +2904,27 @@ function buildCodexDeveloperInstructions(args: { }); } +function buildCodexAdeContextInput(args: { + laneWorktreePath: string; + session: Pick; + collaborationMode: "default" | "plan"; +}): Record { + return { + type: "text", + text: [ + "System context (ADE runtime guidance, do not echo verbatim):", + buildCodexDeveloperInstructions(args), + ].join("\n\n"), + text_elements: [], + }; +} + function buildCodexCollaborationMode( session: Pick< AgentChatSession, "provider" | "permissionMode" | "interactionMode" | "model" | "reasoningEffort" | "codexConfigSource" >, supportedModes: Set | null, - laneWorktreePath: string, ): CodexCollaborationModePayload | null { if (session.provider !== "codex") return null; if (resolveSessionCodexConfigSource(session) === "config-toml") return null; @@ -2927,11 +2943,7 @@ function buildCodexCollaborationMode( settings: { model: session.model, reasoning_effort: session.reasoningEffort ?? DEFAULT_REASONING_EFFORT, - developer_instructions: buildCodexDeveloperInstructions({ - laneWorktreePath, - session, - collaborationMode: mode, - }), + developer_instructions: null, }, }; } @@ -7298,6 +7310,7 @@ export function createAgentChatService(args: { laneDirectiveKey?: string | null; providerSlashCommand?: boolean; forceClaudeUserMessage?: boolean; + optimisticCodexTurnStart?: boolean; onDispatched?: () => void; }, ): Promise => { @@ -7318,19 +7331,28 @@ export function createAgentChatService(args: { _rootPath: managed.laneWorktreePath, })); const displayText = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; + let onDispatched = args.onDispatched; + const markDispatched = () => { + if (!onDispatched) return; + const callback = onDispatched; + onDispatched = undefined; + callback(); + }; setSessionActive(managed); - emitPreparedUserMessage(managed, { - text: displayText, - attachments, - laneDirectiveKey: args.laneDirectiveKey, - onDispatched: args.onDispatched, - }); - emitChatEvent(managed, { type: "status", turnStatus: "started" }); - captureTurnBeforeSha(managed); - emitChatEvent(managed, { - type: "activity", - ...initialTurnActivity(managed.session), - }); + if (!args.optimisticCodexTurnStart) { + emitPreparedUserMessage(managed, { + text: displayText, + attachments, + laneDirectiveKey: args.laneDirectiveKey, + onDispatched: markDispatched, + }); + emitChatEvent(managed, { type: "status", turnStatus: "started" }); + captureTurnBeforeSha(managed); + emitChatEvent(managed, { + type: "activity", + ...initialTurnActivity(managed.session), + }); + } const providerSlashCommand = args.providerSlashCommand === true; const autoMemoryPlan = providerSlashCommand ? null @@ -7350,6 +7372,7 @@ export function createAgentChatService(args: { runtime.awaitingTurnStart = false; throw error; } + markDispatched(); persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); const reviewTurnId = typeof reviewResult.turn?.id === "string" ? reviewResult.turn.id : null; if (reviewTurnId) { @@ -7380,21 +7403,6 @@ export function createAgentChatService(args: { text_elements: [], }); } - input.push({ - type: "text", - text: args.promptText, - text_elements: [] - }); - - for (const attachment of resolvedAttachments) { - const stagedPath = stageAttachmentForCodexInput(attachment); - if (attachment.type === "image") { - input.push({ type: "localImage", path: stagedPath }); - continue; - } - const name = path.basename(attachment.path) || attachment.path; - input.push({ type: "mention", name, path: stagedPath }); - } if (autoMemoryNotice) { emitChatEvent(managed, { @@ -7411,7 +7419,6 @@ export function createAgentChatService(args: { const collaborationMode = buildCodexCollaborationMode( managed.session, runtime.collaborationModes, - managed.laneWorktreePath, ); if ( requestedCollaborationMode === "plan" @@ -7427,6 +7434,28 @@ export function createAgentChatService(args: { } else if (collaborationMode?.mode === "plan") { runtime.planModeFallbackNotified = false; } + if (collaborationMode) { + input.push(buildCodexAdeContextInput({ + laneWorktreePath: managed.laneWorktreePath, + session: managed.session, + collaborationMode: collaborationMode.mode, + })); + } + input.push({ + type: "text", + text: args.promptText, + text_elements: [] + }); + + for (const attachment of resolvedAttachments) { + const stagedPath = stageAttachmentForCodexInput(attachment); + if (attachment.type === "image") { + input.push({ type: "localImage", path: stagedPath }); + continue; + } + const name = path.basename(attachment.path) || attachment.path; + input.push({ type: "mention", name, path: stagedPath }); + } managed.runtime.awaitingTurnStart = true; let result: { turn?: { id?: string } }; try { @@ -7442,6 +7471,7 @@ export function createAgentChatService(args: { managed.runtime.awaitingTurnStart = false; throw error; } + markDispatched(); persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); const turnId = typeof result?.turn?.id === "string" ? result.turn.id : null; @@ -9568,6 +9598,78 @@ export function createAgentChatService(args: { return true; }; + const normalizeCodexPlanText = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed.length) return null; + const proposedPlanMatch = trimmed.match(/\s*([\s\S]*?)\s*<\/proposed_plan>/i); + const planText = (proposedPlanMatch?.[1] ?? trimmed).trim(); + return planText.length ? planText : null; + }; + + const readCodexPlanTextFromItem = (item: Record): string | null => + normalizeCodexPlanText(item.text) + ?? normalizeCodexPlanText(item.planText) + ?? normalizeCodexPlanText(item.markdown) + ?? normalizeCodexPlanText(item.content) + ?? normalizeCodexPlanText(item.description); + + const emitCodexPlanTextApproval = ( + managed: ManagedChatSession, + runtime: CodexRuntime, + text: unknown, + turnId: string | undefined, + ): boolean => { + const planText = normalizeCodexPlanText(text); + if (!planText) return false; + if (managed.session.permissionMode !== "plan") return true; + + const hasExistingApproval = [...runtime.approvals.values()].some((pending) => + pending.kind === "plan_approval" + && ( + (turnId && (pending.request?.turnId ?? null) === turnId) + || pending.request?.description === planText + ), + ); + if (hasExistingApproval) return true; + + const planApprovalItemId = randomUUID(); + const request: PendingInputRequest = { + requestId: planApprovalItemId, + itemId: planApprovalItemId, + source: "codex", + kind: "plan_approval", + title: "Plan Ready for Review", + description: planText, + questions: [{ + id: "plan_decision", + header: "Implementation Plan", + question: planText, + options: [ + { label: "Approve & Implement", value: "approve", recommended: true }, + { label: "Reject & Revise", value: "reject" }, + ], + allowsFreeform: true, + }], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { tool: "codexPlanApproval" }, + turnId: turnId ?? runtime.activeTurnId ?? null, + }; + runtime.approvals.set(planApprovalItemId, { + requestId: planApprovalItemId, + kind: "plan_approval", + request, + }); + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: "Plan ready for approval", + detail: { planContent: planText }, + }); + return true; + }; + /** * Resolve any plan-approval follow-ups that were staged during a planning * turn. Runs once turn/completed has cleared activeTurnId so the @@ -9663,6 +9765,26 @@ export function createAgentChatService(args: { return; } + if (itemType === "plan") { + if (eventKind === "completed") { + const hadStreamingText = runtime.planTextByItemId.has(itemId); + const planText = readCodexPlanTextFromItem(item) ?? runtime.planTextByItemId.get(itemId) ?? null; + if (planText) { + if (!hadStreamingText) { + emitChatEvent(managed, { + type: "plan_text", + text: planText, + turnId, + itemId, + }); + } + emitCodexPlanTextApproval(managed, runtime, planText, turnId); + } + runtime.planTextByItemId.delete(itemId); + } + return; + } + if (itemType === "commandExecution") { emitChatEvent(managed, { type: "activity", @@ -10092,15 +10214,30 @@ export function createAgentChatService(args: { runtime.startedTurnId = null; runtime.ignoredTurnIds.delete(turnId); resetAssistantMessageStream(managed); + const status = mapCodexTurnStatus(turn?.status); + if (status === "completed") { + for (const [planItemId, planText] of runtime.planTextByItemId) { + emitCodexPlanTextApproval( + managed, + runtime, + planText, + runtime.itemTurnIdByItemId.get(planItemId) ?? turnId, + ); + } + } + runtime.planTextByItemId.clear(); runtime.itemTurnIdByItemId.clear(); runtime.agentMessageScopeByTurn.clear(); runtime.agentMessageTextByTurn.clear(); runtime.recentNotificationKeys.clear(); - const status = mapCodexTurnStatus(turn?.status); const usage = normalizeUsagePayload(turn?.usage ?? turn?.totalUsage); markSessionIdleWithFreshCache(managed); drainPendingPlanFollowups(managed, runtime); - runtime.approvals.clear(); + for (const [approvalId, pending] of runtime.approvals) { + if (pending.kind !== "plan_approval") { + runtime.approvals.delete(approvalId); + } + } if (status === "failed" && turn?.error?.message) { emitChatEvent(managed, { @@ -10332,6 +10469,7 @@ export function createAgentChatService(args: { runtime.commandOutputByItemId.clear(); runtime.fileDeltaByItemId.clear(); runtime.fileChangesByItemId.clear(); + runtime.planTextByItemId.clear(); runtime.agentMessageScopeByTurn.clear(); runtime.agentMessageTextByTurn.clear(); runtime.recentNotificationKeys.clear(); @@ -10437,13 +10575,22 @@ export function createAgentChatService(args: { } if (method === "item/plan/delta") { + const explicitItemId = typeof params.itemId === "string" && params.itemId.trim().length + ? params.itemId + : null; + const fallbackTurnId = turnIdFromParams ?? runtime.activeTurnId ?? runtime.startedTurnId ?? "unknown-turn"; + const itemId = explicitItemId ?? `codex-plan:${managed.session.id}:${fallbackTurnId}`; const delta = String((params.delta as string | undefined) ?? ""); if (!delta.length) return; + const turnId = turnIdFromParams ?? runtime.itemTurnIdByItemId.get(itemId) ?? runtime.activeTurnId ?? runtime.startedTurnId ?? undefined; + const next = `${runtime.planTextByItemId.get(itemId) ?? ""}${delta}`; + runtime.planTextByItemId.set(itemId, next); + evictOldestEntries(runtime.planTextByItemId, MAX_SESSION_MAP_ENTRIES); emitChatEvent(managed, { type: "plan_text", text: delta, - turnId: typeof params.turnId === "string" ? params.turnId : undefined, - itemId: typeof params.itemId === "string" ? params.itemId : undefined, + turnId, + itemId, }); return; } @@ -10545,6 +10692,7 @@ export function createAgentChatService(args: { commandOutputByItemId: new Map(), fileDeltaByItemId: new Map(), fileChangesByItemId: new Map>(), + planTextByItemId: new Map(), activeSubagents: new Map(), interruptedTurnIds: new Set(), ignoredTurnIds: new Set(), @@ -15088,6 +15236,7 @@ export function createAgentChatService(args: { turnId, optimisticCursorTurnStart, optimisticAcpTurnStart, + optimisticCodexTurnStart, } = prepared; // OpenCode runtime dispatch @@ -15295,6 +15444,7 @@ export function createAgentChatService(args: { resolvedAttachments, laneDirectiveKey, providerSlashCommand, + optimisticCodexTurnStart, onDispatched, }); return; @@ -15376,6 +15526,23 @@ export function createAgentChatService(args: { // acknowledged the prompt. } + if (prepared.managed.session.provider === "codex") { + prepared.optimisticCodexTurnStart = true; + emitPreparedUserMessage(prepared.managed, { + text: prepared.visibleText, + attachments: prepared.attachments, + laneDirectiveKey: prepared.laneDirectiveKey, + }); + emitChatEvent(prepared.managed, { type: "status", turnStatus: "started" }); + captureTurnBeforeSha(prepared.managed); + emitChatEvent(prepared.managed, { + type: "activity", + ...initialTurnActivity(prepared.managed.session), + }); + setSessionActive(prepared.managed); + persistChatState(prepared.managed); + } + logger.info("agent_chat.turn_dispatch_ack", { sessionId: prepared.sessionId, provider: prepared.managed.session.provider, @@ -16502,6 +16669,7 @@ export function createAgentChatService(args: { if (approved) { managed.session.permissionMode = "edit"; applyLegacyPermissionModeToNativeControls(managed.session, "edit"); + managed.session.interactionMode = "default"; runtime.threadResumed = false; persistChatState(managed); } diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts index aef4e00a4..2f358634b 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts @@ -12,6 +12,7 @@ import { detectIosurfaceIndigoCapability, IosSimulatorOwnedBySessionError, iosurfaceInputScreenFromSnapshot, + iosurfaceInputPointPayload, normalizeIosSimulatorPointForIndigo, parseXcodePreviewWindows, resolveIosSimulatorStreamBackend, @@ -498,6 +499,18 @@ describe("iosSimulatorService Simulator.app launch visibility", () => { y: 830 / 874, }); }); + + it("builds point-space Indigo input payloads with screen dimensions", () => { + expect(iosurfaceInputPointPayload( + { x: 201, y: 830 }, + { width: 402, height: 874 }, + )).toEqual({ + x: 201, + y: 830, + width: 402, + height: 874, + }); + }); }); describe("iosSimulatorService Xcode preview parsing", () => { diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.ts index 0b5a223e2..72791efc7 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.ts @@ -341,6 +341,23 @@ export function normalizeIosSimulatorPointForIndigo( }; } +export function iosurfaceInputPointPayload( + point: { x: number; y: number }, + screen: Pick, +): { x: number; y: number; width: number; height: number } { + const x = Number(point.x); + const y = Number(point.y); + const width = Number(screen.width); + const height = Number(screen.height); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + throw new Error("Simulator point coordinates are required for Indigo input."); + } + if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) { + throw new Error("Simulator screen metrics are required for Indigo input."); + } + return { x, y, width, height }; +} + export function iosurfaceInputScreenFromSnapshot( screen: IosInspectableScreen, shot: Pick, @@ -4425,8 +4442,8 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { const y = normalizeCoordinate(point.y, "y"); return enqueueControl("tap", async () => { await runWithInputFallback("tap", async () => { - const normalized = normalizeIosSimulatorPointForIndigo({ x, y }, screenMetricsForIndigoInput(deviceUdid)); - await sendIosurfaceInputCommand(deviceUdid, { type: "tap", x: normalized.x, y: normalized.y, hold: IOSURFACE_TAP_HOLD_MS }); + const pointPayload = iosurfaceInputPointPayload({ x, y }, screenMetricsForIndigoInput(deviceUdid)); + await sendIosurfaceInputCommand(deviceUdid, { type: "tap", ...pointPayload, hold: IOSURFACE_TAP_HOLD_MS }); }, () => runIdbTap(deviceUdid, x, y)); return { ok: true }; }); @@ -4453,14 +4470,16 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { if (deltaValue != null && (!Number.isFinite(deltaValue) || deltaValue <= 0)) throw new Error("delta must be a positive number."); await runWithInputFallback("drag", async () => { const metrics = screenMetricsForIndigoInput(deviceUdid); - const start = normalizeIosSimulatorPointForIndigo({ x: startX, y: startY }, metrics); - const end = normalizeIosSimulatorPointForIndigo({ x: endX, y: endY }, metrics); + const start = iosurfaceInputPointPayload({ x: startX, y: startY }, metrics); + const end = iosurfaceInputPointPayload({ x: endX, y: endY }, metrics); await sendIosurfaceInputCommand(deviceUdid, { type: "swipe", startX: start.x, startY: start.y, endX: end.x, endY: end.y, + width: start.width, + height: start.height, durationMs: durationMs ?? 180, }); }, () => runIdbSwipe(deviceUdid, { diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index 4b6581d4a..219609d94 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; import { openKvDb } from "../state/kvDb"; import { isCrsqliteAvailable } from "../state/crsqliteExtension"; -import { createSyncHostService } from "./syncHostService"; +import { createSyncHostService, syncHeartbeatMissLimitForPeerMetadata } from "./syncHostService"; import type { SyncPinStore } from "./syncPinStore"; import { encodeSyncEnvelope, parseSyncEnvelope } from "./syncProtocol"; import type { ParsedSyncEnvelope } from "./syncProtocol"; @@ -289,6 +289,15 @@ afterEach(async () => { execFileMock.mockReset(); }); +it("allows a wider heartbeat grace window for mobile peers", () => { + expect(syncHeartbeatMissLimitForPeerMetadata({ platform: "iOS", deviceType: "phone" })).toBeGreaterThan( + syncHeartbeatMissLimitForPeerMetadata({ platform: "macOS", deviceType: "desktop" }), + ); + expect(syncHeartbeatMissLimitForPeerMetadata({ platform: "unknown", deviceType: "phone" })).toBeGreaterThan( + syncHeartbeatMissLimitForPeerMetadata(null), + ); +}); + describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { it("retries tailnet discovery after a serve failure only when forced", async () => { const previousEnv = { diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index ac86d8561..0ad97bad0 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -99,6 +99,8 @@ import { resolveTailscaleCliPath } from "./resolveTailscaleCliPath"; import { createSyncRemoteCommandService } from "./syncRemoteCommandService"; const execFileAsync = promisify(execFile); const DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS = 30_000; +const DEFAULT_SYNC_HEARTBEAT_MISS_LIMIT = 2; +const MOBILE_SYNC_HEARTBEAT_MISS_LIMIT = 6; const DEFAULT_SYNC_POLL_INTERVAL_MS = 400; const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000; const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000; @@ -415,6 +417,12 @@ function toSyncPeerConnectionState(peer: PeerState, currentServerDbVersion: numb }; } +export function syncHeartbeatMissLimitForPeerMetadata(metadata: Pick | null | undefined): number { + return metadata?.platform === "iOS" || metadata?.deviceType === "phone" + ? MOBILE_SYNC_HEARTBEAT_MISS_LIMIT + : DEFAULT_SYNC_HEARTBEAT_MISS_LIMIT; +} + function parseHelloPayload(payload: unknown): SyncHelloPayload | null { const value = payload as SyncHelloPayload | null; const peer = value?.peer; @@ -1003,7 +1011,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } if (peer.awaitingHeartbeatAt) { peer.missedHeartbeatCount += 1; - if (peer.missedHeartbeatCount >= 2) { + if (peer.missedHeartbeatCount >= syncHeartbeatMissLimitForPeerMetadata(peer.metadata)) { try { peer.ws.close(4001, "Heartbeat timed out"); } catch { diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index a8121a216..c1d3fbe1a 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -1254,6 +1254,8 @@ describe("createSyncRemoteCommandService", () => { expect(agentChatService.sendMessage).toHaveBeenCalledWith({ sessionId: "sess-1", text: "hello", + }, { + awaitDispatch: true, }); expect(result).toEqual({ ok: true }); }); @@ -1399,6 +1401,8 @@ describe("createSyncRemoteCommandService", () => { { path: "a", type: "image" }, { path: "b", type: "file" }, ], + }, { + awaitDispatch: true, }); }); @@ -1465,6 +1469,8 @@ describe("createSyncRemoteCommandService", () => { reasoningEffort: "high", executionMode: "autonomous", interactionMode: "chat", + }, { + awaitDispatch: true, }); }); diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index dac36ede9..b71170c38 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -1676,7 +1676,10 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg return summarizeChatSessionForRemote(agentChatService, session); }); register("chat.send", { viewerAllowed: true, queueable: true }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").sendMessage(parseAgentChatSendArgs(payload)); + await requireService(args.agentChatService, "Agent chat service not available.").sendMessage( + parseAgentChatSendArgs(payload), + { awaitDispatch: true }, + ); return { ok: true }; }); register("chat.interrupt", { viewerAllowed: true, queueable: false }, async (payload) => { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 4581b9446..b7e6d140e 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -36,6 +36,11 @@ enum RemoteConnectionState: String { } } +enum SyncChatMessageDelivery: Equatable { + case sent + case queued +} + func unwrapSyncCommandResponse(_ raw: Any) throws -> Any { guard let response = raw as? [String: Any], let ok = response["ok"] as? Bool else { return raw @@ -412,6 +417,40 @@ private func syncIsMessageTooLongError(_ error: Error) -> Bool { return message.contains("message too long") } +func syncConnectionStateAfterTransportFailure(error: Error, fallback: RemoteConnectionState) -> RemoteConnectionState { + syncIsMessageTooLongError(error) ? .error : fallback +} + +func syncShouldPublishForegroundReconnectStarted( + allowAutoReconnect: Bool, + autoReconnectPausedByUser: Bool, + hasToken: Bool, + connectionState: RemoteConnectionState, + automaticAddresses: [String] +) -> Bool { + guard allowAutoReconnect, !autoReconnectPausedByUser, hasToken else { return false } + guard connectionState == .disconnected || connectionState == .error else { return false } + return !automaticAddresses.isEmpty +} + +func syncClientHeartbeatIntervalNanoseconds(serverIntervalMs rawValue: Any?) -> UInt64 { + let parsedMs: Double? = { + if let number = rawValue as? NSNumber { + return number.doubleValue + } + if let value = rawValue as? Double { + return value + } + if let value = rawValue as? Int { + return Double(value) + } + return nil + }() + let serverMs = min(120_000, max(5_000, parsedMs ?? 30_000)) + let clientMs = min(25_000, max(5_000, serverMs / 2)) + return UInt64(clientMs) * 1_000_000 +} + func syncShouldRoamToTailnet( currentAddress: String?, hasTailnetRoute: Bool, @@ -739,6 +778,7 @@ final class SyncService: ObservableObject { private let syncDateFormatter = ISO8601DateFormatter() private let compressionThresholdBytes = 4 * 1024 private var relayTask: Task? + private var clientHeartbeatTask: Task? private var hydrationTask: Task? private var reconnectTask: Task? private var networkPathReconnectTask: Task? @@ -1455,6 +1495,7 @@ final class SyncService: ObservableObject { deinit { databaseRevisionDebounceTask?.cancel() relayTask?.cancel() + clientHeartbeatTask?.cancel() hydrationTask?.cancel() projectSelectionTask?.cancel() reconnectTask?.cancel() @@ -1983,6 +2024,18 @@ final class SyncService: ObservableObject { return } + if let profile = loadProfile() { + let shouldPublishReconnectStart = syncShouldPublishForegroundReconnectStarted( + allowAutoReconnect: allowAutoReconnect, + autoReconnectPausedByUser: autoReconnectPausedByUser, + hasToken: tokenForProfile(profile) != nil, + connectionState: connectionState, + automaticAddresses: automaticReconnectAddresses(for: profile) + ) + if shouldPublishReconnectStart { + publishReconnectStarted(profile: profile) + } + } await reconnectIfPossible() } @@ -3440,8 +3493,13 @@ final class SyncService: ObservableObject { return response.entries } - func sendChatMessage(sessionId: String, text: String) async throws { - _ = try await sendCommand(action: "chat.send", args: ["sessionId": sessionId, "text": text]) + @discardableResult + func sendChatMessage(sessionId: String, text: String) async throws -> SyncChatMessageDelivery { + let response = try await sendCommand(action: "chat.send", args: ["sessionId": sessionId, "text": text]) + if let response = response as? [String: Any], response["queued"] as? Bool == true { + return .queued + } + return .sent } func interruptChatSession(sessionId: String) async throws { @@ -4378,6 +4436,10 @@ final class SyncService: ObservableObject { commandDescriptor(for: action) != nil } + func isRemoteActionQueueable(_ action: String) -> Bool { + commandPolicy(for: action)?.queueable == true + } + private func normalizeOpenLaneId(_ laneId: String) -> String? { let normalized = laneId.trimmingCharacters(in: .whitespacesAndNewlines) return normalized.isEmpty ? nil : normalized @@ -5283,6 +5345,10 @@ final class SyncService: ObservableObject { pendingOutboundChangeset = nil clearPendingOutboundChangesetForActiveProject() } + startClientHeartbeatTask( + intervalNanoseconds: syncClientHeartbeatIntervalNanoseconds(serverIntervalMs: payload["heartbeatIntervalMs"]), + for: connectionGeneration + ) startRelayLoop() startInitialHydrationTask(for: connectionGeneration) restoreChatEventSubscriptions() @@ -5342,7 +5408,7 @@ final class SyncService: ObservableObject { self.handleTransportFailure( failure, phase: .disconnected, - connectionState: .disconnected, + connectionState: .connecting, reconnectDelayNanoseconds: reconnectDelay ) } @@ -5371,7 +5437,7 @@ final class SyncService: ObservableObject { teardownSocket(reason: friendlyError.localizedDescription) markConnectionLoadStrained() lastError = friendlyError.localizedDescription - connectionState = syncIsMessageTooLongError(error) ? .error : .disconnected + connectionState = syncConnectionStateAfterTransportFailure(error: error, fallback: .disconnected) setDomainStatus(SyncDomain.allCases, phase: .failed, error: friendlyError.localizedDescription) failPendingRequests(with: friendlyError) if syncIsMessageTooLongError(error) { @@ -5418,7 +5484,17 @@ final class SyncService: ObservableObject { case "pairing_result": resolve(requestId: requestId, result: .success(payload)) case "changeset_batch": - let batch = try decode(payload, as: SyncChangesetBatchPayload.self) + var batchPayload = payload + if let requestId = requestId?.trimmingCharacters(in: .whitespacesAndNewlines), + !requestId.isEmpty, + var payloadObject = payload as? [String: Any] { + let payloadBatchId = (payloadObject["batchId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if payloadBatchId.isEmpty { + payloadObject["batchId"] = requestId + batchPayload = payloadObject + } + } + let batch = try decode(batchPayload, as: SyncChangesetBatchPayload.self) do { let result = try database.applyChanges(batch.changes) latestRemoteDbVersion = max(latestRemoteDbVersion, batch.toDbVersion, result.dbVersion) @@ -5441,7 +5517,8 @@ final class SyncService: ObservableObject { appliedCount: 0, error: error ) - throw error + lastError = SyncUserFacingError.message(for: error) + resolve(requestId: requestId, result: .success(payload)) } case "changeset_ack": let ack = try decode(payload, as: SyncChangesetAckPayload.self) @@ -5514,6 +5591,22 @@ final class SyncService: ObservableObject { } } + private func startClientHeartbeatTask(intervalNanoseconds: UInt64, for connectionGeneration: UInt64) { + clientHeartbeatTask?.cancel() + clientHeartbeatTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: intervalNanoseconds) + guard let self, !Task.isCancelled else { return } + guard self.isCurrentConnectionGeneration(connectionGeneration), self.canSendLiveRequests() else { return } + self.sendEnvelope(type: "heartbeat", requestId: nil, payload: [ + "kind": "ping", + "sentAt": ISO8601DateFormatter().string(from: Date()), + "dbVersion": self.database.currentDbVersion(), + ]) + } + } + } + private func startInitialHydrationTask(for connectionGeneration: UInt64) { hydrationTask?.cancel() hydrationTask = Task { @MainActor [weak self] in @@ -5697,12 +5790,11 @@ final class SyncService: ObservableObject { let sendSocket = socket guard let payloadData = try? adeJSONData(withJSONObject: payload) else { return } - let envelope: [String: Any] + var envelope: [String: Any] if payloadData.count >= compressionThresholdBytes { envelope = [ "version": 1, "type": type, - "requestId": requestId as Any, "compression": "gzip", "payloadEncoding": "base64", "payload": gzip(payloadData).base64EncodedString(), @@ -5712,12 +5804,14 @@ final class SyncService: ObservableObject { envelope = [ "version": 1, "type": type, - "requestId": requestId as Any, "compression": "none", "payloadEncoding": "json", "payload": payload, ] } + if let requestId, !requestId.isEmpty { + envelope["requestId"] = requestId + } guard let data = try? adeJSONData(withJSONObject: envelope), let text = String(data: data, encoding: .utf8) @@ -5777,6 +5871,8 @@ final class SyncService: ObservableObject { private func teardownSocket(closeCode: URLSessionWebSocketTask.CloseCode = .goingAway, reason: String? = nil) { relayTask?.cancel() relayTask = nil + clientHeartbeatTask?.cancel() + clientHeartbeatTask = nil hydrationTask?.cancel() hydrationTask = nil lanePresenceHeartbeatTask?.cancel() @@ -5812,14 +5908,15 @@ final class SyncService: ObservableObject { teardownSocket(reason: friendlyError.localizedDescription) markConnectionLoadStrained() lastError = friendlyError.localizedDescription - self.connectionState = connectionState - if phase == .failed { + let fatalTransportFailure = syncIsMessageTooLongError(error) + self.connectionState = syncConnectionStateAfterTransportFailure(error: error, fallback: connectionState) + if phase == .failed || fatalTransportFailure { setDomainStatus(SyncDomain.allCases, phase: .failed, error: friendlyError.localizedDescription) } else { setDomainStatus(SyncDomain.allCases, phase: .disconnected) } failPendingRequests(with: friendlyError) - if syncIsMessageTooLongError(error) { + if fatalTransportFailure { allowAutoReconnect = false setAutoReconnectPausedByUser(true) cancelReconnectLoop() diff --git a/apps/ios/ADE/Views/Lanes/LaneComponents.swift b/apps/ios/ADE/Views/Lanes/LaneComponents.swift index a7e50b748..3dec45cc4 100644 --- a/apps/ios/ADE/Views/Lanes/LaneComponents.swift +++ b/apps/ios/ADE/Views/Lanes/LaneComponents.swift @@ -355,6 +355,125 @@ struct LaneListRow: View, Equatable { } } +// MARK: - Inline rebase warning (rendered inside lane cards) + +enum LaneCardRebaseWarningPresentation: Equatable { + case suggestion(behindCount: Int, hasPr: Bool) + case autoRebase(state: String, message: String?) + + var icon: String { + switch self { + case .suggestion: return "arrow.triangle.2.circlepath" + case .autoRebase(let state, _): + return state == "rebaseConflict" ? "exclamationmark.triangle.fill" : "exclamationmark.arrow.triangle.2.circlepath" + } + } + + var tint: Color { + switch self { + case .suggestion: return ADEColor.warning + case .autoRebase(let state, _): + return (state == "rebaseConflict" || state == "rebaseFailed") ? ADEColor.danger : ADEColor.warning + } + } + + var title: String { + switch self { + case .suggestion: return "Rebase suggested" + case .autoRebase(let state, _): + switch state { + case "rebaseConflict": return "Auto-rebase conflict" + case "rebaseFailed": return "Auto-rebase failed" + default: return "Auto-rebase needs attention" + } + } + } + + var detail: String? { + switch self { + case .suggestion(let behindCount, let hasPr): + let noun = behindCount == 1 ? "commit" : "commits" + let base = "\(behindCount) \(noun) behind" + return hasPr ? "\(base) · PR open" : base + case .autoRebase(_, let message): + return message + } + } + + var accessibilitySummary: String { + [title, detail].compactMap { part in + guard let part, !part.isEmpty else { return nil } + return part + }.joined(separator: ". ") + } +} + +func laneCardRebaseWarningPresentation(for snapshot: LaneListSnapshot) -> LaneCardRebaseWarningPresentation? { + if let status = snapshot.autoRebaseStatus, status.state != "autoRebased" { + return .autoRebase(state: status.state, message: status.message) + } + if let suggestion = snapshot.rebaseSuggestion, suggestion.dismissedAt == nil { + return .suggestion(behindCount: suggestion.behindCount, hasPr: suggestion.hasPr) + } + return nil +} + +func laneStackCardAccessibilityLabel( + snapshot: LaneListSnapshot, + isPinned: Bool, + isOpen: Bool, + rebaseWarning: LaneCardRebaseWarningPresentation? +) -> String { + var parts = [snapshot.lane.name, snapshot.lane.branchRef] + if snapshot.lane.laneType == "primary" { parts.append("primary") } + if snapshot.lane.archivedAt != nil { parts.append("archived") } + if snapshot.runtime.bucket == "running" { parts.append("running") } + if snapshot.runtime.bucket == "awaiting-input" { parts.append("awaiting input") } + if snapshot.lane.status.dirty { parts.append("dirty") } + if isPinned { parts.append("pinned") } + if isOpen { parts.append("open") } + if snapshot.lane.status.ahead > 0 { parts.append("\(snapshot.lane.status.ahead) ahead") } + if snapshot.lane.status.behind > 0 { parts.append("\(snapshot.lane.status.behind) behind") } + if snapshot.runtime.sessionCount > 0 { parts.append("\(snapshot.runtime.sessionCount) sessions") } + if let warning = rebaseWarning { parts.append(warning.accessibilitySummary) } + return parts.joined(separator: ", ") +} + +struct LaneCardRebaseWarning: View { + let presentation: LaneCardRebaseWarningPresentation + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: presentation.icon) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(presentation.tint) + VStack(alignment: .leading, spacing: 1) { + Text(presentation.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + if let detail = presentation.detail, !detail.isEmpty { + Text(detail) + .font(.caption2) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(2) + } + } + Spacer(minLength: 0) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(presentation.tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(presentation.tint.opacity(0.28), lineWidth: 0.5) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(presentation.accessibilitySummary) + } +} + // MARK: - Stack card struct LaneStackCard: View, Equatable { @@ -451,6 +570,10 @@ struct LaneStackCard: View, Equatable { .foregroundStyle(ADEColor.textMuted) .lineLimit(1) } + + if let warning = rebaseWarning { + LaneCardRebaseWarning(presentation: warning) + } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) @@ -470,6 +593,10 @@ struct LaneStackCard: View, Equatable { snapshot.lane.laneType == "primary" } + private var rebaseWarning: LaneCardRebaseWarningPresentation? { + laneCardRebaseWarningPresentation(for: snapshot) + } + private var cardBackgroundTint: Color { isPrimary ? ADEColor.accent : ADEColor.surfaceBackground } @@ -492,17 +619,11 @@ struct LaneStackCard: View, Equatable { } private var stackCardAccessibilityLabel: String { - var parts = [snapshot.lane.name, snapshot.lane.branchRef] - if snapshot.lane.laneType == "primary" { parts.append("primary") } - if snapshot.lane.archivedAt != nil { parts.append("archived") } - if snapshot.runtime.bucket == "running" { parts.append("running") } - if snapshot.runtime.bucket == "awaiting-input" { parts.append("awaiting input") } - if snapshot.lane.status.dirty { parts.append("dirty") } - if isPinned { parts.append("pinned") } - if isOpen { parts.append("open") } - if snapshot.lane.status.ahead > 0 { parts.append("\(snapshot.lane.status.ahead) ahead") } - if snapshot.lane.status.behind > 0 { parts.append("\(snapshot.lane.status.behind) behind") } - if snapshot.runtime.sessionCount > 0 { parts.append("\(snapshot.runtime.sessionCount) sessions") } - return parts.joined(separator: ", ") + laneStackCardAccessibilityLabel( + snapshot: snapshot, + isPinned: isPinned, + isOpen: isOpen, + rebaseWarning: rebaseWarning + ) } } diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift b/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift index 502cbfeb5..c27c3cc27 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift @@ -139,11 +139,11 @@ extension LaneDetailScreen { LaneDetailRebaseBanner( behindCount: suggestion.behindCount, parentLabel: detail.lane.baseRef, + hasPr: suggestion.hasPr, canRunLiveActions: canRunLiveActions, - onRebase: { + onViewRebase: { requestGitConfirmation(.rebaseLane) }, - onDefer: handleRebaseSuggestionDefer, onDismiss: handleRebaseSuggestionDismiss ) } diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailRebaseBanner.swift b/apps/ios/ADE/Views/Lanes/LaneDetailRebaseBanner.swift index c3415e30d..7459c261a 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailRebaseBanner.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailRebaseBanner.swift @@ -3,60 +3,60 @@ import SwiftUI struct LaneDetailRebaseBanner: View { let behindCount: Int let parentLabel: String? + let hasPr: Bool let canRunLiveActions: Bool - let onRebase: () -> Void - let onDefer: () -> Void + let onViewRebase: () -> Void let onDismiss: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 10) { - Image(systemName: "exclamationmark.arrow.triangle.2.circlepath") - .font(.system(size: 15, weight: .semibold)) + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 13, weight: .semibold)) .foregroundStyle(ADEColor.warning) - .frame(width: 28, height: 28) - .background(ADEColor.warning.opacity(0.16), in: Circle()) + Text("REBASE SUGGESTED") + .font(.caption.weight(.semibold)) + .tracking(0.6) + .foregroundStyle(ADEColor.textMuted) + Spacer(minLength: 4) + LaneTypeBadge(text: "1 LANE", tint: ADEColor.warning) + } - VStack(alignment: .leading, spacing: 2) { - Text("Rebase suggested") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text(headline) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + if hasPr { + LaneTypeBadge(text: "PR", tint: ADEColor.accent) + } + LaneTypeBadge(text: "\(behindCount) BEHIND", tint: ADEColor.warning) } - Spacer(minLength: 4) + Text(bodyCopy) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) } HStack(spacing: 8) { - Button(action: onRebase) { - HStack(spacing: 6) { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 12, weight: .semibold)) - Text("Rebase") - .font(.subheadline.weight(.semibold)) - } - .foregroundStyle(ADEColor.textPrimary) - .padding(EdgeInsets(top: 9, leading: 14, bottom: 9, trailing: 14)) - .background(ADEColor.warning.opacity(0.24), in: Capsule()) - .overlay(Capsule().stroke(ADEColor.warning.opacity(0.5), lineWidth: 0.6)) + Button(action: onViewRebase) { + Text("View in Rebase/Merge tab") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .padding(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + .background(ADEColor.warning.opacity(0.24), in: Capsule()) + .overlay(Capsule().stroke(ADEColor.warning.opacity(0.5), lineWidth: 0.6)) } .buttonStyle(.plain) .disabled(!canRunLiveActions) .opacity(canRunLiveActions ? 1.0 : 0.55) - Button("Defer", action: onDefer) - .buttonStyle(.plain) - .font(.caption.weight(.medium)) - .foregroundStyle(ADEColor.textSecondary) - .padding(EdgeInsets(top: 8, leading: 10, bottom: 8, trailing: 10)) - - Button("Dismiss", action: onDismiss) - .buttonStyle(.plain) - .font(.caption.weight(.medium)) - .foregroundStyle(ADEColor.textMuted) - .padding(EdgeInsets(top: 8, leading: 10, bottom: 8, trailing: 10)) + Button(action: onDismiss) { + Text("Dismiss") + .font(.caption.weight(.medium)) + .foregroundStyle(ADEColor.textSecondary) + .padding(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + .overlay(Capsule().stroke(ADEColor.border.opacity(0.4), lineWidth: 0.6)) + } + .buttonStyle(.plain) Spacer(minLength: 0) } @@ -69,14 +69,29 @@ struct LaneDetailRebaseBanner: View { .stroke(ADEColor.warning.opacity(0.28), lineWidth: 0.8) ) .accessibilityElement(children: .combine) - .accessibilityLabel("Rebase suggested. \(headline)") + .accessibilityLabel(accessibilityLabel) } - private var headline: String { - let noun = behindCount == 1 ? "commit" : "commits" - if let parentLabel, !parentLabel.isEmpty { - return "Behind \(parentLabel) by \(behindCount) \(noun)" - } - return "Behind parent by \(behindCount) \(noun)" + private var bodyCopy: String { + let base = parentLabel.flatMap { $0.isEmpty ? nil : $0 } ?? "parent branch" + return "Rebase this lane onto \(base) to pick up new commits." + } + + private var accessibilityLabel: String { + laneDetailRebaseBannerAccessibilityLabel(behindCount: behindCount, parentLabel: parentLabel, hasPr: hasPr) + } +} + +func laneDetailRebaseBannerAccessibilityLabel(behindCount: Int, parentLabel: String?, hasPr: Bool) -> String { + let noun = behindCount == 1 ? "commit" : "commits" + let base = parentLabel.flatMap { $0.isEmpty ? nil : $0 } ?? "parent branch" + var parts = [ + "Rebase suggested", + "\(behindCount) \(noun) behind", + ] + if hasPr { + parts.append("PR open") } + parts.append("Rebase this lane onto \(base) to pick up new commits.") + return parts.joined(separator: ". ") } diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift index e7b5560d6..807e1d067 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift @@ -306,20 +306,6 @@ struct LaneDetailScreen: View { ) } - @MainActor - func handleRebaseSuggestionDefer() { - Task { - do { - try await syncService.deferRebaseSuggestion(laneId: laneId) - rebaseSuggestionDismissed = true - await onRefreshRoot() - } catch { - ADEHaptics.error() - errorMessage = error.localizedDescription - } - } - } - @MainActor func handleRebaseSuggestionDismiss() { Task { diff --git a/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift b/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift index 1ba7db569..a417dcf91 100644 --- a/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift +++ b/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift @@ -12,22 +12,6 @@ extension LanesTabView { ) } - var visibleSuggestions: [LaneListSnapshot] { - filteredSnapshots.filter { $0.rebaseSuggestion != nil } - } - - var visibleAutoRebaseAttention: [LaneListSnapshot] { - filteredSnapshots.filter { snapshot in - guard snapshot.rebaseSuggestion == nil else { return false } - guard let status = snapshot.autoRebaseStatus else { return false } - return status.state != "autoRebased" - } - } - - var visibleAttentionLaneIds: Set { - Set((visibleSuggestions + visibleAutoRebaseAttention).map(\.lane.id)) - } - var normalVisibleSnapshots: [LaneListSnapshot] { filteredSnapshots } @@ -118,134 +102,6 @@ extension LanesTabView { .adeGlassCard(cornerRadius: 14, padding: 12) } - @ViewBuilder - var attentionSection: some View { - VStack(alignment: .leading, spacing: 10) { - Text("NEEDS REVIEW") - .font(.caption.weight(.semibold)) - .tracking(0.6) - .foregroundStyle(ADEColor.textMuted) - .padding(.horizontal, 2) - - ForEach(visibleSuggestions) { snapshot in - HStack(spacing: 12) { - Image(systemName: "arrow.triangle.2.circlepath") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.warning) - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(snapshot.lane.name) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(0) - Text(snapshot.lane.branchRef) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - .truncationMode(.middle) - .layoutPriority(1) - } - Text("Behind parent by \(snapshot.rebaseSuggestion?.behindCount ?? 0) commit(s)") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - Spacer(minLength: 8) - Button("Review") { - detailSheetTarget = LaneDetailSheetTarget( - laneId: snapshot.lane.id, - snapshot: snapshot, - initialSection: .git - ) - } - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.accent) - Menu { - Button("Defer") { - Task { - do { - try await syncService.deferRebaseSuggestion(laneId: snapshot.lane.id) - await reload(refreshRemote: true) - } catch { - ADEHaptics.error() - errorMessage = error.localizedDescription - } - } - } - .disabled(!canRunLiveActions) - Button("Dismiss") { - Task { - do { - try await syncService.dismissRebaseSuggestion(laneId: snapshot.lane.id) - await reload(refreshRemote: true) - } catch { - ADEHaptics.error() - errorMessage = error.localizedDescription - } - } - } - .disabled(!canRunLiveActions) - } label: { - Image(systemName: "ellipsis.circle") - .font(.caption) - .foregroundStyle(ADEColor.textMuted) - } - } - .padding(12) - .background(ADEColor.warning.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(ADEColor.warning.opacity(0.2), lineWidth: 0.5) - ) - } - - ForEach(visibleAutoRebaseAttention) { snapshot in - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(snapshot.autoRebaseStatus?.state == "rebaseConflict" ? ADEColor.danger : ADEColor.warning) - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(snapshot.lane.name) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(0) - Text(snapshot.lane.branchRef) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - .truncationMode(.middle) - .layoutPriority(1) - } - Text(snapshot.autoRebaseStatus?.message ?? "Manual follow-up required") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(2) - } - Spacer(minLength: 8) - Button("Open") { - detailSheetTarget = LaneDetailSheetTarget( - laneId: snapshot.lane.id, - snapshot: snapshot, - initialSection: .git - ) - } - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.accent) - } - .padding(12) - .background(ADEColor.danger.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(ADEColor.danger.opacity(0.15), lineWidth: 0.5) - ) - } - } - } - var stackOrderedSnapshots: [LaneListSnapshot] { laneStackGraphOrder(filteredSnapshots) } diff --git a/apps/ios/ADE/Views/LanesTabView.swift b/apps/ios/ADE/Views/LanesTabView.swift index 2972aa9b8..b18f193e1 100644 --- a/apps/ios/ADE/Views/LanesTabView.swift +++ b/apps/ios/ADE/Views/LanesTabView.swift @@ -129,10 +129,6 @@ struct LanesTabView: View { openLanesTray .transition(.move(edge: .top).combined(with: .opacity)) } - if !visibleSuggestions.isEmpty || !visibleAutoRebaseAttention.isEmpty { - attentionSection - .transition(.move(edge: .top).combined(with: .opacity)) - } laneList } .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index edc5e8b31..f8578e1e5 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -29,6 +29,9 @@ struct WorkChatSessionView: View { @State var timelineRebuildTask: Task? @State var timelineRebuildGeneration = 0 let isLive: Bool + let canComposeMessages: Bool + let canSendMessages: Bool + let sendWillQueue: Bool let transitionNamespace: Namespace.ID? let onOpenLane: (() -> Void)? let onSend: @MainActor (String) async -> Bool @@ -124,23 +127,24 @@ struct WorkChatSessionView: View { // Typing stays available so users can draft while disconnected or while // a turn is running. Only Send is gated via `canSend`; the feedback line // below the composer explains why send is disabled. - isLive + canComposeMessages } var canSend: Bool { - // Match desktop: a chat accepts messages as long as the app is live. A - // completed turn (`sessionStatus == "ended"`) just means the previous - // round finished — the user's next message starts a new turn. Only - // client-side archive and disconnected state should gate Send. - isLive && !sending + // Existing chats accept messages while live, and can still accept them + // during reconnects when desktop advertised chat.send as queueable. + canSendMessages && !sending } var composerFeedback: String? { - if !isLive { - return "Reconnect to send messages." - } if sending { - return "Sending message to host..." + return sendWillQueue ? "Queueing message for desktop..." : "Sending message to host..." + } + if sendWillQueue { + return "Desktop is reconnecting. Send will queue until it is back." + } + if !canSendMessages { + return "Reconnect to send messages." } if sessionStatus == "awaiting-input" { return "Answer the waiting prompt above, or send extra context." @@ -224,7 +228,7 @@ struct WorkChatSessionView: View { var streamingStatusSection: some View { WorkActivityIndicator( transcript: transcript, - isStreaming: sessionStatus == "active" && isLive + isStreaming: (sessionStatus == "active" && isLive) || timelineSnapshot.transcriptIndicatesActiveTurn ) } diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index ebb1cb98c..9010ef42a 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -41,6 +41,7 @@ struct WorkLocalEchoMessage: Identifiable, Equatable { let id = UUID().uuidString let text: String let timestamp: String + var deliveryState: String? = nil } struct WorkPendingApprovalModel: Identifiable, Equatable { @@ -272,6 +273,7 @@ struct WorkChatTimelineSnapshot: Equatable { var commandCards: [WorkCommandCardModel] var fileChangeCards: [WorkFileChangeCardModel] var subagentSnapshots: [WorkSubagentSnapshot] + var transcriptIndicatesActiveTurn: Bool var timeline: [WorkTimelineEntry] static let empty = WorkChatTimelineSnapshot( @@ -282,6 +284,7 @@ struct WorkChatTimelineSnapshot: Equatable { commandCards: [], fileChangeCards: [], subagentSnapshots: [], + transcriptIndicatesActiveTurn: false, timeline: [] ) } diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index c370efe22..41d87346f 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -724,6 +724,25 @@ func buildWorkToolCards( argsText: existing?.argsText, resultText: nonEmpty(resultText) ) + case .webSearch(let query, let action, let status, let itemId, _): + // Web searches are tool calls — render them as tool cards so they're + // absorbed into the same `Tool calls` cluster as Read/Bash/etc. on the + // timeline, mirroring the desktop `work_log_group` behavior. Without + // this they fall through to `eventCard(for:)` and break tool clusters + // by appearing as standalone rows outside the group. + let existing = cards[itemId] + if existing == nil { + orderedIds.append(itemId) + } + cards[itemId] = WorkToolCardModel( + id: itemId, + toolName: "web_search", + status: status, + startedAt: existing?.startedAt ?? envelope.timestamp, + completedAt: status == .running ? nil : envelope.timestamp, + argsText: nonEmpty(query), + resultText: action.flatMap(nonEmpty) ?? existing?.resultText + ) default: continue } diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index e7145ee35..bcbe324ce 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -434,6 +434,9 @@ private enum WorkPreviewData { sending: .constant(false), errorMessage: .constant(nil), isLive: true, + canComposeMessages: true, + canSendMessages: true, + sendWillQueue: false, transitionNamespace: nil, onOpenLane: {}, onSend: { _ in true }, diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index 524e212ba..d9aa22ba6 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -8,16 +8,27 @@ extension WorkSessionDestinationView { guard !sending else { return false } let text = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return false } + guard canSendChatMessages else { return false } - let echo = WorkLocalEchoMessage(text: text, timestamp: workDateFormatter.string(from: Date())) + let echo = WorkLocalEchoMessage( + text: text, + timestamp: workDateFormatter.string(from: Date()), + deliveryState: sendWillQueueChatMessage ? "queued" : "sending" + ) let echoId = echo.id localEchoMessages.append(echo) sending = true defer { sending = false } do { - try await syncService.sendChatMessage(sessionId: sessionId, text: text) - await refreshChatStateAfterAction(forceRemote: true) - reconcileLocalEchoMessages() + let delivery = try await syncService.sendChatMessage(sessionId: sessionId, text: text) + switch delivery { + case .queued: + updateLocalEchoDeliveryState(echoId: echoId, deliveryState: "queued") + case .sent: + updateLocalEchoDeliveryState(echoId: echoId, deliveryState: nil) + await refreshChatStateAfterAction(forceRemote: true) + reconcileLocalEchoMessages() + } errorMessage = nil return true } catch { diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index a135fa82c..6ab40b626 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -7,6 +7,22 @@ enum WorkSessionNavigationChrome { case embedded } +func workChatCanSendMessages( + isLive: Bool, + hostReachable: Bool, + chatSendQueueable: Bool +) -> Bool { + isLive && (hostReachable || chatSendQueueable) +} + +func workChatSendWillQueueMessage( + isLive: Bool, + hostReachable: Bool, + chatSendQueueable: Bool +) -> Bool { + isLive && !hostReachable && chatSendQueueable +} + struct WorkSessionDestinationView: View { @EnvironmentObject var syncService: SyncService @@ -62,6 +78,26 @@ struct WorkSessionDestinationView: View { isLive && hostReachable } + var canComposeChatMessages: Bool { + session != nil || initialSession != nil + } + + var canSendChatMessages: Bool { + workChatCanSendMessages( + isLive: isLive, + hostReachable: hostReachable, + chatSendQueueable: syncService.isRemoteActionQueueable("chat.send") + ) + } + + var sendWillQueueChatMessage: Bool { + workChatSendWillQueueMessage( + isLive: isLive, + hostReachable: hostReachable, + chatSendQueueable: syncService.isRemoteActionQueueable("chat.send") + ) + } + /// Trailing nav-bar control scoped to the session's lane. The visible branch /// icon keeps it distinct from in-transcript overflow menus. @ViewBuilder @@ -162,6 +198,9 @@ struct WorkSessionDestinationView: View { sending: $sending, errorMessage: $errorMessage, isLive: isLiveAndReachable, + canComposeMessages: canComposeChatMessages, + canSendMessages: canSendChatMessages, + sendWillQueue: sendWillQueueChatMessage, transitionNamespace: transitionNamespace, onOpenLane: showsLaneActions ? openSessionLane : nil, onSend: sendMessage, @@ -386,14 +425,25 @@ struct WorkSessionDestinationView: View { }) { echo = existingEcho } else { - let nextEcho = WorkLocalEchoMessage(text: prompt, timestamp: workDateFormatter.string(from: Date())) + let nextEcho = WorkLocalEchoMessage( + text: prompt, + timestamp: workDateFormatter.string(from: Date()), + deliveryState: sendWillQueueChatMessage ? "queued" : "sending" + ) localEchoMessages.append(nextEcho) echo = nextEcho } + updateLocalEchoDeliveryState(echoId: echo.id, deliveryState: sendWillQueueChatMessage ? "queued" : "sending") sending = true do { - try await syncService.sendChatMessage(sessionId: sessionId, text: prompt) - await refreshChatStateAfterAction(forceRemote: true) + let delivery = try await syncService.sendChatMessage(sessionId: sessionId, text: prompt) + switch delivery { + case .queued: + updateLocalEchoDeliveryState(echoId: echo.id, deliveryState: "queued") + case .sent: + updateLocalEchoDeliveryState(echoId: echo.id, deliveryState: nil) + await refreshChatStateAfterAction(forceRemote: true) + } errorMessage = nil } catch { ADEHaptics.error() @@ -410,7 +460,11 @@ struct WorkSessionDestinationView: View { let promptKey = "\(sessionId)|\(prompt)" guard stagedOpeningPromptKey != promptKey else { return } stagedOpeningPromptKey = promptKey - localEchoMessages.append(WorkLocalEchoMessage(text: prompt, timestamp: workDateFormatter.string(from: Date()))) + localEchoMessages.append(WorkLocalEchoMessage( + text: prompt, + timestamp: workDateFormatter.string(from: Date()), + deliveryState: sendWillQueueChatMessage ? "queued" : "sending" + )) } @MainActor @@ -441,6 +495,12 @@ struct WorkSessionDestinationView: View { } } + @MainActor + func updateLocalEchoDeliveryState(echoId: String, deliveryState: String?) { + guard let index = localEchoMessages.firstIndex(where: { $0.id == echoId }) else { return } + localEchoMessages[index].deliveryState = deliveryState + } + @MainActor func pollIfNeeded() async { guard isLiveAndReachable, diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 524c70493..2b851f054 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -17,6 +17,7 @@ func buildWorkChatTimelineSnapshot( let commandCards = buildWorkCommandCards(from: transcript) let fileChangeCards = buildWorkFileChangeCards(from: transcript) let subagentSnapshots = buildWorkSubagentSnapshots(from: transcript) + let transcriptIndicatesActiveTurn = workTranscriptIndicatesActiveTurn(transcript) let timeline = buildWorkTimeline( transcript: transcript, fallbackEntries: fallbackEntries, @@ -37,10 +38,44 @@ func buildWorkChatTimelineSnapshot( commandCards: commandCards, fileChangeCards: fileChangeCards, subagentSnapshots: subagentSnapshots, + transcriptIndicatesActiveTurn: transcriptIndicatesActiveTurn, timeline: timeline ) } +func workTranscriptIndicatesActiveTurn(_ transcript: [WorkChatEnvelope]) -> Bool { + var activeTurnIds = Set() + var bootstrapStartOpen = false + for envelope in transcript { + switch envelope.event { + case .status(let turnStatus, _, let turnId): + switch turnStatus.lowercased() { + case "started", "active", "running", "inprogress", "in_progress", "in-progress": + if let turnId, !turnId.isEmpty { + activeTurnIds.insert(turnId) + bootstrapStartOpen = false + } else { + bootstrapStartOpen = true + } + case "completed", "failed", "interrupted", "cancelled", "canceled", "ended": + if let turnId, !turnId.isEmpty { + activeTurnIds.remove(turnId) + } else { + bootstrapStartOpen = false + } + default: + break + } + case .done(_, _, _, let turnId, _, _): + activeTurnIds.remove(turnId) + bootstrapStartOpen = false + default: + break + } + } + return bootstrapStartOpen || !activeTurnIds.isEmpty +} + /// Collapse `subagent_*` events into one snapshot per taskId. Preserves host /// order via a first-seen index so completed subagents don't jump around when /// a later progress event lands. @@ -220,7 +255,15 @@ func buildWorkTimeline( }) entries.append(contentsOf: visibleLocalEchoMessages.enumerated().map { index, echo in - let message = WorkChatMessage(id: echo.id, role: "user", markdown: echo.text, timestamp: echo.timestamp, turnId: nil, itemId: nil) + let message = WorkChatMessage( + id: echo.id, + role: "user", + markdown: echo.text, + timestamp: echo.timestamp, + turnId: nil, + itemId: nil, + deliveryState: echo.deliveryState + ) return WorkTimelineEntry(id: "echo-\(echo.id)", timestamp: echo.timestamp, rank: 3_000 + index, payload: .message(message)) }) @@ -793,18 +836,12 @@ private func eventCard(for envelope: WorkChatEnvelope) -> WorkEventCardModel? { bullets: [], metadata: [] ) - case .webSearch(let query, let action, let status, _, _): - return WorkEventCardModel( - id: envelope.id, - kind: "webSearch", - title: "Web search", - icon: "globe", - tint: status == .failed ? .danger : status == .completed ? .success : .warning, - timestamp: envelope.timestamp, - body: query, - bullets: action.map { [$0] } ?? [], - metadata: [status.rawValue.capitalized] - ) + case .webSearch: + // Web searches now surface as `WorkToolCardModel` entries built in + // `buildWorkToolCards`, so they cluster into the `Tool calls` panel + // alongside Read/Bash/etc. instead of leaking out as standalone event + // cards that break the surrounding tool group. + return nil case .planText(let text, _): return WorkEventCardModel( id: envelope.id, diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index be6831ba7..563364bf1 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -451,6 +451,87 @@ final class ADETests: XCTestCase { ) } + @MainActor + func testSyncAutomaticReconnectWaitsForLiveLanDiscoveryWithoutTailnet() { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + let profile = HostConnectionProfile( + hostIdentity: "host-1", + hostName: "Mac Studio", + port: 8787, + authKind: "paired", + pairedDeviceId: "phone-1", + lastRemoteDbVersion: 0, + lastHostDeviceId: "host-1", + lastSuccessfulAddress: "192.168.1.8", + savedAddressCandidates: ["192.168.1.8"], + discoveredLanAddresses: ["192.168.1.8"], + tailscaleAddress: nil + ) + + XCTAssertEqual(service.automaticReconnectAddressesForTesting(profile), []) + } + + func testSyncForegroundReconnectStartRequiresAutomaticRoute() { + XCTAssertTrue( + syncShouldPublishForegroundReconnectStarted( + allowAutoReconnect: true, + autoReconnectPausedByUser: false, + hasToken: true, + connectionState: .disconnected, + automaticAddresses: ["100.75.20.63"] + ) + ) + XCTAssertFalse( + syncShouldPublishForegroundReconnectStarted( + allowAutoReconnect: true, + autoReconnectPausedByUser: false, + hasToken: true, + connectionState: .disconnected, + automaticAddresses: [] + ) + ) + XCTAssertFalse( + syncShouldPublishForegroundReconnectStarted( + allowAutoReconnect: true, + autoReconnectPausedByUser: false, + hasToken: true, + connectionState: .connected, + automaticAddresses: ["100.75.20.63"] + ) + ) + } + + func testSyncMessageTooLongTransportFailureForcesErrorState() { + let fatalError = NSError( + domain: "ADE", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "WebSocket message too long."] + ) + let transientError = NSError( + domain: "ADE", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The host stopped responding. Reconnecting now."] + ) + + XCTAssertEqual(syncConnectionStateAfterTransportFailure(error: fatalError, fallback: .connecting), .error) + XCTAssertEqual(syncConnectionStateAfterTransportFailure(error: transientError, fallback: .connecting), .connecting) + } + + func testSyncClientHeartbeatUsesHalfServerIntervalWithBounds() { + XCTAssertEqual( + syncClientHeartbeatIntervalNanoseconds(serverIntervalMs: 30_000), + 15_000_000_000 + ) + XCTAssertEqual( + syncClientHeartbeatIntervalNanoseconds(serverIntervalMs: 4_000), + 5_000_000_000 + ) + XCTAssertEqual( + syncClientHeartbeatIntervalNanoseconds(serverIntervalMs: 120_000), + 25_000_000_000 + ) + } + @MainActor func testSyncPlaintextWebSocketAllowlistIncludesTrustedTailscaleRoutesOnly() { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) @@ -3007,6 +3088,40 @@ final class ADETests: XCTestCase { } } + @MainActor + func testRemoteCommandPolicyQueuesChatSendWhenOffline() async throws { + let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" + let pendingOperationsKey = "ade.sync.pendingOperations" + UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) + UserDefaults.standard.removeObject(forKey: pendingOperationsKey) + defer { + UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) + UserDefaults.standard.removeObject(forKey: pendingOperationsKey) + } + + let descriptors = [ + SyncRemoteCommandDescriptor( + action: "chat.send", + policy: SyncRemoteCommandPolicy(viewerAllowed: true, requiresApproval: nil, localOnly: nil, queueable: true) + ), + ] + UserDefaults.standard.set(try JSONEncoder().encode(descriptors), forKey: remoteCommandDescriptorsKey) + + let database = makeDatabase(baseURL: makeTemporaryDirectory()) + defer { database.close() } + let service = SyncService(database: database) + service.disconnect() + + let delivery = try await service.sendChatMessage(sessionId: "chat-1", text: "keep this draft moving") + + XCTAssertEqual(delivery, .queued) + let queued = service.pendingOperationsForTesting() + XCTAssertEqual(service.pendingOperationCount, 1) + XCTAssertEqual(queued.count, 1) + XCTAssertEqual(queued.first?.kind, "command") + XCTAssertEqual(queued.first?.action, "chat.send") + } + @MainActor func testFireAndForgetRemoteCommandQueuesWithStableCommandIdWhenOffline() async throws { let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" @@ -3295,6 +3410,91 @@ final class ADETests: XCTestCase { ) } + func testLaneCardRebaseWarningPrefersAutoRebaseStatusOverSuggestion() { + var snapshot = makeLaneListSnapshot( + id: "lane-rebase", + name: "iOS simulator", + laneType: "worktree", + baseRef: "main", + branchRef: "ade/ios-sim", + worktreePath: "/project/.ade/worktrees/ios-sim", + description: nil, + status: LaneStatus(dirty: false, ahead: 0, behind: 2, remoteBehind: 0, rebaseInProgress: false), + runtime: LaneRuntimeSummary(bucket: "ended", runningCount: 0, awaitingInputCount: 0, endedCount: 1, sessionCount: 1), + createdAt: "2026-03-20T00:00:00.000Z", + archivedAt: nil + ) + snapshot.rebaseSuggestion = RebaseSuggestion( + laneId: "lane-rebase", + parentLaneId: "lane-main", + parentHeadSha: "parent-sha", + behindCount: 2, + lastSuggestedAt: "2026-03-20T00:01:00.000Z", + deferredUntil: nil, + dismissedAt: nil, + hasPr: true + ) + snapshot.autoRebaseStatus = AutoRebaseLaneStatus( + laneId: "lane-rebase", + parentLaneId: "lane-main", + parentHeadSha: "parent-sha", + state: "rebaseConflict", + updatedAt: "2026-03-20T00:02:00.000Z", + conflictCount: 3, + message: "Resolve conflicts in the Rebase/Merge tab." + ) + + let warning = laneCardRebaseWarningPresentation(for: snapshot) + + XCTAssertEqual(warning, .autoRebase(state: "rebaseConflict", message: "Resolve conflicts in the Rebase/Merge tab.")) + XCTAssertEqual(warning?.accessibilitySummary, "Auto-rebase conflict. Resolve conflicts in the Rebase/Merge tab.") + } + + func testLaneStackCardAccessibilityLabelIncludesRebaseWarningSummary() { + var snapshot = makeLaneListSnapshot( + id: "lane-warning", + name: "Sync polish", + laneType: "worktree", + baseRef: "main", + branchRef: "ade/sync-polish", + worktreePath: "/project/.ade/worktrees/sync-polish", + description: nil, + status: LaneStatus(dirty: false, ahead: 1, behind: 4, remoteBehind: 0, rebaseInProgress: false), + runtime: LaneRuntimeSummary(bucket: "ended", runningCount: 0, awaitingInputCount: 0, endedCount: 1, sessionCount: 1), + createdAt: "2026-03-20T00:00:00.000Z", + archivedAt: nil + ) + snapshot.rebaseSuggestion = RebaseSuggestion( + laneId: "lane-warning", + parentLaneId: "lane-main", + parentHeadSha: "parent-sha", + behindCount: 4, + lastSuggestedAt: "2026-03-20T00:01:00.000Z", + deferredUntil: nil, + dismissedAt: nil, + hasPr: true + ) + let warning = laneCardRebaseWarningPresentation(for: snapshot) + + let label = laneStackCardAccessibilityLabel( + snapshot: snapshot, + isPinned: false, + isOpen: true, + rebaseWarning: warning + ) + + XCTAssertTrue(label.contains("Rebase suggested")) + XCTAssertTrue(label.contains("4 commits behind")) + XCTAssertTrue(label.contains("PR open")) + } + + func testLaneDetailRebaseBannerAccessibilityLabelIncludesVisibleBadges() { + XCTAssertEqual( + laneDetailRebaseBannerAccessibilityLabel(behindCount: 1, parentLabel: "main", hasPr: true), + "Rebase suggested. 1 commit behind. PR open. Rebase this lane onto main to pick up new commits." + ) + } + func testLaneRootEmptyStateGuidesUnpairedUsersWhenNoCacheExists() { let emptyState = laneRootEmptyState( connectionState: .disconnected, @@ -3426,6 +3626,44 @@ final class ADETests: XCTestCase { ) } + func testWorkChatQueuedSendRequiresLiveSession() { + XCTAssertTrue( + workChatCanSendMessages( + isLive: true, + hostReachable: false, + chatSendQueueable: true + ) + ) + XCTAssertTrue( + workChatSendWillQueueMessage( + isLive: true, + hostReachable: false, + chatSendQueueable: true + ) + ) + XCTAssertFalse( + workChatCanSendMessages( + isLive: false, + hostReachable: false, + chatSendQueueable: true + ) + ) + XCTAssertFalse( + workChatSendWillQueueMessage( + isLive: false, + hostReachable: false, + chatSendQueueable: true + ) + ) + XCTAssertTrue( + workChatCanSendMessages( + isLive: true, + hostReachable: true, + chatSendQueueable: false + ) + ) + } + func testBuildPullRequestTimelineOrdersStateReviewsAndComments() { let pr = PullRequestListItem( id: "pr-9", @@ -5371,6 +5609,32 @@ final class ADETests: XCTestCase { XCTAssertEqual(userMessages, [prompt, "Still waiting for host acknowledgement"]) } + func testWorkTimelineCarriesQueuedLocalEchoDeliveryState() { + let timeline = buildWorkTimeline( + transcript: [], + fallbackEntries: [], + toolCards: [], + commandCards: [], + fileChangeCards: [], + eventCards: [], + artifacts: [], + localEchoMessages: [ + WorkLocalEchoMessage( + text: "Send once the desktop is back", + timestamp: "2026-03-25T00:00:03.000Z", + deliveryState: "queued" + ), + ] + ) + let message = timeline.compactMap { entry -> WorkChatMessage? in + guard case .message(let message) = entry.payload else { return nil } + return message + }.first + + XCTAssertEqual(message?.markdown, "Send once the desktop is back") + XCTAssertEqual(message?.deliveryState, "queued") + } + func testWorkTurnSeparatorsUsePerTurnModelAfterModelSwitch() { let transcript = [ WorkChatEnvelope( @@ -5487,6 +5751,37 @@ final class ADETests: XCTestCase { XCTAssertEqual(cards.first?.body, "Tool call failed") } + func testWorkTimelineSnapshotCachesTranscriptActiveTurnState() { + let started = WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:00.000Z", + sequence: 0, + event: .status(turnStatus: "started", message: nil, turnId: "turn-1") + ) + let completed = WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:01.000Z", + sequence: 1, + event: .status(turnStatus: "completed", message: nil, turnId: "turn-1") + ) + + let activeSnapshot = buildWorkChatTimelineSnapshot( + transcript: [started], + fallbackEntries: [], + artifacts: [], + localEchoMessages: [] + ) + let completedSnapshot = buildWorkChatTimelineSnapshot( + transcript: [started, completed], + fallbackEntries: [], + artifacts: [], + localEchoMessages: [] + ) + + XCTAssertTrue(activeSnapshot.transcriptIndicatesActiveTurn) + XCTAssertFalse(completedSnapshot.transcriptIndicatesActiveTurn) + } + func testWorkSessionEmptyStateMessagingExplainsSearchAndArchiveFallbacks() { XCTAssertEqual( workSessionEmptyStateTitle(status: .all, searchText: "deploy", hasFilters: true), diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index 18786a636..def6c8698 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -161,6 +161,13 @@ pair. Turns use the Codex-native `effort` key (`turn/start({ threadId, input, effort? })`) instead of the lifecycle `reasoningEffort` name. +Codex plan mode uses the native app-server planning flow. ADE passes its +runtime guidance as an ordinary system-context input item and keeps +`collaborationMode.settings.developer_instructions` null, then turns +completed Codex `plan` items (including `` wrappers) into +ADE plan-approval requests. Accepting that request moves the session to +edit/default mode and starts the implementation turn. + Default Codex chats map to the "Default permissions" preset (`workspace-write` + `on-request`). The older implicit fallback that mapped CLI `edit` mode to `untrusted` was removed so the first-turn diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md index 1ce138c6d..079db6ada 100644 --- a/docs/features/chat/transcript-and-turns.md +++ b/docs/features/chat/transcript-and-turns.md @@ -88,7 +88,7 @@ Two helpers summarise a parsed stream: | `turn_diff_summary` | Git-level before/after SHA + per-file stats for a completed turn. | | `delegation_state` | Mission orchestrator delegation contract updates. | | `context_compact` | Emitted before the provider compacts context (manual or auto). | -| `web_search` | Web-search tool lifecycle. | +| `web_search` | Web-search tool lifecycle; renderers group these with other tool calls instead of showing them as standalone event cards. | | `auto_approval_review` | When auto-approval policy kicks in, this event carries the review text. | | `prompt_suggestion` | Suggested follow-up prompts for the user. | @@ -233,4 +233,4 @@ duplicate rows. disassociated from a lane, `turn_diff_summary` will not emit. Do not rely on it for non-lane surfaces. - \ No newline at end of file + diff --git a/docs/features/ios-simulator/README.md b/docs/features/ios-simulator/README.md index 6efa7c651..fa0a4bbef 100644 --- a/docs/features/ios-simulator/README.md +++ b/docs/features/ios-simulator/README.md @@ -121,8 +121,11 @@ called from a non-darwin host. caller. Requires macOS, full Xcode 17.x or 26.x, `swiftc`, and `clang`; CLT-only machines fall back cleanly. Packaged ADE builds ship the helper sources as app resources and keep generated helper binaries - outside the signed `.app` bundle. New Xcode majors should be added only - after helper compile/smoke and real simulator validation. + outside the signed `.app` bundle. The input helper sends point-space + touch coordinates with screen dimensions; Xcode 26 uses the modern + 9-argument Indigo mouse message path, while older supported Xcodes keep + the legacy 5-argument path. New Xcode majors should be added only after + helper compile/smoke and real simulator validation. - `idb-h264-ffmpeg-mjpeg` (recovery-only) — exact-screen stream through `idb video-stream --format h264` transcoded to MJPEG via `ffmpeg`. The renderer reads the MJPEG endpoint with `fetch` + @@ -173,6 +176,9 @@ called from a non-darwin host. route through an input backend abstraction. Indigo touch input is preferred whenever IOSurface capability is available, including explicit non-IOSurface capture modes, and idb remains the fallback. + The service sends raw simulator points plus screen metrics to the + helper, so tap and drag math stays aligned with ADEInspector and + screenshot coordinates instead of normalizing early in TypeScript. If Indigo input fails twice in 60 s, ADE sticks to idb for the rest of that session and emits a status event with the reason. Text/key input currently falls back to idb because the helper only implements diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index ee02f0e7f..3173323e2 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -333,18 +333,20 @@ protocol versions. invalid_hello`. `SyncPairingResultPayload.error.code` is one of `invalid_pin | pin_not_set | pairing_failed`. -Heartbeat interval is 30 seconds; a peer only gets closed after -**two** consecutive missed heartbeats (the host increments -`missedHeartbeatCount` on the first miss rather than disconnecting -immediately). Reconnection resumes from the last-known `db_version` -so no changesets are lost. - -`changeset_batch` envelopes carry a `batchId`; the receiver replies -with a `changeset_ack` once `applyChanges` commits (or with an error -code on failure). The host keeps the batch in `pendingChangesetBatch` -until the ack lands, retransmitting on timeout so a dropped wifi blip -cannot lose a batch. `pendingChangesetPeerCount` is surfaced through -`brain_status` for diagnostics. +Heartbeat interval is 30 seconds. Desktop peers close after **two** +consecutive missed heartbeats, while mobile peers get a wider grace +window because iOS can briefly suspend foreground networking during app +and route transitions. Reconnection resumes from the last-known +`db_version` so no changesets are lost. + +`changeset_batch` envelopes carry a `batchId`; legacy batches without +one are decoded with a deterministic fallback so older desktops can +still sync. The receiver replies with a `changeset_ack` once +`applyChanges` commits (or with an error code on failure). The host and +phone keep outbound batches pending until the ack lands, retransmitting +on timeout so a dropped wifi blip cannot lose a batch. +`pendingChangesetPeerCount` is surfaced through `brain_status` for +diagnostics. Mobile-originated `command` envelopes are deduplicated through a short-lived `mobileCommandResultCache` (TTL 30 minutes, 512 entries) @@ -483,7 +485,8 @@ current branch modifications to `syncRemoteCommandService.ts`. 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. -- **Controller command queues replay on reconnect.** If the user - fires a `chat.send` while disconnected, the iOS app stores the - command locally with a "pending sync" indicator and replays on - reconnect. Do not assume synchronous semantics from the phone side. +- **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 + a queued delivery state and replays on reconnect. Do not assume + synchronous semantics from the phone side. diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 318cfc357..d2fdb4334 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -299,7 +299,9 @@ can be sensitive. - **Chat sub-protocol** pairs with `chat.create` / `chat.send` + `chat_subscribe`. Same pattern: create / send the message through a command, subscribe to the transcript stream for incremental - events. + events. `chat.send` waits for the host-side dispatch acknowledgement + before returning `ok`, so the phone does not clear its local echo + while the desktop is still preparing the turn. - **File access sub-protocol** (`file_request` / `file_response`) is a separate envelope from remote commands; it handles large binary payloads and streaming reads outside the command surface to avoid