Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3486451
fix(chat): keep user input outside collapsed work (#11363)
maria-rcks Sep 12, 2026
cfeaca4
fix(web): preserve preview focus on window return (#11444)
Lucenx9 Sep 12, 2026
03e1355
fix(web): complete thread status icons and keep input threads promine…
maria-rcks Sep 12, 2026
75d8b13
feat(web): tint image chips with their average color (#11468)
maria-rcks Sep 12, 2026
c1ff6ab
fix(web): move viewer controls outside media and restore arrow naviga…
maria-rcks Sep 12, 2026
b0c6c3b
fix(web): tighten sidebar search and footer spacing (#11466)
maria-rcks Sep 12, 2026
c0ddfb3
feat(web): subagent spawns render as an expandable work row (#11433)
maria-rcks Sep 12, 2026
af2bacc
fix(web): keep subagent rows visible under folded turns (#11474)
maria-rcks Sep 12, 2026
fcbe457
fix(usage): make unavailable account limits more visible (#10601)
dominic-r Sep 12, 2026
8ddd9f7
fix(desktop): bound backend shutdown wait during quit (#7599)
ishaanko Sep 12, 2026
36caf20
feat(web): choose the default diff file state (#11484)
maria-rcks Sep 13, 2026
68c2277
feat(composer): fold large pastes into text attachments (#11442)
chrisdeeming Sep 13, 2026
6cdbf76
feat(web): expose each chat message as a heading for screen readers (…
Leos-Khai Sep 13, 2026
2db675a
fix(usage): respect provider account homes (#11485)
maria-rcks Sep 13, 2026
2587c80
feat(web): switch saved environments off instead of removing them (#1…
t3dotgg Sep 13, 2026
5781e2b
fix(mobile): stop crashing on launch when a thread has a PR stack (#1…
juliusmarminge Sep 13, 2026
af0657e
fix(mobile): stop alerting that shared content vanished after sending…
juliusmarminge Sep 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Cause from "effect/Cause";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
Expand Down Expand Up @@ -142,6 +143,20 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr
const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) =>
handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause)));

export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")(
function* (): Effect.fn.Return<void, never, DesktopBackendPool.DesktopBackendPool> {
// Stop every backend in the pool with a timeout to guarantee the quit
// path makes progress even if a backend hangs during teardown.
const pool = yield* DesktopBackendPool.DesktopBackendPool;
const instances = yield* pool.list;
yield* Effect.forEach(
instances,
(instance) => instance.stop({ timeout: Duration.seconds(5) }),
{ concurrency: "unbounded" },
);
},
);

const bootstrap = Effect.gen(function* () {
const pool = yield* DesktopBackendPool.DesktopBackendPool;
const primaryBackend = yield* pool.primary;
Expand Down Expand Up @@ -313,18 +328,12 @@ const scopedProgram = Effect.scoped(
const shutdown = yield* DesktopShutdown.DesktopShutdown;

yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
const pool = yield* DesktopBackendPool.DesktopBackendPool;
// Stop every backend in the pool, not just the primary. The
// electronApp.quit() path can race ahead of the layer-scope
// cascade, so leaving the WSL instance for its parent scope
// finalizer means it gets hard-killed by the OS instead of
// receiving SIGTERM + grace. Stops run concurrently.
const instances = yield* pool.list;
yield* Effect.forEach(instances, (instance) => instance.stop(), {
concurrency: "unbounded",
});
}).pipe(Effect.ensuring(shutdown.markComplete)),
// Stop every backend in the pool, not just the primary. The
// electronApp.quit() path can race ahead of the layer-scope
// cascade, so leaving the WSL instance for its parent scope
// finalizer means it gets hard-killed by the OS instead of
// receiving SIGTERM + grace.
stopAllPoolInstances().pipe(Effect.ensuring(shutdown.markComplete)),
);

yield* startup;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopConnectionCatalogStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ const migrateSavedEnvironmentRecords = Effect.fn(
profiles,
credentials,
remoteDpopTokens: [],
disabledEnvironmentIds: [],
};
});

Expand Down
74 changes: 74 additions & 0 deletions apps/desktop/src/backend/DesktopBackendManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import * as DesktopBackendManager from "./DesktopBackendManager.ts";
import * as DesktopApp from "../app/DesktopApp.ts";
import * as DesktopBackendPool from "./DesktopBackendPool.ts";
import * as DesktopObservability from "../app/DesktopObservability.ts";
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
Expand Down Expand Up @@ -1503,4 +1505,76 @@ describe("DesktopBackendManager", () => {
}).pipe(Effect.provide(TestClock.layer())),
),
);

it.effect("stopAllPoolInstances bounds the quit finalizer when backends hang", () =>
Effect.scoped(
Effect.gen(function* () {
// Each backend's process-scope finalizer reports when it starts and
// when it finishes, keyed by instance name, so the test can prove
// both backends reached each milestone instead of inferring it from
// a shared flag or a clock advance.
const teardownStarted = yield* Queue.unbounded<string>();
const teardownFinished = yield* Queue.unbounded<string>();
const allowTeardown = yield* Deferred.make<void>();

const makeInstance = (name: string) =>
makeTestInstance({
spawnerLayer: Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.gen(function* () {
const scope = yield* Scope.Scope;
yield* Scope.addFinalizer(
scope,
Queue.offer(teardownStarted, name).pipe(
Effect.andThen(Deferred.await(allowTeardown)),
Effect.andThen(Queue.offer(teardownFinished, name)),
Effect.asVoid,
),
);
return makeProcess({ exitCode: Effect.never });
}),
),
),
httpClientLayer: httpClientLayer(() => Effect.never),
});

const instance1 = yield* makeInstance("instance1");
const instance2 = yield* makeInstance("instance2");

yield* instance1.start;
yield* instance2.start;

const mockPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
list: Effect.succeed([instance1, instance2]),
get: () => Effect.succeed(Option.none()),
primary: Effect.die(new Error("primary not implemented")),
register: () => Effect.die(new Error("register not implemented")),
unregister: () => Effect.die(new Error("unregister not implemented")),
});

// Mirror the quit path: register stopAllPoolInstances as a scope
// finalizer and let the scope close run it, rather than calling it
// as an ordinary interruptible effect.
const quitFiber = yield* Effect.scoped(
Effect.addFinalizer(() => DesktopApp.stopAllPoolInstances()),
).pipe(Effect.provide(mockPool), Effect.forkChild);

const started = yield* Queue.takeN(teardownStarted, 2);
assert.deepEqual(started.toSorted(), ["instance1", "instance2"]);

// Both backends are now hung in teardown. Advancing past the 5s
// budget must let the quit finalizer return without them.
yield* TestClock.adjust(Duration.seconds(5));
yield* Fiber.join(quitFiber);
assert.equal(yield* Queue.size(teardownFinished), 0);

// The timed-out closes keep running in the background and finish
// once the backends unblock.
yield* Deferred.succeed(allowTeardown, undefined);
const finished = yield* Queue.takeN(teardownFinished, 2);
assert.deepEqual(finished.toSorted(), ["instance1", "instance2"]);
}).pipe(Effect.provide(TestClock.layer())),
),
);
});
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
openExternal,
openSystemSettings,
checkSystemPermission,
pasteAsText,
probeRemoteEditors,
pickFolder,
pickProjectFavicon,
Expand Down Expand Up @@ -124,6 +125,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(openExternal);
yield* ipc.handle(openSystemSettings);
yield* ipc.handle(checkSystemPermission);
yield* ipc.handle(pasteAsText);
yield* ipc.handle(probeRemoteEditors);
yield* ipc.handle(getUpdateState);
yield* ipc.handle(setUpdateChannel);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";
export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";
export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
Expand Down
54 changes: 54 additions & 0 deletions apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,23 @@ import { vi } from "vite-plus/test";

