Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4a92dad
feat(desktop): pop a browser tab out into its own window
brunodubcosta Nov 6, 2026
4cb9498
fix(desktop): harden browser pop-out window per review
brunodubcosta Nov 6, 2026
62e50a0
fix(desktop): pop-out navigation guard checks scheme and host
brunodubcosta Nov 6, 2026
a5b5c41
fix(web): pop-out "Bring back to chat" always closes the window
brunodubcosta Nov 6, 2026
47478b3
test(desktop): cover renderer navigation guard beside its module
brunodubcosta Nov 6, 2026
225f905
fix(desktop): reject pop-out for closing tabs; disable mini-player wh…
brunodubcosta Nov 6, 2026
a57bc23
fix(desktop): fence pop-out windows to the tab generation that opened…
brunodubcosta Nov 6, 2026
e30fbff
test(desktop): name navigation guard test after what it checks
brunodubcosta Nov 6, 2026
d2dbacc
fix(desktop): set pop-out detached state under the tab lifecycle lock
brunodubcosta Nov 6, 2026
360c9a8
feat(desktop): draw pop-out window controls inside the browser toolba…
brunodubcosta Nov 6, 2026
daceb6c
fix(web): pop-out browser annotations reach the chat prompt
brunodubcosta Nov 6, 2026
a188007
fix(desktop): pop-out of an empty tab shows recent pages
brunodubcosta Nov 6, 2026
5023dca
Merge remote-tracking branch 'github/main' into pr-31984
maria-rcks Nov 10, 2026
20fe8c4
Merge branch 'main' of https://github.com/pingdotgg/t3code into feat/…
brunodubcosta Nov 11, 2026
bdaeadd
fix(desktop): pop-out windows work with the IPC sender guard
brunodubcosta Nov 11, 2026
ec3f3b9
Merge branch 'feat/browser-popout-window' of https://github.com/bruno…
brunodubcosta Nov 11, 2026
38cf62f
Merge remote-tracking branch 'github/main' into pr-31984
maria-rcks Nov 11, 2026
5c743c3
test(desktop): stub find-result forwarding in the open-link test
maria-rcks Nov 11, 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
28 changes: 26 additions & 2 deletions apps/desktop/src/ipc/DesktopIpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ vi.mock("electron", () => ({}));
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopIpc from "./DesktopIpc.ts";

