Skip to content

Commit 592a6c9

Browse files
arul28claude
andauthored
Mobile + desktop enhancements (#216)
* ship: prepare lane for review Mobile + desktop enhancements: - iOS: PR Draft filter, work session/chat refinements, sync reduced-load support - Desktop: iOS sim attachToChatSession, sync host backpressure, clipboard image paste fallback, terminal WebGL renderer reset, work-log status resolver, chat composer iOS-sim toggle - Tests: clipboard-paste contract test, iOS sync reduced-load coverage Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ship: iteration 1 — fix test-desktop (1), address 13 CodeRabbit comments CI fix: - AgentChatPane.tsx: guard iosSimulator IPC with try/catch + optional chaining (sync TypeError aborted createSession in tests where the iosSimulator namespace is unmocked) Review fixes (CodeRabbit): - iosSimulatorService: attachToChatSession validates owner on null detach - registerIpc: guard null/non-object payload in attach handler - syncHostService: centralize backpressure check in send() - AgentChatComposer: clipboard fallback claims generation before attaching to prevent double-attach - AgentChatMessageList: chip parser requires valid token boundary - AgentChatPane: gate eager-create attach behind iosSimulatorOpen - ChatWorkLogBlock: trim() check on entry.output - TerminalView: pendingWebGLRestore flag survives renderer changes - SyncService.swift: monotonic clock for PendingRequest.startedAt + forceReducedSyncLoadUntil + RTT - SyncService.swift: teardownSocket before markConnectionLoadStrained re-subscribe - WorkRootScreen+Actions: priority-sort lanes before reduced-mode prefix(6) - WorkSessionDestinationView: combine isLive && hostReachable; preserve fallbackEntries on skip/fail Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d356183 commit 592a6c9

26 files changed

Lines changed: 875 additions & 114 deletions

apps/desktop/src/main/services/ios/iosSimulatorService.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2549,6 +2549,27 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) {
25492549
});
25502550
};
25512551

