Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
d6f47ff
feat(providers): run Muse Code as a native provider (#17082)
t3dotgg Oct 8, 2026
0647c48
feat(web): compact-before-send is a chip that shows the token count (…
t3dotgg Oct 8, 2026
2ea7684
fix(clients): copy button is back in its old spot, before fork (#17137)
t3dotgg Oct 8, 2026
4d1f24b
fix(usage): repeat usage scans no longer decode unchanged Antigravity…
t3dotgg Oct 8, 2026
1f31906
fix(server): shell snapshots no longer block other database reads whi…
t3dotgg Oct 8, 2026
10e29a2
perf(server): ACP tool updates no longer persist a snapshot per strea…
darjss Oct 8, 2026
13c5328
feat(web): filter the Usage page to just the providers you want (#16970)
t3dotgg Oct 8, 2026
9381533
fix(usage): Cursor account history loads about 4x faster (#17140)
t3dotgg Oct 8, 2026
0ce6b9d
fix(server): threads settle as soon as branch status sees their PR me…
t3dotgg Oct 8, 2026
8597558
perf(usage): the Usage page shows numbers in under a second and dims …
t3dotgg Oct 8, 2026
37eaf5d
fix(server): an agent can settle its own thread when its turn ends (#…
t3dotgg Oct 8, 2026
5e22256
feat(web): Add provider button sits with the provider list (#17152)
t3dotgg Oct 8, 2026
a4c9494
fix(web): link pull requests to threads in folders that aren't Git re…
shivamhwp Oct 8, 2026
12069ee
feat(web): find messages and plans in the current thread (#10439)
Noojuno Oct 8, 2026
9a3070b
feat(web): improve terminal scrollback navigation and snapshots (#17091)
StiensWout Oct 8, 2026
a65b438
docs(internals): add a checklist for adding a provider (#17229)
juliusmarminge Oct 8, 2026
a6ec88f
fix(mobile): keep native screens ordered during stack pops (#17231)
juliusmarminge Oct 8, 2026
4daec10
fix(server): pairing tokens work on Node versions that cannot bind bo…
chisewaguri Oct 8, 2026
61b9790
fix(mobile): HTML pages in a thread no longer trap scrolling on Andro…
SunkenInTime Oct 8, 2026
07a8b9e
fix(web): centered scrollers no longer shift when the scrollbar appea…
maria-rcks Oct 8, 2026
62a5d37
fix(web): distinguish thread search matches from code tints (#17263)
Yash-Singh1 Oct 8, 2026
f999eb2
fix(server): Pi extension wakes get an owned continuation turn (#17214)
StiensWout Oct 8, 2026
de72286
fix(server): Pi discovers optional T3 tools on demand (#17220)
StiensWout Oct 8, 2026
eb1cf7b
fix(web): stack merge dialog closes as soon as you confirm (#17116)
flamboh Oct 8, 2026
e8f5850
fix(server): Pi editor dialogs prefill the answer composer (#17206)
StiensWout Oct 8, 2026
a0066d5
fix(desktop): generate valid User-Agent that follows RFC 9110 guideli…
jztmanyl Oct 8, 2026
0b6ec43
fix(server): Pi discovers workspace skills and commands (#17190)
StiensWout Oct 8, 2026
805967a
fix(mobile): preserve navigation after native swipe back (#17268)
juliusmarminge Oct 8, 2026
fd25c42
fix(server): keep newly discovered models out of legacy groups (#14314)
Bil0000 Oct 8, 2026
df616cc
feat(editors): open remote projects in JetBrains IDEs over SSH (#17271)
juliusmarminge Oct 8, 2026
740f591
test(desktop): expect JetBrains IDEs among remote editors (#17291)
juliusmarminge Oct 8, 2026
cdd331b
fix(server): recognize authenticated GitHub Enterprise hosts (#11059)
alimek Oct 8, 2026
2a93885
fix(connect): relay client updates itself and skips incompatible clou…
juliusmarminge Oct 8, 2026
bd565bd
fix(shared): relay client install waits out a brief Windows file lock…
ScottN-PV Oct 8, 2026
c377d1c
fix(shared): release relay install locks on cancellation (#10585)
yashranaway Oct 8, 2026
af3d842
chore(shared): bump managed cloudflared to 2026.10.0 (#11184)
bompus Oct 8, 2026
5809487
fix(shared): bound cloudflared download with 10-minute timeout (#14139)
kvnloo Oct 8, 2026
9eea28b
refactor(provider-core): add provider-core and provider-testing packa…
juliusmarminge Oct 8, 2026
2c11607
refactor(settings): drop the legacy per-driver providers map (#17300)
juliusmarminge Oct 8, 2026
9b0df13
refactor(provider-pi): move Pi into its own provider package (#17302)
juliusmarminge Oct 8, 2026
7b57b72
feat(models): tell users when a CLI update unlocks a new model (#17307)
juliusmarminge Oct 8, 2026
48b71f0
fix(web): collapsed composer reserves room for wide send actions (#17…
maria-rcks Oct 8, 2026
d81afa0
fix(muse): workflow subagents no longer stall on hidden approvals (#1…
t3dotgg Oct 8, 2026
e38b6a1
refactor(provider-core): share attachment prompts, notifications, and…
juliusmarminge Oct 8, 2026
741377c
fix(web): file previews handle downloads, in-page links, and repo pat…
juliusmarminge Oct 8, 2026
5754121
fix(server): environment-hosted browser tabs behave like a normal bro…
juliusmarminge Oct 8, 2026
d9f7c0c
fix(desktop): browser tab fixes for fullscreen, shortcuts, links, rel…
juliusmarminge Oct 8, 2026
66298aa
fix(web): desktop opens remote environments' browser tabs locally (#1…
juliusmarminge Oct 8, 2026
3bfbc37
fix(desktop): the t3 command warns instead of installing behind anoth…
juliusmarminge Oct 8, 2026
e0c80c1
fix(web): images, video, HTML and PDF preview in a thread before its …
juliusmarminge Oct 8, 2026
5b78b34
refactor(provider-muse): move Muse Code into its own provider package…
juliusmarminge Oct 8, 2026
6308db4
fix(web): semantic branch naming hint lines up with its setting (#16972)
limineol Oct 8, 2026
393ff4c
fix(mobile): restore chat image previews in the v5 stack (#17361)
juliusmarminge Oct 8, 2026
c724c7c
feat(mobile): fade working threads and match web's status labels (#17…
juliusmarminge Oct 9, 2026
dbd8343
fix(server): agent browser tools stop bloating history, fall back sen…
juliusmarminge Oct 9, 2026
29980a3
fix(web): add room for thread timeline markers (#17372)
Yash-Singh1 Oct 9, 2026
b0ee7dd
fix(web): drop sidebar context before cancelling pointer drag (#17373)
Yash-Singh1 Oct 9, 2026
2c7a446
refactor(providers): namespace-import service modules in core, Muse, …
juliusmarminge Oct 9, 2026
456930a
fix(auth): show connection permissions and enforce session lifetime (…
juliusmarminge Oct 9, 2026
e62161b
refactor(provider-opencode): move OpenCode into its own provider pack…
juliusmarminge Oct 9, 2026
b5e4f1e
refactor(provider-cursor): move Cursor into its own provider package …
juliusmarminge Oct 9, 2026
664bd5e
refactor(provider-acp): move the shared ACP adapter into its own pack…
juliusmarminge Oct 9, 2026
b707eeb
refactor(provider-grok): move Grok into its own provider package (#17…
juliusmarminge Oct 9, 2026
42c6623
fix(server): speed up long thread message sync (#17387)
Yash-Singh1 Oct 9, 2026
c9fa136
fix(desktop): cancel backend pipe reads to avoid slow shutdown (#17386)
Yash-Singh1 Oct 9, 2026
b000eac
refactor(providers): adapter factories yield their services (#17381)
juliusmarminge Oct 9, 2026
5785df1
fix(web): show a row spinner instead of a banner when expanding a fol…
juliusmarminge Oct 9, 2026
8f76037
fix(server): a timed-out browser drag no longer exits the server (#17…
ScottN-PV Oct 9, 2026
563645c
fix(server): a logged-out Claude CLI no longer reports as authenticat…
yordis Oct 9, 2026
3b6af0b
fix(server): Pi loads every selected skill without losing prompt text…
StiensWout Oct 9, 2026
c908cea
fix(server): keep the Claude MCP token out of process arguments (#17408)
juliusmarminge Oct 9, 2026
7a2b1c7
fix(server): reconcile Pi native session rewinds (#13839)
StiensWout Oct 9, 2026
cb46c62
test(provider-pi): cover continuation offers through the driver (#17407)
juliusmarminge Oct 9, 2026
43f8a8d
refactor(provider-acp-registry): move the ACP Registry into its own p…
juliusmarminge Oct 9, 2026
42a9793
chore(rtvision): sync upstream and prepare 0.0.76
kalvenschraut Oct 9, 2026
6d602ef
test(web): refresh connection fixture for session permissions
kalvenschraut Oct 9, 2026
7089335
fix(desktop): preserve launchers during PATH collision checks
kalvenschraut Oct 9, 2026
81ce992
fix(sync): preserve Find dismissal and Muse workflow completion
kalvenschraut Oct 9, 2026
54196df
fix(desktop): support older preview bridges in new clients
kalvenschraut Oct 9, 2026
21faf52
fix(web): keep preview actions in the attached browser profile
kalvenschraut Oct 9, 2026
67b11f9
fix(client-runtime): preserve expanded Find history on retry
kalvenschraut Oct 9, 2026
07a33e7
docs(rtvision): align remote browser and provider guidance with upstream
kalvenschraut Oct 9, 2026
f559bbb
fix(web): preserve browser runtime when reopening closed tabs
kalvenschraut Oct 9, 2026
58570d3
fix(web): find Mermaid source during loading and errors
kalvenschraut Oct 9, 2026
4ec20d6
fix(web): clear retained usage when diagnostics access changes
kalvenschraut Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 2 additions & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@t3tools/desktop",
"version": "0.0.75",
"version": "0.0.76",
"private": true,
"type": "module",
"main": "dist-electron/boot.cjs",
Expand All @@ -24,7 +24,7 @@
"@t3tools/tailscale": "workspace:*",
"dbus-next": "0.10.2",
"effect": "catalog:",
"electron": "44.4.2",
"electron": "44.4.5",
"electron-store": "^8.2.0",
"electron-updater": "^6.8.9",
"ffi-rs": "1.3.2",
Expand Down
35 changes: 34 additions & 1 deletion apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ describe("DesktopAppIdentity", () => {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
yield* identity.configure;

assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]);
assert.deepEqual(calls.setName, ["T3 Code Alpha"]);
assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)");
assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3");
assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab");
Expand All @@ -237,6 +237,39 @@ describe("DesktopAppIdentity", () => {
);
});

it.effect.each([
{ stage: "Alpha", environment: {} },
{
stage: "Nightly",
environment: { appVersion: "0.0.43-nightly.20260929.2428" },
},
{
stage: "Dev",
environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } },
},
])("uses a valid native User-Agent product name for $stage", ({ stage, environment }) => {
const calls: ElectronAppCalls = {
setAboutPanelOptions: [],
setDockIcon: [],
setName: [],
};

return withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
yield* identity.configure;

const runtimeName = calls.setName[0];
assert.isDefined(runtimeName);
assert.equal(runtimeName, `T3 Code ${stage}`);
// RFC 9110's token grammar, after Electron removes ASCII spaces.
assert.match(runtimeName.replaceAll(" ", ""), /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/);
assert.equal(calls.setAboutPanelOptions[0]?.applicationName, `T3 Code (${stage})`);
}),
{ calls, environment },
);
});

it.effect("sets the dock icon only when running unpackaged", () => {
const calls: ElectronAppCalls = {
setAboutPanelOptions: [],
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/app/DesktopAppIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,12 @@ export const make = Effect.gen(function* () {

const configure = Effect.gen(function* () {
const commitHash = yield* resolveAboutCommitHash;
yield* electronApp.setName(environment.displayName);
// Electron removes spaces from this name to build the native User-Agent
// product token, but leaves parentheses intact. Keep the runtime name valid
// without rewriting preview sessions (which breaks Turnstile, #7110).
yield* electronApp.setName(
`${environment.branding.baseName} ${environment.branding.stageLabel}`,
);
yield* electronApp.setAboutPanelOptions({
applicationName: environment.displayName,
applicationVersion: environment.appVersion,
Expand Down
99 changes: 91 additions & 8 deletions apps/desktop/src/app/DesktopCliCommand.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import { afterEach, beforeEach, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -85,6 +85,15 @@ const fakePowerShell = (initial: string) => {
return { registry, spawner };
};

// The machine's own PATH may already hold a t3; each test sets the PATH it means.
const machinePath = process.env.PATH;
beforeEach(() => {
process.env.PATH = "";
});
afterEach(() => {
process.env.PATH = machinePath;
});

it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
it.effect("links the launcher onto PATH and removes only that link", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -148,6 +157,73 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
}).pipe(Effect.scoped),
);

it.effect("replaces an owned launcher before a later foreign PATH command", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped();
const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") });
const link = (yield* before.install).installedPath!;
const foreignDirectory = path.join(home, "foreign");
const foreign = path.join(foreignDirectory, "t3");
yield* fs.makeDirectory(foreignDirectory);
yield* fs.writeFileString(foreign, "#!/bin/sh\n", { mode: 0o755 });
process.env.PATH = [path.dirname(link), foreignDirectory].join(":");

const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") });
expect(yield* after.install).toMatchObject({ installedPath: link, onPath: true });
expect(yield* fs.readLink(link)).toBe(path.join(home, "new-t3", "bin", "t3"));
expect(yield* fs.readFileString(foreign)).toBe("#!/bin/sh\n");
}).pipe(Effect.scoped),
);

it.effect("preserves the old launcher when an earlier foreign command rejects migration", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped();
const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") });
const link = (yield* before.install).installedPath!;
const original = yield* fs.readLink(link);
const foreignDirectory = path.join(home, "foreign");
const foreign = path.join(foreignDirectory, "t3");
yield* fs.makeDirectory(foreignDirectory);
yield* fs.writeFileString(foreign, "#!/bin/sh\n", { mode: 0o755 });
process.env.PATH = [foreignDirectory, path.dirname(link)].join(":");

const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") });
expect((yield* Effect.flip(after.install)).message).toContain(foreign);
expect(yield* fs.readLink(link)).toBe(original);
expect(yield* fs.readFileString(foreign)).toBe("#!/bin/sh\n");
}).pipe(Effect.scoped),
);

it.effect.each(["non-executable file", "directory"] as const)(
"skips a %s named t3 earlier on PATH",
(kind) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped();
const skippedDirectory = path.join(home, "skipped");
const skipped = path.join(skippedDirectory, "t3");
yield* fs.makeDirectory(skippedDirectory);
if (kind === "directory") {
yield* fs.makeDirectory(skipped);
} else {
yield* fs.writeFileString(skipped, "not a command\n", { mode: 0o644 });
}
const link = path.join(home, ".local", "bin", "t3");
process.env.PATH = [skippedDirectory, path.dirname(link)].join(":");

const command = yield* commandIn({ home });
expect((yield* command.state).shadowedBy).toBeUndefined();
expect(yield* command.install).toMatchObject({ installedPath: link, onPath: true });
expect(yield* fs.exists(skipped)).toBe(true);
expect((yield* fs.stat(skipped)).type).toBe(kind === "directory" ? "Directory" : "File");
}).pipe(Effect.scoped),
);

it.effect("does not read a large binary another t3 links to", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand All @@ -165,24 +241,31 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
}).pipe(Effect.scoped),
);

it.effect("reports when another t3 earlier on PATH would run instead", () =>
it.effect("refuses to install behind another t3 that runs first", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped();
const shadow = path.join(home, "shadow");
yield* fs.makeDirectory(shadow);
yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 });
const previous = process.env.PATH;
process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":");
yield* Effect.addFinalizer(() => Effect.sync(() => (process.env.PATH = previous)));

const command = yield* commandIn({ home });
const theirs = path.join(shadow, "t3");
expect((yield* command.state).shadowedBy).toBe(theirs);
// A link behind it would never run, so nothing is created.
const error = yield* Effect.flip(command.install);
expect(error.message).toContain(theirs);
expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false);

yield* fs.remove(theirs);
const installed = yield* command.install;
expect(installed.installedPath).toBe(path.join(home, ".local", "bin", "t3"));
expect(installed.onPath).toBe(false);
yield* fs.remove(path.join(shadow, "t3"));
expect((yield* command.state).onPath).toBe(true);
expect(installed).toMatchObject({
onPath: true,
installedPath: path.join(home, ".local", "bin", "t3"),
});
expect(installed.shadowedBy).toBeUndefined();
}).pipe(Effect.scoped),
);

Expand Down
48 changes: 42 additions & 6 deletions apps/desktop/src/app/DesktopCliCommand.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// FileSystem.access has no executable flag; X_OK follows the current user's Unix permissions.
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFS from "node:fs";
import * as NodeFSP from "node:fs/promises";
import type { DesktopCliCommandState } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -141,11 +145,30 @@ export const make = Effect.gen(function* () {
const firstOnPath = Effect.gen(function* () {
for (const directory of pathEntries(process.env.PATH, ":")) {
const candidate = path.join(directory, "t3");
if (yield* exists(candidate)) return Option.some(candidate);
const regularFile = yield* fs.stat(candidate).pipe(
Effect.map((info) => info.type === "File"),
Effect.orElseSucceed(() => false),
);
if (!regularFile) continue;
const executable = yield* Effect.tryPromise(() =>
NodeFSP.access(candidate, NodeFS.constants.X_OK),
).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
);
if (executable) return Option.some(candidate);
}
return Option.none<string>();
});

/** The `t3` a new shell runs when it is not this app's, on Unix. */
const foreignFirstOnPath = Effect.gen(function* () {
if (windows) return Option.none<string>();
const first = yield* firstOnPath;
if (Option.isNone(first) || (yield* isOurLink(first.value))) return Option.none<string>();
return first;
});

/** Where this app's command is installed now, if anywhere. */
const installedAt = Effect.gen(function* () {
if (windows) {
Expand All @@ -167,12 +190,16 @@ export const make = Effect.gen(function* () {
return { supported: false, installedPath: null, onPath: false } as const;
}
const installed = yield* installedAt;
if (Option.isNone(installed)) return { supported: true, installedPath: null, onPath: false };
// A Windows registry entry does not prove which command a fresh terminal resolves.
// Settings offers the absolute launcher there. Unix can check this process's PATH.
const shadowedBy = yield* foreignFirstOnPath;
const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {};
if (Option.isNone(installed)) {
return { supported: true, installedPath: null, onPath: false, ...shadow };
}
// A Windows registry entry does not prove fresh-terminal command precedence.
// Settings exposes the absolute launcher; Unix can check this process PATH.
const first = windows ? Option.none<string>() : yield* firstOnPath;
const onPath = Option.isSome(first) && (yield* isOurLink(first.value));
return { supported: true, installedPath: installed.value, onPath };
return { supported: true, installedPath: installed.value, onPath, ...shadow };
}).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false })));

/** Writes the launcher if the app has not yet, e.g. when no local backend runs. */
Expand Down Expand Up @@ -204,7 +231,16 @@ export const make = Effect.gen(function* () {
if (Option.isSome(existing)) {
const target = yield* fs.readLink(existing.value).pipe(Effect.option);
if (Option.getOrUndefined(target) === launcher) return yield* state;
// A link to a previous T3 home's launcher: point it at this one instead.
}
// A link behind another `t3` never runs, so installing one would only hide the problem.
const shadowedBy = yield* foreignFirstOnPath;
if (Option.isSome(shadowedBy)) {
return yield* fail(
`Another t3 at ${shadowedBy.value} runs first in a new terminal. Remove it, or run the launcher directly at ${launcher}.`,
);
}
if (Option.isSome(existing)) {
// Check precedence while the previous home's launcher still occupies its PATH slot.
yield* fs
.remove(existing.value)
.pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`)));
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ function layerDesktopWindow(
dispatchMenuAction: () => Effect.void,
dispatchSnapShotEvent: () => Effect.void,
zoomMain: () => Effect.void,
runMainContentsCommand: () => Effect.void,
syncAppearance: Effect.void,
});
}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/backend/DesktopBackendPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
dispatchMenuAction: () => Effect.die("unexpected menu action"),
dispatchSnapShotEvent: () => Effect.void,
zoomMain: () => Effect.die("unexpected zoom"),
runMainContentsCommand: () => Effect.die("unexpected main contents command"),
syncAppearance: Effect.void,
} satisfies DesktopWindow.DesktopWindow["Service"]),
),
Expand Down
9 changes: 7 additions & 2 deletions apps/desktop/src/electron/ElectronDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,10 @@ export class ElectronDialog extends Context.Service<
readonly pickFiles: (
input: ElectronDialogPickFilesInput,
) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;
/** Pass `owner` to attach the box to that window so it cannot fall behind it. */
readonly showMessageBox: (
options: Electron.MessageBoxOptions,
owner?: Electron.BrowserWindow,
) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>;
}
Expand Down Expand Up @@ -164,9 +166,12 @@ export const make = ElectronDialog.of({
});
return result.canceled ? [] : result.filePaths;
}),
showMessageBox: (options) =>
showMessageBox: (options, owner) =>
Effect.tryPromise({
try: () => Electron.dialog.showMessageBox(options),
try: () =>
owner === undefined
? Electron.dialog.showMessageBox(options)
: Electron.dialog.showMessageBox(owner, options),
catch: (cause) =>
new ElectronDialogShowMessageBoxError({
type: options.type ?? null,
Expand Down
22 changes: 20 additions & 2 deletions apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ describe("ElectronShell", () => {
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens the JetBrains Toolbox ssh deep link", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const url =
"jetbrains://gateway/ssh/environment?h=example.com&launchIde=true&ideHint=IU&projectHint=%2Fhome%2Fuser%2Fproject";
const electronShell = yield* ElectronShell.ElectronShell;
const result = yield* electronShell.openExternal(url);

assert.equal(result, true);
assert.deepEqual(openExternalMock.mock.calls, [[url]]);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("does not open editor URLs that mix up link shapes", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
Expand All @@ -114,9 +128,12 @@ describe("ElectronShell", () => {
const results = yield* Effect.all([
electronShell.openExternal("zed://extension/attacker"),
electronShell.openExternal("vscode://ssh/example.com/home/user/project"),
electronShell.openExternal("jetbrains://idea/navigate/reference?project=x&path=y"),
electronShell.openExternal("jetbrains://gateway/ssh/environment?launchIde=true"),
electronShell.openExternal("jetbrains://gateway/com.example.provider/connect?h=x"),
]);

assert.deepEqual(results, [false, false]);
assert.deepEqual(results, [false, false, false, false, false]);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
);
Expand All @@ -134,9 +151,10 @@ describe("ElectronShell", () => {
"vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project",
),
electronShell.openExternal("zed://ssh/user@example.com/home/user/project"),
electronShell.openExternal("jetbrains://user@gateway/ssh/environment?h=example.com"),
]);

assert.deepEqual(results, [false, false, false]);
assert.deepEqual(results, [false, false, false, false]);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
);
Expand Down
Loading
Loading