import type * as Electron from "electron";

const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({
focusedWebContents: vi.fn(),
ownerWindow: vi.fn(),
}));
vi.mock("electron", () => ({
webContents: { getFocusedWebContents: focusedWebContents },
BrowserWindow: { fromWebContents: ownerWindow },
}));

import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
import * as ElectronDialog from "../../electron/ElectronDialog.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import {
getLocalEnvironmentBootstraps,
getWindowFullscreenState,
pasteAsText,
pickProjectFavicon,
} from "./window.ts";

Expand Down Expand Up @@ -153,6 +163,50 @@ describe("getWindowFullscreenState", () => {
});
});

describe("pasteAsText", () => {
it.effect(
"pastes into the focused guest only after the main renderer acknowledges the menu action",
() => {
const paste = vi.fn();
const mainPaste = vi.fn();
const window = {
webContents: { id: 42, paste: mainPaste },
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
focusedWebContents.mockReturnValue({ paste, isDestroyed: () => false });
ownerWindow.mockReturnValue(window);

return Effect.gen(function* () {
yield* pasteAsText.handler(undefined, { sender: { id: 42 } });
assert.equal(paste.mock.calls.length, 1);
assert.equal(mainPaste.mock.calls.length, 0);

yield* pasteAsText.handler(undefined, { sender: { id: 99 } });
assert.equal(paste.mock.calls.length, 1);
ownerWindow.mockReturnValue({}); // A focused PiP/other BrowserWindow.
yield* pasteAsText.handler(undefined, { sender: { id: 42 } });
assert.equal(paste.mock.calls.length, 1);
ownerWindow.mockReturnValue(null); // Detached contents.
yield* pasteAsText.handler(undefined, { sender: { id: 42 } });
assert.equal(paste.mock.calls.length, 1);
ownerWindow.mockReturnValue(window);
focusedWebContents.mockReturnValue({ paste, isDestroyed: () => true });
yield* pasteAsText.handler(undefined, { sender: { id: 42 } });
assert.equal(paste.mock.calls.length, 1);
focusedWebContents.mockReturnValue(null);
yield* pasteAsText.handler(undefined, { sender: { id: 42 } });
assert.equal(paste.mock.calls.length, 1);
}).pipe(
Effect.provide(
Layer.mock(ElectronWindow.ElectronWindow)({
main: Effect.succeed(Option.some(window)),
}),
),
);
},
);
});

describe("pickProjectFavicon", () => {
it.effect("opens a single-image picker from the project directory", () =>
Effect.gen(function* () {
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,32 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
}),
});

export const pasteAsText = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PASTE_AS_TEXT_CHANNEL,
payload: Schema.Undefined,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.window.pasteAsText")(function* (_input, event) {
const electronWindow = yield* ElectronWindow.ElectronWindow;
const window = yield* electronWindow.main;
if (
event === undefined ||
Option.isNone(window) ||
window.value.isDestroyed() ||
window.value.webContents.id !== event.sender.id
) {
return;
}
const focused = Electron.webContents.getFocusedWebContents();
if (
focused &&
!focused.isDestroyed() &&
Electron.BrowserWindow.fromWebContents(focused) === window.value
) {
focused.paste();
}
}),
});