2552+
const attachToChatSession = (chatSessionId: string | null, callerChatSessionId: string | null = chatSessionId): IosSimulatorSession | null => {
2553+
if (!activeSession) return null;
2554+
// Ownership check applies symmetrically: callers passing null to detach
2555+
// must still be the current owner, otherwise an unrelated chat could free
2556+
// another chat's simulator binding. The caller's own chat id is supplied
2557+
// via callerChatSessionId; for attach calls it is the same as the new
2558+
// chatSessionId, for detach calls (chatSessionId === null) it identifies
2559+
// the chat requesting the detach.
2560+
if (
2561+
activeSession.chatSessionId
2562+
&& callerChatSessionId
2563+
&& activeSession.chatSessionId !== callerChatSessionId
2564+
) {
2565+
throw new IosSimulatorOwnedBySessionError(activeSession);
2566+
}
2567+
if (activeSession.chatSessionId === chatSessionId) return activeSession;
2568+
activeSession = { ...activeSession, chatSessionId };
2569+
emit({ type: "session-updated", session: activeSession });
2570+
return activeSession;
2571+
};
2572+
25522573
const launch = async (launchArgs: IosSimulatorLaunchArgs = {}): Promise<IosSimulatorSession> => {
25532574
if (process.platform !== "darwin") {
25542575
throw new Error("iOS Simulator control is only available on macOS.");
@@ -2604,10 +2625,21 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) {
26042625

26052626
currentStep = "open-simulator";
26062627
emitLaunchProgress(launchId, "open-simulator", "running", "Preparing Simulator.app in the background...", null, { deviceUdid: device.udid });
2607-
const openArgs = launchArgs.keepSimulatorInBackground === false
2608-
? ["-a", "Simulator"]
2609-
: ["-g", "-a", "Simulator"];
2628+
const keepInBackground = launchArgs.keepSimulatorInBackground !== false;
2629+
const openArgs = keepInBackground
2630+
? ["-gj", "-a", "Simulator"]
2631+
: ["-a", "Simulator"];
26102632
spawn("open", openArgs, { detached: true, stdio: "ignore" }).unref();
2633+
if (keepInBackground) {
2634+
// `simctl boot`/`launch` can still steal focus on some macOS versions even
2635+
// with `open -gj`. Force-hide Simulator.app via System Events so the user
2636+
// never sees it pop in front of ADE during the launch sequence.
2637+
spawn(
2638+
"osascript",
2639+
["-e", 'tell application "System Events" to if exists process "Simulator" then set visible of process "Simulator" to false'],
2640+
{ detached: true, stdio: "ignore" },
2641+
).unref();
2642+
}
26112643
emitLaunchProgress(launchId, "open-simulator", "complete", "Simulator.app is ready in the background.", null, { deviceUdid: device.udid });
26122644

26132645
currentStep = "resolve-target";
@@ -2683,6 +2715,15 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) {
26832715
env: childEnv,
26842716
timeoutMs: 60_000,
26852717
});
2718+
if (launchArgs.keepSimulatorInBackground !== false) {
2719+
// simctl launch frequently activates Simulator.app — re-hide it so ADE
2720+
// stays in front for the remainder of the session.
2721+
spawn(
2722+
"osascript",
2723+
["-e", 'tell application "System Events" to if exists process "Simulator" then set visible of process "Simulator" to false'],
2724+
{ detached: true, stdio: "ignore" },
2725+
).unref();
2726+
}
26862727
emitLaunchProgress(launchId, "launch-app", "complete", "App launched.", bundleId, { deviceUdid: device.udid, targetId: target.target.id });
26872728
emitLaunchProgress(launchId, "ready", "complete", "iOS simulator drawer is ready.", device.name, { deviceUdid: device.udid, targetId: target.target.id });
26882729
emit({ type: "session-started", session });
@@ -3375,6 +3416,7 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) {
33753416
listDevices,
33763417
listLaunchTargets,
33773418
launch,
3419+
attachToChatSession,
33783420
shutdown,
33793421
screenshot,
33803422
getScreenSnapshot,

apps/desktop/src/main/services/ipc/registerIpc.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2112,6 +2112,22 @@ export function registerIpc({
21122112
clipboard.writeText(text);
21132113
});
21142114

2115+
ipcMain.handle(IPC.appReadClipboardImage, async (): Promise<{ data: string; filename: string; mimeType: string } | null> => {
2116+
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
2117+
const image = clipboard.readImage();
2118+
if (image.isEmpty()) return null;
2119+
const png = image.toPNG();
2120+
if (!png.byteLength) return null;
2121+
if (png.byteLength > MAX_ATTACHMENT_BYTES) {
2122+
throw new Error("Clipboard image must be 10 MB or smaller.");
2123+
}
2124+
return {
2125+
data: png.toString("base64"),
2126+
filename: "clipboard.png",
2127+
mimeType: "image/png",
2128+
};
2129+
});
2130+
21152131
ipcMain.handle(IPC.appGetImageDataUrl, async (_event, arg: { path: string }): Promise<{ dataUrl: string }> => {
21162132
const filePath = resolveAllowedRendererPath(arg?.path);
21172133
// Use async fs APIs and a size pre-check so a 10 MB image read never
@@ -5043,6 +5059,18 @@ export function registerIpc({
50435059

50445060
ipcMain.handle(IPC.iosSimulatorLaunch, async (_event, arg = {}) => ensureIosSimulator().launch(arg));
50455061

5062+
ipcMain.handle(IPC.iosSimulatorAttachToChatSession, async (_event, arg) => {
5063+
// Tolerate null/undefined payloads (treated as detach) and reject malformed
5064+
// shapes with a clear error rather than throwing on a property dereference.
5065+
if (arg !== undefined && arg !== null && typeof arg !== "object") {
5066+
throw new Error("iosSimulatorAttachToChatSession requires { chatSessionId } payload.");
5067+
}
5068+
const payload = (arg ?? {}) as { chatSessionId?: string | null; callerChatSessionId?: string | null };
5069+
const chatSessionId = payload.chatSessionId ?? null;
5070+
const callerChatSessionId = payload.callerChatSessionId ?? chatSessionId;
5071+
return ensureIosSimulator().attachToChatSession(chatSessionId, callerChatSessionId);
5072+
});
5073+
50465074
ipcMain.handle(IPC.iosSimulatorShutdown, async (_event, arg = {}) => ensureIosSimulator().shutdown(arg));
50475075

50485076
ipcMain.handle(IPC.iosSimulatorScreenshot, async (_event, arg = {}) => ensureIosSimulator().screenshot(arg));

apps/desktop/src/main/services/sync/syncHostService.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ const DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS = 30_000;
100100
const DEFAULT_SYNC_POLL_INTERVAL_MS = 400;
101101
const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000;
102102
const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000;
103+
const PEER_BACKPRESSURE_BYTES = 4 * 1024 * 1024;
103104
const LANE_PRESENCE_TTL_MS = 60_000;
104105
const SYNC_MDNS_SERVICE_TYPE = "ade-sync";
105106
export const SYNC_TAILNET_DISCOVERY_SERVICE_NAME = "svc:ade-sync";
@@ -753,6 +754,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
753754
const sentAt = nowIso();
754755
for (const peer of peers) {
755756
if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue;
757+
if (isPeerBackpressured(peer)) {
758+
args.logger.debug("sync_host.heartbeat_deferred_backpressure", {
759+
peerDeviceId: peer.metadata?.deviceId ?? null,
760+
bufferedAmount: peer.ws.bufferedAmount,
761+
});
762+
continue;
763+
}
756764
if (peer.awaitingHeartbeatAt) {
757765
peer.missedHeartbeatCount += 1;
758766
if (peer.missedHeartbeatCount >= 2) {
@@ -1095,10 +1103,23 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
10951103
}
10961104
};
10971105

1098-
function send<TPayload>(ws: WebSocket, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): void {
1106+
function send<TPayload>(target: WebSocket | PeerState, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): void {
1107+
const ws = target instanceof WebSocket ? target : target.ws;
1108+
if (ws.readyState !== WebSocket.OPEN) return;
1109+
// Drop sends to backpressured peers as the default — most envelopes are
1110+
// either replayable (chat events / changesets re-derived from db state) or
1111+
// tolerable to lose (acks, status pings). Routes that *must* deliver under
1112+
// backpressure should call ws.send / sendAndWait directly.
1113+
if (target instanceof WebSocket ? ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES : isPeerBackpressured(target)) {
1114+
return;
1115+
}
10991116
ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }));
11001117
}
11011118

1119+
function isPeerBackpressured(peer: PeerState): boolean {
1120+
return peer.ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES;
1121+
}
1122+
11021123
function sendAndWait<TPayload>(
11031124
ws: WebSocket,
11041125
type: SyncEnvelope["type"],
@@ -1421,6 +1442,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
14211442

14221443
for (const peer of peers) {
14231444
if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue;
1445+
if (isPeerBackpressured(peer)) continue;
14241446
for (const sessionId of peer.subscribedChatSessionIds) {
14251447
const session = args.sessionService.get(sessionId);
14261448
if (!session?.transcriptPath) continue;
@@ -1441,6 +1463,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
14411463
function broadcastChatEvent(event: AgentChatEventEnvelope): void {
14421464
for (const peer of peers) {
14431465
if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue;
1466+
if (isPeerBackpressured(peer)) continue;
14441467
if (!peer.subscribedChatSessionIds.has(event.sessionId)) continue;
14451468
if (!rememberChatEventSent(peer, event)) continue;
14461469
send(peer.ws, "chat_event", event);
@@ -1452,6 +1475,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
14521475
const currentDbVersion = args.db.sync.getDbVersion();
14531476
for (const peer of peers) {
14541477
if (!peer.authenticated || !peer.metadata || peer.ws.readyState !== WebSocket.OPEN) continue;
1478+
if (isPeerBackpressured(peer)) continue;
14551479
if (currentDbVersion <= peer.lastKnownServerDbVersion) continue;
14561480
const changes = args.db.sync
14571481
.exportChangesSince(peer.lastKnownServerDbVersion)
@@ -2394,6 +2418,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
23942418
};
23952419
for (const peer of peers) {
23962420
if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue;
2421+
if (isPeerBackpressured(peer)) continue;
23972422
send(peer.ws, "terminal_data", payload);
23982423
}
23992424
},
@@ -2407,6 +2432,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
24072432
};
24082433
for (const peer of peers) {
24092434
if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue;
2435+
if (isPeerBackpressured(peer)) continue;
24102436
send(peer.ws, "terminal_exit", payload);
24112437
}
24122438
},

apps/desktop/src/preload/global.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ declare global {
642642
revealPath: (path: string) => Promise<void>;
643643
openPath: (path: string) => Promise<void>;
644644
writeClipboardText: (text: string) => Promise<void>;
645+
readClipboardImage: () => Promise<{ data: string; filename: string; mimeType: string } | null>;
645646
getImageDataUrl: (path: string) => Promise<{ dataUrl: string }>;
646647
writeClipboardImage: (path: string) => Promise<void>;
647648
openPathInEditor: (args: {
@@ -1265,6 +1266,7 @@ declare global {
12651266
listDevices: () => Promise<IosSimulatorDevice[]>;
12661267
listLaunchTargets: (args?: IosSimulatorListLaunchTargetsArgs) => Promise<IosSimulatorLaunchTarget[]>;
12671268
launch: (args?: IosSimulatorLaunchArgs) => Promise<IosSimulatorSession>;
1269+
attachToChatSession: (args: { chatSessionId: string | null; callerChatSessionId?: string | null }) => Promise<IosSimulatorSession | null>;
12681270
shutdown: (args?: IosSimulatorShutdownArgs) => Promise<IosSimulatorShutdownResult>;
12691271
screenshot: (args?: { deviceUdid?: string | null }) => Promise<IosSimulatorScreenshot>;
12701272
getScreenSnapshot: (args?: IosScreenSnapshotArgs) => Promise<IosScreenSnapshot>;

apps/desktop/src/preload/preload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,8 @@ contextBridge.exposeInMainWorld("ade", {
653653
ipcRenderer.invoke(IPC.appOpenPath, { path }),
654654
writeClipboardText: async (text: string): Promise<void> =>
655655
ipcRenderer.invoke(IPC.appWriteClipboardText, { text }),
656+
readClipboardImage: async (): Promise<{ data: string; filename: string; mimeType: string } | null> =>
657+
ipcRenderer.invoke(IPC.appReadClipboardImage),
656658
getImageDataUrl: async (path: string): Promise<{ dataUrl: string }> =>
657659
ipcRenderer.invoke(IPC.appGetImageDataUrl, { path }),
658660
writeClipboardImage: async (path: string): Promise<void> =>
@@ -1798,6 +1800,8 @@ contextBridge.exposeInMainWorld("ade", {
17981800
ipcRenderer.invoke(IPC.iosSimulatorListLaunchTargets, args),
17991801
launch: async (args: IosSimulatorLaunchArgs = {}): Promise<IosSimulatorSession> =>
18001802
ipcRenderer.invoke(IPC.iosSimulatorLaunch, args),
1803+
attachToChatSession: async (args: { chatSessionId: string | null; callerChatSessionId?: string | null }): Promise<IosSimulatorSession | null> =>
1804+
ipcRenderer.invoke(IPC.iosSimulatorAttachToChatSession, args),
18011805
shutdown: async (args: IosSimulatorShutdownArgs = {}): Promise<IosSimulatorShutdownResult> =>
18021806
ipcRenderer.invoke(IPC.iosSimulatorShutdown, args),
18031807
screenshot: async (args: { deviceUdid?: string | null } = {}): Promise<IosSimulatorScreenshot> =>

apps/desktop/src/renderer/browserMock.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2638,6 +2638,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) {
26382638
openExternal: resolvedArg(undefined),
26392639
revealPath: resolvedArg(undefined),
26402640
writeClipboardText: resolvedArg(undefined),
2641+
readClipboardImage: resolved(null),
26412642
getImageDataUrl: resolvedArg({ dataUrl: "" }),
26422643
writeClipboardImage: resolvedArg(undefined),
26432644
openPath: resolvedArg(undefined),

apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* @vitest-environment jsdom */
22

33
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4-
import { cleanup, fireEvent, render, screen, type RenderResult } from "@testing-library/react";
4+
import { cleanup, fireEvent, render, screen, waitFor, type RenderResult } from "@testing-library/react";
55
import type { ComponentProps } from "react";
66
import { AgentChatComposer } from "./AgentChatComposer";
77

@@ -63,7 +63,10 @@ beforeEach(() => {
6363
installMatchMediaMock();
6464
});
6565

66-
afterEach(cleanup);
66+
afterEach(() => {
67+
cleanup();
68+
delete (window as any).ade;
69+
});
6770

6871
function buildComposerProps(overrides: Partial<ComponentProps<typeof AgentChatComposer>> = {}) {
6972
const props: ComponentProps<typeof AgentChatComposer> = {
@@ -478,6 +481,51 @@ describe("AgentChatComposer", () => {
478481
expect((screen.getByLabelText("Upload file from disk") as HTMLButtonElement).disabled).toBe(false);
479482
});
480483

484+
it("attaches a native clipboard image when macOS Cmd+V does not expose paste files", async () => {
485+
const originalPlatform = navigator.platform;
486+
Object.defineProperty(navigator, "platform", {
487+
configurable: true,
488+
value: "MacIntel",
489+
});
490+
const readClipboardImage = vi.fn().mockResolvedValue({
491+
data: "abc123",
492+
filename: "clipboard.png",
493+
mimeType: "image/png",
494+
});
495+
const saveTempAttachment = vi.fn().mockResolvedValue({ path: "/tmp/ade-clipboard.png" });
496+
(window as any).ade = {
497+
app: { readClipboardImage },
498+
agentChat: { saveTempAttachment },
499+
};
500+
501+
try {
502+
const props = renderComposer({
503+
turnActive: false,
504+
draft: "",
505+
});
506+
507+
fireEvent.keyDown(screen.getByPlaceholderText("Type to vibecode..."), {
508+
key: "v",
509+
metaKey: true,
510+
});
511+
512+
await waitFor(() => expect(readClipboardImage).toHaveBeenCalledTimes(1));
513+
expect(saveTempAttachment).toHaveBeenCalledWith({
514+
data: "abc123",
515+
filename: "clipboard.png",
516+
});
517+
expect(props.onAddAttachment).toHaveBeenCalledWith({
518+
path: "/tmp/ade-clipboard.png",
519+
type: "image",
520+
});
521+
} finally {
522+
Object.defineProperty(navigator, "platform", {
523+
configurable: true,
524+
value: originalPlatform,
525+
});
526+
}
527+
});
528+
481529
it("hides native permission controls until a model is selected", () => {
482530
const props = buildComposerProps({
483531
modelId: "",

0 commit comments

Comments
 (0)