it.effect("answers only the main window's top frame", () =>
const popout = (id: number, url: string) => {
const mainFrame = {};
return {
sender: { id, getType: () => "window", getURL: () => url, mainFrame },
senderFrame: mainFrame,
};
};

it.effect("answers only the app's own windows' top frames", () =>
Effect.gen(function* () {
const mainFrame = {};
const invoke = new Map<string, DesktopIpc.DesktopIpcHandleListener>();
Expand All @@ -25,7 +33,7 @@ it.effect("answers only the main window's top frame", () =>
Layer.provide(
Layer.mock(ElectronWindow.ElectronWindow, {
main: Effect.succeedSome({
webContents: { id: 1, mainFrame },
webContents: { id: 1, mainFrame, getURL: () => "t3://app/index.html#/" },
} as unknown as Electron.BrowserWindow),
}),
),
Expand All @@ -46,6 +54,17 @@ it.effect("answers only the main window's top frame", () =>
app: { sender: { id: 1 }, senderFrame: mainFrame },
appSubframe: { sender: { id: 1 }, senderFrame: {} },
previewGuest: { sender: { id: 2 }, senderFrame: {} },
popout: popout(3, "t3://app/index.html#/browser-window/tab-1"),
popoutSubframe: {
...popout(3, "t3://app/index.html#/browser-window/tab-1"),
senderFrame: {},
},
popoutOnAppRoute: popout(3, "t3://app/index.html#/settings"),
foreignWindow: popout(4, "https://example.com/#/browser-window/tab-1"),
guestOnAppUrl: {
...popout(5, "t3://app/index.html#/browser-window/tab-1"),
sender: { ...popout(5, "").sender, getType: () => "webview" },
},
};
const results: Record<string, unknown> = {};
for (const [name, sender] of Object.entries(senders)) {
Expand All @@ -62,6 +81,11 @@ it.effect("answers only the main window's top frame", () =>
app: ["secret", "secret"],
appSubframe: ["rejected", undefined],
previewGuest: ["rejected", undefined],
popout: ["secret", "secret"],
popoutSubframe: ["rejected", undefined],
popoutOnAppRoute: ["rejected", undefined],
foreignWindow: ["rejected", undefined],
guestOnAppUrl: ["rejected", undefined],
});
}).pipe(Effect.scoped),
);
34 changes: 26 additions & 8 deletions apps/desktop/src/ipc/DesktopIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import * as Scope from "effect/Scope";
import * as ElectronWindow from "../electron/ElectronWindow.ts";

export interface DesktopIpcInvokeEvent {
readonly sender: { readonly id: number };
readonly sender: {
readonly id: number;
readonly getType?: () => string;
readonly getURL?: () => string;
readonly mainFrame?: unknown;
};
readonly senderFrame?: unknown;
}

Expand Down Expand Up @@ -118,6 +123,7 @@ export const make = (
Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({ channel });
if (!(yield* isTrustedSender(event))) {
yield* Effect.logWarning("Rejected IPC from an untrusted sender.");
return yield* new DesktopIpcUntrustedSenderError({ channel });
}
return yield* handler(raw, event);
Expand Down Expand Up @@ -185,17 +191,29 @@ export const layer = (ipcMain: DesktopIpcMain) =>
Effect.gen(function* () {
const electronWindow = yield* ElectronWindow.ElectronWindow;
return make(ipcMain, (event) =>
Effect.map(
electronWindow.main,
(main) =>
Option.isSome(main) &&
main.value.webContents.id === event.sender.id &&
main.value.webContents.mainFrame === event.senderFrame,
),
Effect.map(electronWindow.main, (main) => {
if (Option.isNone(main)) return false;
const app = main.value.webContents;
if (app.id === event.sender.id) return app.mainFrame === event.senderFrame;
return isPopoutWindow(event, app.getURL());
}),
);
}),
);

// A pop-out browser window runs the app itself, so it is as trusted as the main window.
// Preview guests are "webview" contents, and their pop-ups never load the app origin.
const isPopoutWindow = (event: DesktopIpcInvokeEvent, appUrl: string): boolean => {
const sender = event.sender;
if (sender.getType?.() !== "window" || sender.mainFrame !== event.senderFrame) return false;
try {
const url = new URL(sender.getURL?.() ?? "");
return url.origin === new URL(appUrl).origin && url.hash.startsWith("#/browser-window/");
} catch {
return false;
}
};

export const layerTest = (ipcMain: DesktopIpcMain) =>
Layer.succeed(
DesktopIpc,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact";
export const PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL = "desktop:preview-pip-open";
export const PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL = "desktop:preview-pip-close";
export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-frame";
export const PREVIEW_TAB_WINDOW_OPEN_CHANNEL = "desktop:preview-tab-window-open";
export const PREVIEW_TAB_WINDOW_CLOSE_CHANNEL = "desktop:preview-tab-window-close";
export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start";
export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop";
export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
Expand Down
35 changes: 35 additions & 0 deletions apps/desktop/src/ipc/methods/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import * as PreviewManager from "../../preview/Manager.ts";
import * as PreviewIpc from "./preview.ts";
import * as DesktopIpc from "../DesktopIpc.ts";
Expand Down Expand Up @@ -110,6 +111,40 @@ describe("preview IPC methods", () => {
}),
);

effectIt.effect("sends opened links only to the main window, never a pop-out", () =>
Effect.gen(function* () {
let openLink: ((event: unknown) => Effect.Effect<void>) | undefined;
const mainSend = vi.fn();
const sendAll = vi.fn(() => Effect.void);
const listen = () => Effect.void;
yield* PreviewIpc.installPreviewEventForwarding().pipe(
Effect.provideService(ElectronWindow.ElectronWindow, {
main: Effect.succeedSome({
isDestroyed: () => false,
webContents: { send: mainSend },
} as unknown as Electron.BrowserWindow),
sendAll,
} as unknown as ElectronWindow.ElectronWindow["Service"]),
Effect.provideService(PreviewManager.PreviewManager, {
subscribeStateChanges: listen,
subscribeRecordingInputs: listen,
subscribePointerEvents: listen,
subscribeFindResults: listen,
subscribeOpenLinks: (listener: (event: unknown) => Effect.Effect<void>) => {
openLink = listener;
return Effect.void;
},
} as unknown as PreviewManager.PreviewManager["Service"]),
);
yield* openLink!({ url: "https://example.com" });
expect(mainSend).toHaveBeenCalledOnce();
expect(sendAll).not.toHaveBeenCalledWith(
expect.stringContaining("open-link"),
expect.anything(),
);
}),
);

beforeEach(() => {
fromPartition.mockClear();
});
Expand Down
26 changes: 25 additions & 1 deletion apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DesktopPreviewConfigInputSchema,
DesktopPreviewFindInPageInputSchema,
DesktopPreviewNavigateInputSchema,
DesktopPreviewOpenTabWindowInputSchema,
DesktopPreviewRecordingArtifactSchema,
DesktopPreviewRecordingSaveInputSchema,
DesktopPreviewRegisterWebviewInputSchema,
Expand Down Expand Up @@ -57,8 +58,15 @@ export const installPreviewEventForwarding = Effect.fn(
yield* manager.subscribePointerEvents((event) =>
electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event),
);
// Only the main window opens tabs; pop-out windows would open a duplicate.
yield* manager.subscribeOpenLinks((event) =>
electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event),
Effect.flatMap(electronWindow.main, (main) =>
Effect.sync(() => {
if (Option.isSome(main) && !main.value.isDestroyed()) {
main.value.webContents.send(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event);
}
}),
),
);
yield* manager.subscribeFindResults((event) =>
electronWindow.sendAll(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, event),
Expand Down Expand Up @@ -269,6 +277,20 @@ const openPictureInPicture = tabMethod(
"desktop.ipc.preview.openPictureInPicture",
(manager, tabId) => manager.openPictureInPicture(tabId),
);
const openTabWindow = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_TAB_WINDOW_OPEN_CHANNEL,
payload: DesktopPreviewOpenTabWindowInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.preview.openTabWindow")(function* ({ tabId, partition }) {
const manager = yield* PreviewManager.PreviewManager;
yield* manager.openTabWindow(tabId, partition);
}),
});
const closeTabWindow = tabMethod(
IpcChannels.PREVIEW_TAB_WINDOW_CLOSE_CHANNEL,
"desktop.ipc.preview.closeTabWindow",
(manager, tabId) => manager.closeTabWindow(tabId),
);
const closePictureInPicture = tabMethod(
IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL,
"desktop.ipc.preview.closePictureInPicture",
Expand Down Expand Up @@ -495,6 +517,8 @@ export const methods = [
copyArtifactToClipboard,
openPictureInPicture,
closePictureInPicture,
openTabWindow,
closeTabWindow,
startRecording,
stopRecording,
saveRecording,
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
close: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, { tabId }),
},
tabWindow: {
open: (tabId, partition) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_TAB_WINDOW_OPEN_CHANNEL, { tabId, partition }),
close: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_TAB_WINDOW_CLOSE_CHANNEL, { tabId }),
},
recording: {
onInput: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, event: unknown) => {
Expand Down
74 changes: 73 additions & 1 deletion apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1651,12 +1651,15 @@ describe("PreviewManager", () => {
expect(replacementListenerSpies.on).not.toHaveBeenCalled();
yield* manager.closeTab("tab_close_register_race");
const recordingExit = yield* Effect.exit(manager.startRecording("tab_close_register_race"));
const tabWindowExit = yield* Effect.exit(
manager.openTabWindow("tab_close_register_race", "persist:test"),
);
yield* Deferred.succeed(continueCloseCleanup, undefined);
yield* Fiber.join(closeFiber);
const recreated = yield* Fiber.join(recreateFiber);
const registrationExit = yield* Fiber.await(registrationFiber);

for (const exit of [registrationExit, recordingExit] as ReadonlyArray<
for (const exit of [registrationExit, recordingExit, tabWindowExit] as ReadonlyArray<
Exit.Exit<unknown, PreviewManager.PreviewManagerError>
>) {
expect(Exit.isFailure(exit)).toBe(true);
Expand All @@ -1675,6 +1678,75 @@ describe("PreviewManager", () => {
),
);

effectIt.effect("keeps a stale tab window from attaching to a recreated tab", () =>
withManager((manager) =>
Effect.gen(function* () {
const hostHandlers = new Map<string, (...args: ReadonlyArray<unknown>) => void>();
const tabWindow = {
webContents: {
id: 900,
on: (event: string, handler: (...args: ReadonlyArray<unknown>) => void) =>
hostHandlers.set(event, handler),
setWindowOpenHandler: vi.fn(),
},
isDestroyed: () => false,
once: vi.fn(),
close: vi.fn(),
loadURL: vi.fn(async () => undefined),
};
browserWindowConstructor.mockImplementation(function () {
return tabWindow;
});
yield* manager.setMainWindow({
isDestroyed: () => false,
once: vi.fn(),
webContents: { getURL: () => "t3code://app/" },
} as never);

yield* manager.createTab("tab_stale_window");
yield* manager.openTabWindow("tab_stale_window", "persist:t3code-preview-test");
yield* manager.closeTab("tab_stale_window");
yield* manager.createTab("tab_stale_window");
const guest = { id: 77, on: vi.fn() };
tabWindow.close.mockClear();
hostHandlers.get("did-attach-webview")!({}, guest);

expect(tabWindow.close).toHaveBeenCalledOnce();
expect(guest.on).not.toHaveBeenCalled();
}),
),
);

effectIt.effect("detaches a tab with no page as soon as its window opens", () =>
withManager((manager) =>
Effect.gen(function* () {
browserWindowConstructor.mockImplementation(function () {
return {
webContents: { id: 901, on: vi.fn(), setWindowOpenHandler: vi.fn() },
isDestroyed: () => false,
once: vi.fn(),
close: vi.fn(),
loadURL: vi.fn(async () => undefined),
};
});
yield* manager.setMainWindow({
isDestroyed: () => false,
once: vi.fn(),
webContents: { getURL: () => "t3code://app/" },
} as never);
const detached: Array<boolean> = [];
yield* manager.subscribeStateChanges((_tabId, state) =>
Effect.sync(() => detached.push(state.detached)),
);

yield* manager.createTab("tab_empty_window");
yield* manager.openTabWindow("tab_empty_window", "persist:t3code-preview-test");

expect(detached.at(-1)).toBe(true);
}),
),
);

effectIt.effect("preserves both screenshots of the same site in the same millisecond", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down
Loading
Loading