/** Theme files are a few KB; anything larger returns empty text and lets the
* renderer reject it by size without the contents ever crossing the bridge. */
const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
openSystemSettings: (pane: string) =>
ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane),
probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined),
onMenuAction: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
if (typeof action !== "string") return;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const clientSettings: ClientSettings = {
contextWindowMeterEnabled: false,
composerCollapseOnScroll: true,
dismissedProviderUpdateNotificationKeys: [],
diffFilesCollapsed: true,
diffIgnoreWhitespace: true,
diffLayout: "stacked",
environmentIdentificationMode: "artwork",
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,36 @@ describe("DesktopApplicationMenu", () => {
}),
);

it.effect("owns Paste as Text and routes it through the renderer", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();

yield* configureMenu(selectedAction, applicationMenuTemplate);

const template = yield* Deferred.await(applicationMenuTemplate);
const editMenu = template.find((item) => item.label === "Edit");
assert.isDefined(editMenu);
if (!Array.isArray(editMenu.submenu)) {
throw new Error("Expected Edit menu submenu to be an array.");
}
const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text");
assert.isDefined(pasteAsTextItem);
assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V");
if (typeof pasteAsTextItem.click !== "function") {
throw new Error("Expected Paste as Text menu item to have a click handler.");
}

pasteAsTextItem.click(
{} as Electron.MenuItem,
{} as Electron.BrowserWindow,
{} as KeyboardEvent,
);
assert.equal(yield* Deferred.await(selectedAction), "paste-as-text");
}),
);

// Zoom must route through DesktopWindow.zoomMain instead of the Electron
// zoom roles: the roles zoom whichever webContents has focus, which breaks
// app zoom while an embedded preview WebContentsView holds focus.
Expand Down
36 changes: 34 additions & 2 deletions apps/desktop/src/window/DesktopApplicationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function
action: string,
): Effect.fn.Return<void, DesktopWindow.DesktopWindowError, DesktopWindow.DesktopWindow> {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.dispatchMenuAction(action);
yield* desktopWindow.dispatchMenuAction(action, {
reveal: action !== "paste-as-text",
});
});

const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* (
Expand Down Expand Up @@ -135,6 +137,9 @@ export const make = Effect.gen(function* () {
const settingsClick = () => {
runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
};
const pasteAsTextClick = () => {
runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text"));
};
const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction));
};
Expand Down Expand Up @@ -184,7 +189,34 @@ export const make = Effect.gen(function* () {
{ role: environment.platform === "darwin" ? "close" : "quit" },
],
},
{ role: "editMenu" },
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{
label: "Paste as Text",
accelerator: "CmdOrCtrl+Shift+V",
click: pasteAsTextClick,
},
{ role: "delete" },
{ type: "separator" },
{ role: "selectAll" },
...(environment.platform === "darwin"
? [
{ type: "separator" as const },
{
label: "Speech",
submenu: [{ role: "startSpeaking" as const }, { role: "stopSpeaking" as const }],
},
]
: []),
],
},
{
label: "View",
submenu: [
Expand Down
Loading
Loading