diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 9bc321dac..38a764eab 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -9,6 +9,7 @@ body:
attributes:
value: |
Use this form for broken behavior, regressions, crashes, or reliability problems.
+ Feature requests belong in [Discussions](https://github.com/pingdotgg/t3code/discussions/categories/ideas).
Search existing issues first and keep the report focused on one problem.
- type: checkboxes
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..4f4940ba6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Feature request
+ url: https://github.com/pingdotgg/t3code/discussions/categories/ideas
+ about: Suggest an improvement or new capability in Discussions.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
deleted file mode 100644
index 3c9424fb3..000000000
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ /dev/null
@@ -1,102 +0,0 @@
-name: Feature request
-description: Propose a scoped improvement or new capability.
-title: "[Feature]: "
-labels:
- - enhancement
- - needs-triage
-body:
- - type: markdown
- attributes:
- value: |
- Use this form for new capabilities or meaningful improvements to existing behavior.
- This repo is still early. Small, concrete requests that clearly explain the problem and scope are much easier to evaluate.
-
- - type: checkboxes
- id: checks
- attributes:
- label: Before submitting
- options:
- - label: I searched existing issues and did not find a duplicate.
- required: true
- - label: I am describing a concrete problem or use case, not just a vague idea.
- required: true
-
- - type: dropdown
- id: area
- attributes:
- label: Area
- description: Which part of the project would this change affect?
- options:
- - apps/web
- - apps/server
- - apps/desktop
- - apps/mobile
- - packages/contracts or packages/shared
- - Build, CI, or release tooling
- - Docs
- - Not sure
- validations:
- required: true
-
- - type: textarea
- id: problem
- attributes:
- label: Problem or use case
- description: What are you trying to do? What is hard, slow, or impossible today?
- placeholder: I want to reconnect to an existing provider session after a browser refresh without losing the current thread state.
- validations:
- required: true
-
- - type: textarea
- id: proposal
- attributes:
- label: Proposed solution
- description: Describe the behavior, API, or UX you want.
- placeholder: Persist enough session metadata so the client can discover and reattach to the active provider session on load.
- validations:
- required: true
-
- - type: textarea
- id: value
- attributes:
- label: Why this matters
- description: Who benefits, and what outcome does this unlock?
- placeholder: This would make reconnects predictable during network drops and reduce accidental duplicate sessions.
- validations:
- required: true
-
- - type: textarea
- id: scope
- attributes:
- label: Smallest useful scope
- description: What is the narrowest version of this request that would still solve your problem?
- placeholder: A first pass only needs to support restoring the active session for the current thread.
- validations:
- required: true
-
- - type: textarea
- id: alternatives
- attributes:
- label: Alternatives considered
- description: Workarounds, prior art, or other approaches you considered.
- placeholder: I currently work around this by manually restarting the provider session, but that loses in-flight context.
-
- - type: textarea
- id: tradeoffs
- attributes:
- label: Risks or tradeoffs
- description: What costs, complexity, or edge cases should be considered?
- placeholder: This may require careful handling when the underlying provider session has already exited.
-
- - type: textarea
- id: references
- attributes:
- label: Examples or references
- description: Links, screenshots, mockups, or comparable tools.
-
- - type: checkboxes
- id: contribution
- attributes:
- label: Contribution
- options:
- - label: I would be open to helping implement this.
diff --git a/.github/pr-assets/6424-after.svg b/.github/pr-assets/6424-after.svg
new file mode 100644
index 000000000..dbeb594a0
--- /dev/null
+++ b/.github/pr-assets/6424-after.svg
@@ -0,0 +1 @@
+
diff --git a/.github/pr-assets/6424-before.svg b/.github/pr-assets/6424-before.svg
new file mode 100644
index 000000000..6b365bad6
--- /dev/null
+++ b/.github/pr-assets/6424-before.svg
@@ -0,0 +1 @@
+
diff --git a/.github/pr-assets/6503-after.svg b/.github/pr-assets/6503-after.svg
new file mode 100644
index 000000000..db1c9cb54
--- /dev/null
+++ b/.github/pr-assets/6503-after.svg
@@ -0,0 +1 @@
+
diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md
new file mode 100644
index 000000000..8ec720742
--- /dev/null
+++ b/.macroscope/check-run-agents/ui-consistency.md
@@ -0,0 +1,82 @@
+---
+title: UI Consistency
+model: claude-opus-5
+effort: high
+input: full_diff
+tools:
+ - browse_code
+ - git_tools
+ - github_api_read_only
+ - modify_pr
+include:
+ - "apps/web/src/**/*.ts"
+ - "apps/web/src/**/*.tsx"
+ - "apps/web/src/**/*.css"
+conclusion: failure
+showToolCalls: true
+---
+
+# UI consistency review
+
+Review changed web UI code and directly affected call sites for consistency with the shared component system, Tailwind ownership, and the behavioral constraints below. Apply these rules when a pull request creates, moves, or modifies controls or styling. Do not demand unrelated repository-wide cleanup.
+
+The goal is not to minimize CSS or class counts at any cost. The goal is to put each behavior in the smallest correct owner while preserving interaction, theming, accessibility, layout, and browser behavior.
+
+## Shared controls and variants
+
+- Prefer the core UI primitives in `apps/web/src/components/ui` over native controls or locally reconstructed primitives. In ordinary product UI, a raw `` that recreates `Button`, a raw input that recreates `Input` or `InputGroup`, or a local trigger that recreates `Select`, `Toggle`, or `Menu` is a concrete finding.
+- Do not flag raw elements that intentionally implement a semantic row, tab, resize handle, swatch, image target, editor surface, or another interaction whose behavior or geometry differs from the core primitive.
+- When multiple call sites repeat the same durable geometry or treatment, prefer a named primitive size or variant. Examples include compact controls, micro icon actions, muted ghost actions, and glass actions. Keep contextual layout, width, and color at the call site.
+- Flag large call-site class strings that override a primitive's core height, radius, padding, focus ring, cursor, hit target, or base state colors. Prefer extending the primitive contract when the same pattern is genuinely shared.
+- Preserve accessibility and interaction semantics during migrations: focus-visible rings, disabled behavior, loading state, keyboard behavior, pointer cursor, `aria-*`, Base UI or Radix render/close props, and coarse-pointer hit targets.
+- Do not require tests for a tiny visual-only class migration. Require focused tests when primitive composition changes behavior, prop forwarding, state transitions, keyboard handling, or width/defaulting logic.
+
+## CSS and Tailwind ownership
+
+- Ordinary one-owner presentation belongs in the owning TS or TSX module as static Tailwind classes or owner-level CSS variables. Examples include local geometry, spacing, typography, backgrounds, borders, simple vendor pseudo-elements, and component-only positioning.
+- Keep global CSS when it is genuinely reusable or behaviorally complex: generated markdown or imperative DOM, custom elements and shadow roots, masks, shared or complex pseudo-elements, animations, glass composition, runtime theme variables, safe-area calculations, scrollbar-lane preservation, Electron drag regions, and browser/vendor integration. Simple owner-local pseudo-elements may still belong in the owning module.
+- Before calling a selector dead, trace literal, dynamic, generated, imperative, test, custom-element, and shadow-root consumers. Search both the emitted class string and any class-valued field names through their final DOM sink. A helper returning a class is not proof that it is rendered, and a missed downstream property read can make a deletion unsafe.
+- Flag duplicate declarations only after comparing cascade layer, selector specificity, inheritance, runtime theme scope, media/variant scope, and the final owning element. Textually identical declarations are not necessarily behaviorally redundant.
+- When moving CSS into Tailwind, preserve selector scope and cascade ownership. A utility at the owner is preferable to a fragile global override that depends on stylesheet order.
+- Do not request moving complex global behavior into arbitrary Tailwind merely to reduce `index.css`. Do not preserve ordinary one-owner CSS merely because it already exists globally.
+
+## Themes and generated CSS
+
+- Use the project variants for theme-only declarations:
+ - dark-only declarations use `@variant dark`;
+ - light-only declarations use `@variant light`;
+ - raw `.dark` should remain only in the `dark` and `light` custom-variant definitions.
+- Preserve custom themes and runtime token bridges. Removing a variable or selector is safe only when all runtime, inspector, generated, and theme-palette consumers are accounted for.
+- Inspect emitted production CSS after unusual variants, arbitrary selectors, nested pseudo-elements, or attribute matching. Source syntax that looks valid is insufficient.
+- Flag malformed or empty emitted selectors such as empty `:is()` or `:not(:is())`, selector branches that can never match their own class attribute, and transformations that silently drop the intended rule.
+- Prefer source-level logic over clever selectors when behavior depends on consumer-provided class strings. Preserve `MenuPopup`'s current defaulting contract: a string `className` containing a `w-*`, `min-w-*`, or `max-w-*` utility after variant prefixes are stripped suppresses `min-w-32`; a string without one and a functional/non-string `className` keep the default. Arbitrary width values count as width utilities, and the consumer class must be merged last so it retains control. Do not replace this with a raw class-attribute substring selector.
+- Do not fail solely because a valid emitted selector is verbose or because a source rule uses an intentional custom property.
+
+## Scroll and virtualized lists
+
+- `ScrollArea` owns and masks its Base UI viewport. A virtualized list usually owns a native scrolling element and cannot automatically reuse viewport-specific `ScrollArea` behavior.
+- Repeated native or virtualized overflow fades should use the shared virtualized-scroll-fade contract rather than component-named mask selectors.
+- Preserve runtime top and bottom overflow state. Do not replace dynamic fades with an always-on static mask.
+- Preserve fade geometry and keep the native scrollbar lane opaque so the track and thumb stay visible and usable. A visually similar mask that fades the scrollbar is a regression.
+- Verify actual scroll behavior when changing virtualizers, masks, overflow ownership, or scrollbar selectors. Source-level class comparison is not enough.
+
+## Visual and layout preservation
+
+- Preserve responsive geometry, titlebar insets, panel and inline-preview modes, desktop Electron layout, light and dark contrast, clipping, radius, and composable shadows.
+- For meaningful visual changes, prefer available real-app evidence using the actual component and state. A mock recreation does not validate the real component. Light and dark evidence is useful when theme-sensitive styles change, but missing or inaccessible evidence alone is not a finding; report only a concrete regression supported by the diff, code, or available artifacts.
+- Do not treat a screenshot as proof of keyboard, overflow, scrollbar, responsive, or runtime-theme behavior. Pair visual evidence with source, computed-style, emitted-CSS, or interaction checks as appropriate.
+- Be alert to shared primitive color indirection. When a primitive routes icon color through a CSS variable, ensure migrated contextual icons retain their intended tone, including pressed and disabled states.
+
+## Change discipline
+
+- Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup.
+- Prefer the smallest durable contract over a component-specific workaround or a broad abstraction with one consumer.
+- Preserve intentional exceptions and comments that explain browser, virtualizer, theme, or Electron constraints.
+- If a proposed cleanup cannot prove ownership or semantic equivalence, ask for evidence or leave it unchanged rather than guessing.
+- Select verification gates according to the changed behavior: typecheck or focused tests for typing and interaction contracts, production build and emitted-CSS inspection for Tailwind or selector transformations, and real-app evidence for meaningful visual behavior when available. These gates are complementary when applicable, but do not require every gate for tiny visual-only migrations or fail solely because an artifact the configured tools cannot produce is absent.
+
+## Reporting
+
+Report only concrete violations introduced by changed lines or behavior, plus pre-existing behavior that the patch directly makes relevant or worsens. Touching a large file does not make unrelated retained issues reportable. Prefer precise inline comments on the smallest relevant line range. Explain the broken behavior or ownership rule, not merely the preferred syntax, and state the smallest expected fix. A clear consistency or regression risk may fail the check. Do not fail for optional aesthetic preferences, harmless class ordering, or unrelated legacy code.
+
+This check defaults to failure. When there are no findings, stop immediately and make the entire final response exactly `All clear` on one line. Do not add a title, explanation, punctuation, Markdown, JSON, or trailing analysis, and do not continue reasoning after deciding the review is clean.
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 120000
index c31706425..000000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1 +0,0 @@
-AGENTS.md
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..43c994c2d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8b734a99b..e8e2f9b11 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,7 +4,9 @@
We are not actively accepting contributions right now.
-You can still open an issue or PR, but please do so knowing there is a high chance we close it, defer it forever, or never look at it.
+You can still report a bug or open a PR, but please do so knowing there is a high chance we close it, defer it forever, or never look at it.
+
+Feature requests and proposals belong in [Ideas discussions](https://github.com/pingdotgg/t3code/discussions/categories/ideas), not issues.
If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control.
@@ -50,9 +52,9 @@ If the change depends on motion, timing, transitions, or interaction details, in
If we have to guess what changed, we are much less likely to review it.
-## Issues First
+## Discuss Changes First
-If you are thinking about a non-trivial change, open an issue first.
+If you are thinking about a non-trivial change, start a discussion first. Issues are reserved for bug reports.
That still does not mean we will want the PR, but it gives you a chance to avoid wasting your time.
diff --git a/apps/desktop/resources/dmg/dmg-background-canary.svg b/apps/desktop/resources/dmg/dmg-background-canary.svg
new file mode 100644
index 000000000..80c78cdfe
--- /dev/null
+++ b/apps/desktop/resources/dmg/dmg-background-canary.svg
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ T3 CODE
+ Desktop
+ CANARY BUILD
+
+
+
+
+
+
+
+ Drag T3 Code to Applications
+ Open it from Applications when the copy finishes.
+
+
diff --git a/apps/desktop/resources/dmg/dmg-background-latest.svg b/apps/desktop/resources/dmg/dmg-background-latest.svg
new file mode 100644
index 000000000..132d829b1
--- /dev/null
+++ b/apps/desktop/resources/dmg/dmg-background-latest.svg
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ T3 CODE
+ Desktop
+ LATEST RELEASE
+
+
+
+
+
+
+
+ Drag T3 Code to Applications
+ Open it from Applications when the copy finishes.
+
+
diff --git a/apps/desktop/resources/dmg/dmg-background-nightly.svg b/apps/desktop/resources/dmg/dmg-background-nightly.svg
new file mode 100644
index 000000000..8df5e4c07
--- /dev/null
+++ b/apps/desktop/resources/dmg/dmg-background-nightly.svg
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ T3 CODE
+ Desktop
+ NIGHTLY BUILD
+
+
+
+
+
+
+
+ Drag T3 Code to Applications
+ Open it from Applications when the copy finishes.
+
+
diff --git a/apps/desktop/resources/icon.icns b/apps/desktop/resources/icon.icns
deleted file mode 100644
index da16d12a0..000000000
Binary files a/apps/desktop/resources/icon.icns and /dev/null differ
diff --git a/apps/desktop/resources/icon.ico b/apps/desktop/resources/icon.ico
deleted file mode 100644
index 8298f70d8..000000000
Binary files a/apps/desktop/resources/icon.ico and /dev/null differ
diff --git a/apps/desktop/resources/icon.png b/apps/desktop/resources/icon.png
deleted file mode 100644
index 37f3f756a..000000000
Binary files a/apps/desktop/resources/icon.png and /dev/null differ
diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs
index 929afeeab..07fb87b05 100644
--- a/apps/desktop/scripts/electron-launcher.mjs
+++ b/apps/desktop/scripts/electron-launcher.mjs
@@ -20,14 +20,14 @@ export const APP_BUNDLE_ID = isDevelopment
? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}`
: "com.t3tools.t3code";
const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];
-const LAUNCHER_VERSION = 14;
-const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns");
+const LAUNCHER_VERSION = 15;
const developmentMacIconPngPath = NodePath.join(
repoRoot,
"assets",
"dev",
"blueprint-macos-1024.png",
);
+const productionMacIconPngPath = NodePath.join(repoRoot, "assets", "prod", "black-macos-1024.png");
// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime.
const hostPlatform = NodeOS.platform();
@@ -165,15 +165,22 @@ function registerMacLauncherBundle(appBundlePath) {
}
}
-function ensureDevelopmentIconIcns(runtimeDir) {
- const generatedIconPath = NodePath.join(runtimeDir, "icon-dev.icns");
+export function resolveMacLauncherIconPaths(runtimeDir, development = isDevelopment) {
+ return {
+ sourceIconPath: development ? developmentMacIconPngPath : productionMacIconPngPath,
+ generatedIconPath: NodePath.join(runtimeDir, development ? "icon-dev.icns" : "icon-prod.icns"),
+ };
+}
+
+function ensureMacIconIcns(runtimeDir) {
+ const { sourceIconPath, generatedIconPath } = resolveMacLauncherIconPaths(runtimeDir);
NodeFS.mkdirSync(runtimeDir, { recursive: true });
- if (!NodeFS.existsSync(developmentMacIconPngPath)) {
- return defaultIconPath;
+ if (!NodeFS.existsSync(sourceIconPath)) {
+ throw new Error(`Desktop macOS icon source is missing at ${sourceIconPath}`);
}
- const sourceMtimeMs = NodeFS.statSync(developmentMacIconPngPath).mtimeMs;
+ const sourceMtimeMs = NodeFS.statSync(sourceIconPath).mtimeMs;
if (
NodeFS.existsSync(generatedIconPath) &&
NodeFS.statSync(generatedIconPath).mtimeMs >= sourceMtimeMs
@@ -191,7 +198,7 @@ function ensureDevelopmentIconIcns(runtimeDir) {
"-z",
String(size),
String(size),
- developmentMacIconPngPath,
+ sourceIconPath,
"--out",
NodePath.join(iconsetDir, `icon_${size}x${size}.png`),
]);
@@ -201,7 +208,7 @@ function ensureDevelopmentIconIcns(runtimeDir) {
"-z",
String(retinaSize),
String(retinaSize),
- developmentMacIconPngPath,
+ sourceIconPath,
"--out",
NodePath.join(iconsetDir, `icon_${size}x${size}@2x.png`),
]);
@@ -209,12 +216,6 @@ function ensureDevelopmentIconIcns(runtimeDir) {
runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]);
return generatedIconPath;
- } catch (error) {
- console.warn(
- "[desktop-launcher] Failed to generate dev macOS icon, falling back to default icon.",
- error,
- );
- return defaultIconPath;
} finally {
NodeFS.rmSync(iconsetRoot, { recursive: true, force: true });
}
@@ -297,7 +298,7 @@ function buildMacLauncher(electronBinaryPath) {
const launcherBinaryPath = isDevelopment
? developmentPaths.launcherBinaryPath
: runtimeElectronBinaryPath;
- const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath;
+ const iconPath = ensureMacIconIcns(runtimeDir);
const metadataPath = NodePath.join(runtimeDir, "metadata.json");
NodeFS.mkdirSync(runtimeDir, { recursive: true });
diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs
index 1c82167ea..1ed5a1b8e 100644
--- a/apps/desktop/scripts/electron-launcher.test.mjs
+++ b/apps/desktop/scripts/electron-launcher.test.mjs
@@ -3,6 +3,7 @@ import { assert, describe, it } from "vite-plus/test";
import {
makeDevelopmentLauncherScript,
resolveElectronBinaryPath,
+ resolveMacLauncherIconPaths,
resolveMacLauncherPaths,
} from "./electron-launcher.mjs";
@@ -78,4 +79,14 @@ describe("electron development launcher", () => {
);
assert.notInclude(script, "node_modules/electron");
});
+
+ it("derives launcher icons from canonical development and production assets", () => {
+ const development = resolveMacLauncherIconPaths("/runtime", true);
+ const production = resolveMacLauncherIconPaths("/runtime", false);
+
+ assert.match(development.sourceIconPath, /assets\/dev\/blueprint-macos-1024\.png$/);
+ assert.equal(development.generatedIconPath, "/runtime/icon-dev.icns");
+ assert.match(production.sourceIconPath, /assets\/prod\/black-macos-1024\.png$/);
+ assert.equal(production.generatedIconPath, "/runtime/icon-prod.icns");
+ });
});
diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts
index de945054c..da767a037 100644
--- a/apps/desktop/src/app/DesktopAppIdentity.test.ts
+++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts
@@ -40,6 +40,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
+ systemLocale: Effect.succeed("en-US"),
whenReady: Effect.void,
quit: Effect.void,
exit: () => Effect.void,
diff --git a/apps/desktop/src/app/DesktopAssets.test.ts b/apps/desktop/src/app/DesktopAssets.test.ts
index 2eb55c720..bb118d43d 100644
--- a/apps/desktop/src/app/DesktopAssets.test.ts
+++ b/apps/desktop/src/app/DesktopAssets.test.ts
@@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
import * as PlatformError from "effect/PlatformError";
import * as DesktopAssets from "./DesktopAssets.ts";
@@ -22,6 +23,45 @@ const environmentLayer = DesktopEnvironment.layer({
}).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))));
describe("DesktopAssets", () => {
+ it.effect("uses canonical source-tree icons for unpackaged development", () =>
+ Effect.gen(function* () {
+ const developmentEnvironmentLayer = DesktopEnvironment.layer({
+ dirname: "/repo/apps/desktop/dist-electron",
+ homeDirectory: "/Users/alice",
+ platform: "linux",
+ processArch: "x64",
+ appVersion: "1.2.3",
+ appPath: "/repo",
+ isPackaged: false,
+ resourcesPath: "/repo/apps/desktop/resources",
+ runningUnderArm64Translation: false,
+ }).pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ NodeServices.layer,
+ DesktopConfig.layerTest({ VITE_DEV_SERVER_URL: "http://localhost:5733" }),
+ ),
+ ),
+ );
+ const fileSystemLayer = FileSystem.layerNoop({
+ exists: (path) => Effect.succeed(String(path).includes("/assets/dev/")),
+ });
+ const assets = yield* DesktopAssets.DesktopAssets.pipe(
+ Effect.provide(
+ DesktopAssets.layer.pipe(
+ Layer.provide(Layer.merge(fileSystemLayer, developmentEnvironmentLayer)),
+ ),
+ ),
+ );
+
+ const icons = yield* assets.iconPaths;
+
+ assert.match(Option.getOrThrow(icons.ico), /assets\/dev\/blueprint-windows\.ico$/);
+ assert.match(Option.getOrThrow(icons.png), /assets\/dev\/blueprint-universal-1024\.png$/);
+ assert.isTrue(Option.isNone(icons.icns));
+ }),
+ );
+
it.effect("preserves the failed asset candidate and filesystem cause", () =>
Effect.gen(function* () {
const fileName = "custom.bin";
diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts
index 95585acab..f1c6f1bb8 100644
--- a/apps/desktop/src/app/DesktopAssets.ts
+++ b/apps/desktop/src/app/DesktopAssets.ts
@@ -61,6 +61,35 @@ const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(func
return Option.none();
});
+const sourceTreeIconFileNames = {
+ dev: {
+ ico: "blueprint-windows.ico",
+ macPng: "blueprint-macos-1024.png",
+ universalPng: "blueprint-universal-1024.png",
+ },
+ prod: {
+ ico: "t3-black-windows.ico",
+ macPng: "black-macos-1024.png",
+ universalPng: "black-universal-1024.png",
+ },
+} as const;
+
+function resolveSourceTreeIconPath(
+ environment: DesktopEnvironment.DesktopEnvironment["Service"],
+ ext: keyof DesktopIconPaths,
+): string | undefined {
+ if (environment.isPackaged || ext === "icns") return undefined;
+ const brand = environment.isDevelopment ? "dev" : "prod";
+ const fileNames = sourceTreeIconFileNames[brand];
+ const fileName =
+ ext === "ico"
+ ? fileNames.ico
+ : environment.platform === "darwin"
+ ? fileNames.macPng
+ : fileNames.universalPng;
+ return environment.path.join(environment.rootDir, "assets", brand, fileName);
+}
+
const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (
ext: keyof DesktopIconPaths,
): Effect.fn.Return<
@@ -70,20 +99,20 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (
> {
const fileSystem = yield* FileSystem.FileSystem;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
- if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") {
- const developmentDockIconPath = environment.developmentDockIconPath;
- const developmentDockIconExists = yield* fileSystem.exists(developmentDockIconPath).pipe(
+ const sourceTreeIconPath = resolveSourceTreeIconPath(environment, ext);
+ if (sourceTreeIconPath !== undefined) {
+ const sourceTreeIconExists = yield* fileSystem.exists(sourceTreeIconPath).pipe(
Effect.mapError(
(cause) =>
new DesktopAssetProbeError({
- fileName: "icon.png",
- candidatePath: developmentDockIconPath,
+ fileName: `icon.${ext}`,
+ candidatePath: sourceTreeIconPath,
cause,
}),
),
);
- if (developmentDockIconExists) {
- return Option.some(developmentDockIconPath);
+ if (sourceTreeIconExists) {
+ return Option.some(sourceTreeIconPath);
}
}
diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts
index d9a843b1e..c40ec4131 100644
--- a/apps/desktop/src/app/DesktopEnvironment.ts
+++ b/apps/desktop/src/app/DesktopEnvironment.ts
@@ -83,7 +83,6 @@ export class DesktopEnvironment extends Context.Service<
readonly runtimeInfo: DesktopRuntimeInfo;
readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option;
readonly resolveResourcePathCandidates: (fileName: string) => readonly string[];
- readonly developmentDockIconPath: string;
}
>()("@t3tools/desktop/app/DesktopEnvironment") {}
@@ -282,7 +281,6 @@ const make = Effect.fn("desktop.environment.make")(function* (
path.join(resourcesPath, "resources", fileName),
path.join(resourcesPath, fileName),
],
- developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"),
});
});
diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts
index 45e1c8246..eb045be28 100644
--- a/apps/desktop/src/app/DesktopLifecycle.test.ts
+++ b/apps/desktop/src/app/DesktopLifecycle.test.ts
@@ -21,6 +21,7 @@ describe("DesktopLifecycle", () => {
const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
+ systemLocale: Effect.succeed("en-US"),
whenReady: Effect.void,
quit: Effect.void,
exit: () => Effect.void,
diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts
index a32caa1fd..3efc81ed5 100644
--- a/apps/desktop/src/backend/DesktopBackendManager.test.ts
+++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts
@@ -701,6 +701,84 @@ describe("DesktopBackendManager", () => {
),
);
+ it.effect(
+ "re-probes readiness after the first budget expires while the backend is still alive",
+ () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const requestUrls: Array = [];
+ let requestCount = 0;
+ let readyCount = 0;
+ let readinessTimeoutCount = 0;
+ const firstProbe = yield* Deferred.make();
+ const childExit = yield* Deferred.make();
+
+ const spawnerLayer = Layer.succeed(
+ ChildProcessSpawner.ChildProcessSpawner,
+ ChildProcessSpawner.make(() =>
+ Effect.succeed(
+ makeProcess({
+ exitCode: Deferred.await(childExit).pipe(
+ Effect.as(ChildProcessSpawner.ExitCode(0)),
+ ),
+ }),
+ ),
+ ),
+ );
+
+ // The backend stays 503 through the first *two* readiness budgets
+ // and only becomes healthy (200) for the third round, i.e. it comes
+ // up well after the initial 50ms budget has expired.
+ const httpLayer = httpClientLayer((request) =>
+ Effect.gen(function* () {
+ requestCount += 1;
+ requestUrls.push(request.url);
+ yield* Deferred.succeed(firstProbe, void 0);
+ return responseForRequest(request, requestCount <= 2 ? 503 : 200);
+ }),
+ );
+
+ const runFiber = yield* DesktopBackendManager.runBackendProcess({
+ ...baseConfig,
+ desktopTelemetryStream: Stream.empty,
+ readinessTimeout: Duration.millis(50),
+ onReady: () =>
+ Effect.sync(() => {
+ readyCount += 1;
+ }),
+ onReadinessFailure: () =>
+ Effect.sync(() => {
+ readinessTimeoutCount += 1;
+ }),
+ }).pipe(Effect.provide(Layer.merge(spawnerLayer, httpLayer)), Effect.forkChild);
+
+ yield* Deferred.await(firstProbe);
+ assert.equal(readyCount, 0);
+ assert.equal(readinessTimeoutCount, 0);
+
+ // The first 50ms readiness budget expires while the backend still
+ // answers 503. The child is alive and may yet become healthy, so the
+ // probe must start a fresh round instead of stopping permanently —
+ // the pre-fix behavior left the app stuck on "Connecting to WSL…"
+ // forever even though the backend kept running.
+ yield* TestClock.adjust(Duration.millis(50));
+ assert.equal(readinessTimeoutCount, 1);
+ assert.equal(readyCount, 0);
+
+ // The second budget also expires (backend still 503), then the third
+ // round connects. The point is the probe persisted across budgets
+ // while the process was alive instead of giving up after the first.
+ yield* TestClock.adjust(Duration.millis(100));
+ assert.equal(readinessTimeoutCount, 2);
+ assert.equal(readyCount, 1);
+ assert.equal(requestUrls.length, 3);
+
+ yield* Deferred.succeed(childExit, void 0);
+ assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 0);
+ }).pipe(Effect.provide(TestClock.layer())),
+ ),
+ );
+
it.effect("starts the configured backend and closes the scoped process on stop", () =>
Effect.scoped(
Effect.gen(function* () {
diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts
index de785ce04..7a1e4bf50 100644
--- a/apps/desktop/src/backend/DesktopBackendManager.ts
+++ b/apps/desktop/src/backend/DesktopBackendManager.ts
@@ -563,20 +563,33 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
).pipe(Effect.forkScoped),
);
}
- yield* waitForHttpReady({
- executablePath: options.executablePath,
- entryPath: options.entryPath,
- cwd: options.cwd,
- httpBaseUrl: options.httpBaseUrl,
- timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
- }).pipe(
- Effect.tap(() => options.onReady?.() ?? Effect.void),
- Effect.catchTags({
- BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void,
- }),
- Effect.forkScoped,
+ // Probe readiness in a loop while the backend process is still alive
+ // instead of giving up after the first budget. A slow cold boot (the
+ // WSL bundle loading across /mnt/c, or a first launch right after an
+ // update) can exceed the initial readiness budget while the backend is
+ // about to come up moments later; a one-shot probe left the app stuck
+ // on "Connecting to WSL…" forever even though the backend kept running
+ // and became healthy. Each round gets a fresh budget, and the forked
+ // loop is torn down with the run scope once the child exits.
+ const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() =>
+ waitForHttpReady({
+ executablePath: options.executablePath,
+ entryPath: options.entryPath,
+ cwd: options.cwd,
+ httpBaseUrl: options.httpBaseUrl,
+ timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
+ }).pipe(
+ Effect.flatMap(() => options.onReady?.() ?? Effect.void),
+ Effect.as(true),
+ Effect.catchTags({
+ BackendReadinessTimeoutError: (error) =>
+ (options.onReadinessFailure?.(error) ?? Effect.void).pipe(Effect.as(false)),
+ }),
+ ),
);
+ yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped);
+
const exit = yield* handle.exitCode.pipe(
Effect.mapError(
(cause) =>
diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts
index 28bf211f0..e8216ea99 100644
--- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts
+++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts
@@ -5,7 +5,6 @@ import { HttpClient } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";
import {
- isTailscaleIpv4Address,
parseTailscaleMagicDnsName,
resolveTailscaleAdvertisedEndpoints,
} from "./tailscaleEndpointProvider.ts";
@@ -22,13 +21,6 @@ const unusedTailscaleExternalServicesLayer = Layer.mergeAll(
);
describe("tailscale endpoint provider", () => {
- it("detects Tailnet IPv4 addresses", () => {
- assert.equal(isTailscaleIpv4Address("100.64.0.1"), true);
- assert.equal(isTailscaleIpv4Address("100.127.255.254"), true);
- assert.equal(isTailscaleIpv4Address("100.128.0.1"), false);
- assert.equal(isTailscaleIpv4Address("192.168.1.44"), false);
- });
-
it.effect("parses MagicDNS names from tailscale status", () =>
Effect.gen(function* () {
const dnsName = yield* parseTailscaleMagicDnsName(
diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts
index e0d229497..4189ea793 100644
--- a/apps/desktop/src/electron/ElectronApp.test.ts
+++ b/apps/desktop/src/electron/ElectronApp.test.ts
@@ -8,6 +8,7 @@ const {
autoUpdaterRemoveListenerMock,
exitMock,
getAppPathMock,
+ getSystemLocaleMock,
getVersionMock,
isDefaultProtocolClientMock,
onMock,
@@ -29,6 +30,7 @@ const {
autoUpdaterRemoveListenerMock: vi.fn(),
exitMock: vi.fn(),
getAppPathMock: vi.fn(() => "/app"),
+ getSystemLocaleMock: vi.fn(() => "en-GB"),
getVersionMock: vi.fn(() => "1.2.3"),
isDefaultProtocolClientMock: vi.fn(() => false),
onMock: vi.fn(),
@@ -60,6 +62,7 @@ vi.mock("electron", () => ({
setIcon: setDockIconMock,
},
getAppPath: getAppPathMock,
+ getSystemLocale: getSystemLocaleMock,
getVersion: getVersionMock,
isDefaultProtocolClient: isDefaultProtocolClientMock,
isPackaged: true,
@@ -111,6 +114,23 @@ describe("ElectronApp", () => {
}).pipe(Effect.provide(ElectronApp.layer)),
);
+ it.effect("reads the OS locale through the service", () =>
+ Effect.gen(function* () {
+ const electronApp = yield* ElectronApp.ElectronApp;
+
+ assert.strictEqual(yield* electronApp.systemLocale, "en-GB");
+ }).pipe(Effect.provide(ElectronApp.layer)),
+ );
+
+ it.effect("normalizes POSIX-style locale identifiers that Intl rejects", () =>
+ Effect.gen(function* () {
+ getSystemLocaleMock.mockImplementationOnce(() => "en_GB");
+ const electronApp = yield* ElectronApp.ElectronApp;
+
+ assert.strictEqual(yield* electronApp.systemLocale, "en-GB");
+ }).pipe(Effect.provide(ElectronApp.layer)),
+ );
+
it.effect("reports which app metadata property failed", () =>
Effect.gen(function* () {
const cause = new Error("version unavailable");
diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts
index 6fb84c53b..5a6f16ae8 100644
--- a/apps/desktop/src/electron/ElectronApp.ts
+++ b/apps/desktop/src/electron/ElectronApp.ts
@@ -43,6 +43,13 @@ export class ElectronApp extends Context.Service<
{
readonly metadata: Effect.Effect;
readonly name: Effect.Effect;
+ /**
+ * The OS locale, read from the operating system rather than from Chromium's
+ * resolved application locale — the packaged app ships only the `en-US`
+ * locale pak, so `app.getLocale()` and the renderer's `Intl` default are
+ * pinned to `en-US` however the machine is configured.
+ */
+ readonly systemLocale: Effect.Effect;
readonly whenReady: Effect.Effect;
readonly quit: Effect.Effect;
readonly exit: (code: number) => Effect.Effect;
@@ -119,6 +126,10 @@ export const make = ElectronApp.of({
};
}),
name: Effect.sync(() => Electron.app.name),
+ // macOS derives this from NSLocale, which uses POSIX-style identifiers
+ // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so
+ // the tag is normalized here rather than in the renderer that consumes it.
+ systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")),
whenReady: Effect.gen(function* () {
const isPackaged = Electron.app.isPackaged;
yield* Effect.tryPromise({
diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts
index 316d3138b..126be71b6 100644
--- a/apps/desktop/src/electron/ElectronShell.ts
+++ b/apps/desktop/src/electron/ElectronShell.ts
@@ -1,3 +1,4 @@
+import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
@@ -5,7 +6,16 @@ import * as Option from "effect/Option";
import * as Electron from "electron";
-const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]);
+// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`)
+// must reach the OS handler; every other non-web scheme stays blocked.
+const SAFE_EXTERNAL_PROTOCOLS = new Set([
+ "http:",
+ "https:",
+ ...REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
+ const scheme = remoteSchemeForEditor(id);
+ return scheme === undefined ? [] : [`${scheme}:`];
+ }),
+]);
export function parseSafeExternalUrl(rawUrl: unknown): Option.Option {
if (typeof rawUrl !== "string") {
diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts
index f292b05d9..8019eec90 100644
--- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts
+++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts
@@ -35,8 +35,10 @@ import {
getAppBranding,
getLocalEnvironmentBootstraps,
getLocalEnvironmentBearerToken,
+ getSystemLocale,
getWindowFullscreenState,
openExternal,
+ probeRemoteEditors,
pickFolder,
pickThemeFiles,
setTheme,
@@ -50,6 +52,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* PreviewIpc.installPreviewEventForwarding();
yield* ipc.handleSync(getAppBranding);
+ yield* ipc.handleSync(getSystemLocale);
yield* ipc.handleSync(getWindowFullscreenState);
yield* ipc.handleSync(getLocalEnvironmentBootstraps);
yield* ipc.handle(setLocalBackendEnabled);
@@ -85,6 +88,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(setTheme);
yield* ipc.handle(showContextMenu);
yield* ipc.handle(openExternal);
+ yield* ipc.handle(probeRemoteEditors);
yield* ipc.handle(getUpdateState);
yield* ipc.handle(setUpdateChannel);
yield* ipc.handle(downloadUpdate);
diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts
index c66ab0c9d..857c964aa 100644
--- a/apps/desktop/src/ipc/channels.ts
+++ b/apps/desktop/src/ipc/channels.ts
@@ -3,7 +3,9 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
export const SET_THEME_CHANNEL = "desktop:set-theme";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
+export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
+export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
export const UPDATE_STATE_CHANNEL = "desktop:update-state";
@@ -13,6 +15,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
+export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";
export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";
export const SET_LOCAL_BACKEND_ENABLED_CHANNEL = "desktop:set-local-backend-enabled";
export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =
diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts
index febdefa98..2453cfc0b 100644
--- a/apps/desktop/src/ipc/methods/preview.ts
+++ b/apps/desktop/src/ipc/methods/preview.ts
@@ -14,6 +14,7 @@ import {
DesktopPreviewRegisterWebviewInputSchema,
DesktopPreviewScreenshotArtifactSchema,
DesktopPreviewSetColorSchemeInputSchema,
+ DesktopPreviewCreateTabInputSchema,
DesktopPreviewTabInputSchema,
DesktopPreviewWebviewConfigSchema,
PreviewAnnotationSubmissionResultSchema,
@@ -48,11 +49,15 @@ export const installPreviewEventForwarding = Effect.fn(
export const createTab = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL,
- payload: DesktopPreviewTabInputSchema,
+ payload: DesktopPreviewCreateTabInputSchema,
result: Schema.Void,
- handler: Effect.fn("desktop.ipc.preview.createTab")(function* ({ tabId }) {
+ handler: Effect.fn("desktop.ipc.preview.createTab")(function* ({
+ tabId,
+ zoomFactor,
+ colorScheme,
+ }) {
const manager = yield* PreviewManager.PreviewManager;
- yield* manager.createTab(tabId);
+ yield* manager.createTab(tabId, { zoomFactor, colorScheme });
}),
});
diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts
index 7a39eb429..0c7e90b95 100644
--- a/apps/desktop/src/ipc/methods/window.ts
+++ b/apps/desktop/src/ipc/methods/window.ts
@@ -3,12 +3,16 @@ import {
DesktopAppBrandingSchema,
DesktopEnvironmentBootstrapSchema,
DesktopThemeSchema,
+ EDITORS,
+ EditorId,
PickedThemeFileSchema,
PickFolderOptionsSchema,
PRIMARY_LOCAL_ENVIRONMENT_ID,
+ REMOTE_CAPABLE_EDITOR_IDS,
type DesktopEnvironmentBootstrap,
type PickedThemeFile,
} from "@t3tools/contracts";
+import { isCommandAvailable } from "@t3tools/shared/shell";
import * as NodeOS from "node:os";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
@@ -22,6 +26,7 @@ import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
+import * as ElectronApp from "../../electron/ElectronApp.ts";
import * as ElectronDialog from "../../electron/ElectronDialog.ts";
import * as ElectronMenu from "../../electron/ElectronMenu.ts";
import * as ElectronShell from "../../electron/ElectronShell.ts";
@@ -60,6 +65,15 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({
}),
});
+export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({
+ channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL,
+ result: Schema.String,
+ handler: Effect.fn("desktop.ipc.window.getSystemLocale")(function* () {
+ const electronApp = yield* ElectronApp.ElectronApp;
+ return yield* electronApp.systemLocale;
+ }),
+});
+
export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({
channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL,
result: Schema.Boolean,
@@ -261,6 +275,30 @@ export const openExternal = DesktopIpc.makeIpcMethod({
}),
});
+export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
+ channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL,
+ payload: Schema.Undefined,
+ result: Schema.Array(EditorId),
+ // Probes THIS machine (where the renderer runs) for remote-capable editor
+ // CLIs, unlike the server's probe which walks the environment host's PATH.
+ // A Finder-launched app can miss PATH entries; an empty result makes the
+ // renderer fall back to VS Code only, so that fails soft.
+ handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () {
+ const available: Array = [];
+ for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) {
+ const commands = EDITORS.find((editor) => editor.id === editorId)?.commands;
+ if (!commands) continue;
+ for (const command of commands) {
+ if (yield* isCommandAvailable(command, { env: process.env })) {
+ available.push(editorId);
+ break;
+ }
+ }
+ }
+ return available;
+ }),
+});
+
/** 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;
diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts
index 3e07ae7f3..38435e286 100644
--- a/apps/desktop/src/ipc/methods/wsl.test.ts
+++ b/apps/desktop/src/ipc/methods/wsl.test.ts
@@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";
import * as DesktopShutdown from "../../app/DesktopShutdown.ts";
import * as DesktopState from "../../app/DesktopState.ts";
import * as ElectronApp from "../../electron/ElectronApp.ts";
+import * as ElectronDialog from "../../electron/ElectronDialog.ts";
import * as ElectronTheme from "../../electron/ElectronTheme.ts";
+import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
+import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";
import * as DesktopWindow from "../../window/DesktopWindow.ts";
import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
@@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll(
ElectronTheme.ElectronTheme,
ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]),
),
+ Layer.succeed(
+ ElectronDialog.ElectronDialog,
+ ElectronDialog.ElectronDialog.of({} as ElectronDialog.ElectronDialog["Service"]),
+ ),
+ Layer.succeed(
+ ElectronWindow.ElectronWindow,
+ ElectronWindow.ElectronWindow.of({} as ElectronWindow.ElectronWindow["Service"]),
+ ),
+ DesktopClientSettings.layerTest(),
);
describe("WSL IPC", () => {
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
index eeeadd33f..0be6de7d4 100644
--- a/apps/desktop/src/preload.ts
+++ b/apps/desktop/src/preload.ts
@@ -35,6 +35,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {
}
return result as ReturnType;
},
+ getSystemLocale: () => {
+ const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
+ return typeof result === "string" ? result : null;
+ },
getLocalEnvironmentBootstraps: () => {
const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL);
if (!Array.isArray(result)) {
@@ -107,6 +111,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
...(position === undefined ? {} : { position }),
}),
openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
+ probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
onMenuAction: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
if (typeof action !== "string") return;
@@ -118,6 +123,17 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);
};
},
+ onQuitShortcut: (listener) => {
+ const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
+ if (state !== "down" && state !== "up") return;
+ listener(state);
+ };
+
+ ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener);
+ return () => {
+ ipcRenderer.removeListener(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener);
+ };
+ },
getWindowFullscreenState: () =>
ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true,
onWindowFullscreenStateChange: (listener) => {
@@ -149,7 +165,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
};
},
preview: {
- createTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { tabId }),
+ createTab: (tabId, defaults) =>
+ ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, {
+ tabId,
+ zoomFactor: defaults?.zoomFactor,
+ colorScheme: defaults?.colorScheme,
+ }),
closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }),
registerWebview: (tabId, webContentsId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, { tabId, webContentsId }),
diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts
index 00616c6a4..e63597b71 100644
--- a/apps/desktop/src/preview/GuestProtocol.ts
+++ b/apps/desktop/src/preview/GuestProtocol.ts
@@ -4,3 +4,4 @@ export const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";
export const HUMAN_INPUT_CHANNEL = "preview:human-input";
+export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate";
diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts
index c24dca802..c4297a69c 100644
--- a/apps/desktop/src/preview/Manager.test.ts
+++ b/apps/desktop/src/preview/Manager.test.ts
@@ -979,7 +979,10 @@ describe("PreviewManager", () => {
),
);
- effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () =>
+ // The guest reports whatever zoom level Chromium handed it from the app
+ // window, so the tab's own zoom is the source of truth in both directions:
+ // asserted onto every guest, never read back off one.
+ effectIt.effect("keeps the tab's own zoom instead of the guest's reported zoom", () =>
withManager((manager) =>
Effect.gen(function* () {
let effectiveZoom = 0.9;
@@ -1025,18 +1028,13 @@ describe("PreviewManager", () => {
yield* manager.createTab("tab_zoom");
yield* manager.registerWebview("tab_zoom", 42);
- expect(states.at(-1)?.zoomFactor).toBe(0.9);
- expect(setZoomFactor).not.toHaveBeenCalled();
+ expect(states.at(-1)?.zoomFactor).toBe(1);
+ expect(setZoomFactor).toHaveBeenCalledWith(1);
- effectiveZoom = 1.25;
- listeners.get("did-navigate")?.();
- yield* Effect.yieldNow;
-
- expect(states.at(-1)?.zoomFactor).toBe(1.25);
- expect(setZoomFactor).not.toHaveBeenCalled();
-
- zoomReadable = false;
- url = "https://example.com/after-zoom-read-failed";
+ // An app zoom leaves the guest reporting the inherited level. Navigating
+ // must not adopt it as the preview's zoom.
+ effectiveZoom = 0.8;
+ url = "https://example.com/after-app-zoom";
listeners.get("did-navigate")?.();
yield* Effect.yieldNow;
@@ -1045,7 +1043,18 @@ describe("PreviewManager", () => {
url,
title: "Example",
});
- expect(states.at(-1)?.zoomFactor).toBe(1.25);
+ expect(states.at(-1)?.zoomFactor).toBe(1);
+
+ // Only the preview's own zoom controls move it.
+ yield* manager.zoomIn("tab_zoom");
+ expect(setZoomFactor).toHaveBeenCalledWith(1.1);
+ expect(states.at(-1)?.zoomFactor).toBe(1.1);
+
+ zoomReadable = false;
+ listeners.get("did-navigate")?.();
+ yield* Effect.yieldNow;
+
+ expect(states.at(-1)?.zoomFactor).toBe(1.1);
const replacementSetZoomFactor = vi.fn();
fromId.mockReturnValue({
@@ -1074,8 +1083,103 @@ describe("PreviewManager", () => {
yield* manager.registerWebview("tab_zoom", 43);
- expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.25);
- expect(states.at(-1)?.zoomFactor).toBe(1.25);
+ expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.1);
+ expect(states.at(-1)?.zoomFactor).toBe(1.1);
+ }),
+ ),
+ );
+
+ // Zooming the app UI pushes the window's zoom level onto every guest, so the
+ // preview has to be put back at the zoom the user gave it.
+ effectIt.effect("re-applies each tab's own zoom when the app window zooms", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const setZoomFactor = vi.fn();
+ fromId.mockReturnValue({
+ id: 42,
+ isDestroyed: () => false,
+ getType: () => "webview",
+ getURL: () => "https://example.com",
+ getTitle: () => "Example",
+ isLoading: () => false,
+ getZoomFactor: () => 1,
+ setZoomFactor,
+ on: vi.fn(),
+ off: vi.fn(),
+ ipc: { on: vi.fn(), off: vi.fn() },
+ send: webviewSend,
+ navigationHistory: { canGoBack: () => false, canGoForward: () => false },
+ setWindowOpenHandler: vi.fn(),
+ debugger: {
+ isAttached: () => false,
+ attach: vi.fn(),
+ sendCommand: vi.fn(async () => undefined),
+ on: vi.fn(),
+ off: vi.fn(),
+ },
+ } as never);
+
+ yield* manager.createTab("tab_reapply");
+ yield* manager.registerWebview("tab_reapply", 42);
+ yield* manager.zoomIn("tab_reapply");
+ setZoomFactor.mockClear();
+
+ yield* manager.reapplyZoom();
+
+ expect(setZoomFactor).toHaveBeenCalledTimes(1);
+ expect(setZoomFactor).toHaveBeenCalledWith(1.1);
+ }),
+ ),
+ );
+
+ // did-attach and dom-ready both re-register the guest that is already
+ // attached, and a guest that just inherited the app window's zoom needs its
+ // own back — without that round trip republishing tab state.
+ effectIt.effect("re-asserts the tab's zoom when the active guest registers again", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const setZoomFactor = vi.fn();
+ fromId.mockReturnValue({
+ id: 42,
+ isDestroyed: () => false,
+ getType: () => "webview",
+ getURL: () => "https://example.com",
+ getTitle: () => "Example",
+ isLoading: () => false,
+ getZoomFactor: () => 1,
+ setZoomFactor,
+ on: vi.fn(),
+ off: vi.fn(),
+ ipc: { on: vi.fn(), off: vi.fn() },
+ send: webviewSend,
+ navigationHistory: { canGoBack: () => false, canGoForward: () => false },
+ setWindowOpenHandler: vi.fn(),
+ debugger: {
+ isAttached: () => false,
+ attach: vi.fn(),
+ sendCommand: vi.fn(async () => undefined),
+ on: vi.fn(),
+ off: vi.fn(),
+ },
+ } as never);
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+
+ yield* manager.createTab("tab_reregister_zoom");
+ yield* manager.registerWebview("tab_reregister_zoom", 42);
+ yield* manager.zoomIn("tab_reregister_zoom");
+ setZoomFactor.mockClear();
+ const publishedBefore = states.length;
+
+ yield* manager.registerWebview("tab_reregister_zoom", 42);
+
+ expect(setZoomFactor).toHaveBeenCalledWith(1.1);
+ expect(states.length).toBe(publishedBefore);
+ expect(states.at(-1)?.zoomFactor).toBe(1.1);
}),
),
);
@@ -2135,6 +2239,69 @@ describe("PreviewManager", () => {
),
);
+ effectIt.effect("navigates the guest history when the thumb-button ipc fires", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined;
+ const goBack = vi.fn();
+ const goForward = vi.fn();
+ let canGoBack = true;
+ fromId.mockReturnValue({
+ id: 42,
+ isDestroyed: () => false,
+ getType: () => "webview",
+ getURL: () => "https://example.com",
+ getTitle: () => "Example",
+ isLoading: () => false,
+ getZoomFactor: () => 1,
+ setZoomFactor: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ ipc: {
+ on: vi.fn((channel: string, listener: typeof mouseNavigate) => {
+ if (channel === "preview:mouse-navigate") mouseNavigate = listener;
+ }),
+ off: vi.fn(),
+ },
+ send: webviewSend,
+ navigationHistory: {
+ canGoBack: () => canGoBack,
+ canGoForward: () => true,
+ goBack,
+ goForward,
+ },
+ setWindowOpenHandler: vi.fn(),
+ debugger: {
+ isAttached: () => false,
+ attach: vi.fn(),
+ sendCommand: vi.fn(async () => undefined),
+ on: vi.fn(),
+ off: vi.fn(),
+ },
+ } as never);
+
+ yield* manager.createTab("tab_nav");
+ yield* manager.registerWebview("tab_nav", 42);
+ expect(mouseNavigate).toBeDefined();
+
+ mouseNavigate?.({}, { direction: "back" });
+ yield* Effect.yieldNow;
+ expect(goBack).toHaveBeenCalledOnce();
+
+ mouseNavigate?.({}, { direction: "forward" });
+ yield* Effect.yieldNow;
+ expect(goForward).toHaveBeenCalledOnce();
+
+ // Ignores unknown payloads and never navigates when history is exhausted.
+ mouseNavigate?.({}, { direction: "sideways" });
+ canGoBack = false;
+ mouseNavigate?.({}, { direction: "back" });
+ yield* Effect.yieldNow;
+ expect(goBack).toHaveBeenCalledOnce();
+ }),
+ ),
+ );
+
effectIt.effect("reveals only files inside the configured browser artifact directory", () =>
withManager((manager) =>
Effect.gen(function* () {
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
index 4799a7dfa..63abde47d 100644
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -16,6 +16,7 @@ import type {
DesktopPreviewRecordingArtifact,
DesktopPreviewRecordingFrame,
DesktopPreviewScreenshotArtifact,
+ DesktopPreviewTabDefaults,
PreviewAutomationClickInput,
PreviewAutomationActionEvent,
PreviewAutomationConsoleEntry,
@@ -58,6 +59,7 @@ import {
CANCEL_PICK_CHANNEL,
ELEMENT_PICKED_CHANNEL,
HUMAN_INPUT_CHANNEL,
+ MOUSE_NAVIGATE_CHANNEL,
START_PICK_CHANNEL,
} from "./GuestProtocol.ts";
import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
@@ -333,6 +335,21 @@ const findZoomStep = (current: number): number => {
return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1;
};
+/**
+ * Clamp a client-supplied zoom factor onto the discrete ladder. The setting is
+ * chosen from the same ladder, but it arrives over IPC from a schema that only
+ * guarantees a positive number, so an out-of-band value snaps to the nearest
+ * step rather than leaving the guest at a zoom the zoom controls can't reach.
+ */
+const normalizeZoomFactor = (value: number | undefined): number => {
+ if (value === undefined || !Number.isFinite(value)) return DEFAULT_ZOOM_FACTOR;
+ let closest = ZOOM_LEVELS[0]!;
+ for (const level of ZOOM_LEVELS) {
+ if (Math.abs(level - value) < Math.abs(closest - value)) closest = level;
+ }
+ return closest;
+};
+
const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
const step = findZoomStep(current);
if (direction === "in") {
@@ -647,6 +664,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
if (Option.isSome(next)) yield* emit(tabId, next.value);
});
+ /**
+ * Pushes a tab's zoom factor onto whichever guest it currently owns, reading
+ * both at call time. Anything that applies zoom after an await goes through
+ * here: a snapshot taken before the await can be older than a zoom action that
+ * landed in between, and re-applying it would roll that action back.
+ */
+ const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) {
+ const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
+ if (!tab || tab.webContentsId == null) return;
+ const wc = webContents.fromId(tab.webContentsId);
+ if (!wc || wc.isDestroyed()) return;
+ yield* attempt({ operation: "assertTabZoom", tabId, webContentsId: wc.id }, () =>
+ wc.setZoomFactor(tab.zoomFactor),
+ ).pipe(Effect.ignore);
+ });
+
const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* (
tabId: string,
) {
@@ -1305,10 +1338,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
confirmedNavigation = false,
) {
if (wc.isDestroyed()) return;
- const zoomFactor = yield* attempt(
- { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id },
- () => wc.getZoomFactor(),
- ).pipe(Effect.option);
const computedNavStatus = computeNavStatus(wc);
const canGoBack = wc.navigationHistory.canGoBack();
const canGoForward = wc.navigationHistory.canGoForward();
@@ -1338,7 +1367,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
navStatus,
canGoBack,
canGoForward,
- ...(Option.isSome(zoomFactor) ? { zoomFactor: zoomFactor.value } : {}),
+ // zoomFactor is deliberately not read back from the guest: Chromium
+ // reports the level it inherited from the app window, so mirroring it
+ // would turn an app zoom into the preview's own zoom.
updatedAt,
};
return [
@@ -1492,6 +1523,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const humanInput = (_event: unknown, rawSignal?: unknown): void => {
runFork(handleHumanInput(rawSignal));
};
+ const mouseNavigate = (_event: unknown, payload?: unknown): void => {
+ const direction =
+ typeof payload === "object" && payload !== null && "direction" in payload
+ ? (payload as { direction?: unknown }).direction
+ : undefined;
+ if (direction !== "back" && direction !== "forward") return;
+ runFork(
+ attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => {
+ if (direction === "back") {
+ if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack();
+ } else if (wc.navigationHistory.canGoForward()) {
+ wc.navigationHistory.goForward();
+ }
+ }).pipe(Effect.ignore),
+ );
+ };
const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* (
event: Electron.Event,
input: Electron.Input,
@@ -1538,6 +1585,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.off("did-fail-load", failed as never);
wc.off("before-input-event", beforeInput);
wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
+ wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
}).pipe(Effect.ignore),
);
const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
@@ -1551,6 +1599,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.on("did-stop-loading", sync);
wc.on("did-fail-load", failed as never);
wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
+ wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
wc.setWindowOpenHandler(({ url }) => {
runFork(
attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () =>
@@ -1581,6 +1630,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const createTabUnlocked = Effect.fn("PreviewManager.createTabUnlocked")(function* (
tabId: string,
+ defaults?: DesktopPreviewTabDefaults,
) {
const updatedAt = yield* currentIso;
const result = yield* SynchronizedRef.modify(
@@ -1599,9 +1649,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
navStatus: { kind: "Idle" },
canGoBack: false,
canGoForward: false,
- zoomFactor: DEFAULT_ZOOM_FACTOR,
+ zoomFactor: normalizeZoomFactor(defaults?.zoomFactor),
pictureInPicture: false,
- colorScheme: "system",
+ colorScheme: defaults?.colorScheme ?? "system",
controller: "none",
updatedAt,
};
@@ -1620,8 +1670,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
return result.state;
});
- const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) {
- return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId));
+ const createTab = Effect.fn("PreviewManager.createTab")(function* (
+ tabId: string,
+ defaults?: DesktopPreviewTabDefaults,
+ ) {
+ return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId, defaults));
});
const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) {
@@ -1716,11 +1769,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const annotationTheme = yield* Ref.get(annotationThemeRef);
const currentAttachment = attached.get(webContentsId);
if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) {
- const zoomFactor = yield* attempt(
- { operation: "registerWebview.getZoomFactor", tabId, webContentsId },
- () => wc.getZoomFactor(),
- );
- yield* update(tabId, { zoomFactor });
+ // The guest we already own re-announced itself, so nothing about the tab
+ // changed. Only push its zoom back down — Chromium may have just handed
+ // this guest the app window's zoom level.
+ yield* assertTabZoom(tabId);
yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
);
@@ -1749,18 +1801,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
) {
return yield* new PreviewTabNotFoundError({ tabId });
}
- const zoomFactor =
- replacedWebContentsId !== null
- ? yield* attempt(
- { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId },
- () => {
- wc.setZoomFactor(currentTab.zoomFactor);
- return currentTab.zoomFactor;
- },
- )
- : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () =>
- wc.getZoomFactor(),
- );
+ // Always assert the tab's own zoom rather than reading the guest's: a guest
+ // attaching while the app UI is zoomed starts at the embedder's inherited
+ // zoom level, which is not the preview's zoom. Done before the guest is
+ // published so it never paints a frame at the inherited zoom.
+ yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () =>
+ wc.setZoomFactor(currentTab.zoomFactor),
+ );
yield* attachListeners(tabId, wc);
const registeredAt = yield* currentIso;
const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) =>
@@ -1784,7 +1831,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
canGoBack: wc.navigationHistory.canGoBack(),
canGoForward: wc.navigationHistory.canGoForward(),
- zoomFactor,
updatedAt: registeredAt,
};
return [
@@ -1806,6 +1852,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
return yield* new PreviewTabNotFoundError({ tabId });
}
const { state: registered, pendingUrl } = registration.value;
+ // A zoom action that landed while this attach was in flight addressed the
+ // guest this one replaced, so settle the new guest on the committed factor.
+ yield* assertTabZoom(tabId);
runFork(restoreControlSession(tabId, wc));
yield* emit(tabId, registered);
yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
@@ -2099,6 +2148,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
});
+ /**
+ * Chromium hands every guest `` the embedder's zoom level, so zooming
+ * the app UI drags the previewed page along with it. The preview browser owns
+ * its own zoom factor, so re-assert it on each attached guest whenever the main
+ * window's zoom changes (see DesktopWindow.zoomMain).
+ */
+ const reapplyZoom = Effect.fn("PreviewManager.reapplyZoom")(function* () {
+ const tabIds = Array.from((yield* SynchronizedRef.get(tabsRef)).keys());
+ yield* Effect.forEach(tabIds, assertTabZoom, { discard: true });
+ });
+
const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* (
tabId: string,
transform: (current: number) => number,
@@ -3476,6 +3536,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
openPictureInPicture,
openDevTools,
pickElement,
+ reapplyZoom,
refresh,
registerWebview,
resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR),
@@ -3761,7 +3822,10 @@ export class PreviewManager extends Context.Service<
readonly setMainWindow: (window: BrowserWindow) => Effect.Effect;
readonly getBrowserSession: (scope?: string) => Effect.Effect;
readonly isBrowserPartition: (partition: string) => boolean;
- readonly createTab: (tabId: string) => Effect.Effect;
+ readonly createTab: (
+ tabId: string,
+ defaults?: DesktopPreviewTabDefaults,
+ ) => Effect.Effect;
readonly closeTab: (tabId: string) => Effect.Effect;
readonly registerWebview: (
tabId: string,
@@ -3774,6 +3838,9 @@ export class PreviewManager extends Context.Service<
readonly zoomIn: (tabId: string) => Effect.Effect;
readonly zoomOut: (tabId: string) => Effect.Effect;
readonly resetZoom: (tabId: string) => Effect.Effect;
+ // Re-applies every attached guest's own zoom factor, undoing the zoom level
+ // Chromium inherits from the embedder when the app UI zooms.
+ readonly reapplyZoom: () => Effect.Effect;
readonly hardReload: (tabId: string) => Effect.Effect;
readonly setColorScheme: (
tabId: string,
@@ -3874,6 +3941,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
zoomIn: operations.zoomIn,
zoomOut: operations.zoomOut,
resetZoom: operations.resetZoom,
+ reapplyZoom: operations.reapplyZoom,
hardReload: operations.hardReload,
setColorScheme: operations.setColorScheme,
openDevTools: operations.openDevTools,
diff --git a/apps/desktop/src/preview/PickPreload.test.ts b/apps/desktop/src/preview/PickPreload.test.ts
deleted file mode 100644
index 5696fe508..000000000
--- a/apps/desktop/src/preview/PickPreload.test.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { describe, expect, it } from "vite-plus/test";
-
-import { computeLabelPosition } from "./PickLabelPosition.ts";
-
-const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 };
-
-describe("computeLabelPosition", () => {
- it("anchors to the element's top-left when there's room above and to the right", () => {
- const { x, y } = computeLabelPosition({
- ...VIEWPORT,
- targetLeft: 200,
- targetTop: 200,
- targetBottom: 240,
- labelWidth: 120,
- labelHeight: 18,
- });
- expect(x).toBe(200);
- // 200 (top) - 18 (height) - 4 (gap)
- expect(y).toBe(200 - 18 - 4);
- });
-
- it("clamps left edge so the label stays inside the viewport", () => {
- const { x } = computeLabelPosition({
- ...VIEWPORT,
- targetLeft: -50,
- targetTop: 200,
- targetBottom: 240,
- labelWidth: 120,
- labelHeight: 18,
- });
- expect(x).toBe(4);
- });
-
- it("clamps right edge when the label would overflow the viewport (the bug we shipped)", () => {
- const { x } = computeLabelPosition({
- ...VIEWPORT,
- targetLeft: 1240,
- targetTop: 200,
- targetBottom: 240,
- labelWidth: 200,
- labelHeight: 18,
- });
- // viewportWidth (1280) - labelWidth (200) - margin (4) = 1076
- expect(x).toBe(1076);
- });
-
- it("flips the label below the element when there's no room above", () => {
- const { y } = computeLabelPosition({
- ...VIEWPORT,
- targetLeft: 200,
- targetTop: 4,
- targetBottom: 44,
- labelWidth: 120,
- labelHeight: 18,
- });
- // labelY = 4 - 18 - 4 = -18 → flip → 44 + 4 = 48
- expect(y).toBe(48);
- });
-
- it("pins to the bottom margin when the element fills the viewport (no room above OR below)", () => {
- const { y } = computeLabelPosition({
- ...VIEWPORT,
- targetLeft: 200,
- targetTop: 0,
- targetBottom: 800,
- labelWidth: 120,
- labelHeight: 18,
- });
- // Above overflows top → flip below = 800 + 4 = 804 → also overflows
- // bottom → pin to viewportHeight - labelHeight - margin = 778.
- expect(y).toBe(800 - 18 - 4);
- });
-
- it("never returns a negative coordinate", () => {
- const { x, y } = computeLabelPosition({
- ...VIEWPORT,
- targetLeft: -1000,
- targetTop: -1000,
- targetBottom: -900,
- labelWidth: 5000,
- labelHeight: 5000,
- });
- expect(x).toBeGreaterThanOrEqual(0);
- expect(y).toBeGreaterThanOrEqual(0);
- });
-});
diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts
index d03673400..f315bdcec 100644
--- a/apps/desktop/src/preview/PickPreload.ts
+++ b/apps/desktop/src/preview/PickPreload.ts
@@ -22,6 +22,7 @@ import {
CANCEL_PICK_CHANNEL,
ELEMENT_PICKED_CHANNEL,
HUMAN_INPUT_CHANNEL,
+ MOUSE_NAVIGATE_CHANNEL,
START_PICK_CHANNEL,
} from "./GuestProtocol.ts";
const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";
@@ -102,6 +103,40 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => {
window.addEventListener("pointerdown", reportHumanPointerInput, true);
window.addEventListener("keydown", reportHumanKeyInput, true);
+// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward.
+const MOUSE_BUTTON_BACK = 3;
+const MOUSE_BUTTON_FORWARD = 4;
+
+const navigationDirectionForButton = (button: number): "back" | "forward" | null => {
+ if (button === MOUSE_BUTTON_BACK) return "back";
+ if (button === MOUSE_BUTTON_FORWARD) return "forward";
+ return null;
+};
+
+// Chromium routes thumb-button history navigation to the *focused* WebContents,
+// so hovering this guest without focusing it sends the host app's router back
+// instead of the preview. Suppress Chromium's default here and drive this tab's
+// history explicitly so the buttons always navigate the browser the pointer is
+// over — never the host app.
+const suppressNavigationButton = (event: MouseEvent): void => {
+ if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+};
+
+const requestNavigationForButton = (event: MouseEvent): void => {
+ if (!event.isTrusted) return;
+ const direction = navigationDirectionForButton(event.button);
+ if (direction === null) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction });
+};
+
+window.addEventListener("mousedown", suppressNavigationButton, true);
+window.addEventListener("mouseup", requestNavigationForButton, true);
+window.addEventListener("auxclick", suppressNavigationButton, true);
+
const nextId = (prefix: string): string => {
idSequence += 1;
return `${prefix}_${idSequence.toString(36)}`;
diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 8cfb44a79..20136683f 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -13,6 +13,11 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopClientSettings from "./DesktopClientSettings.ts";
const clientSettings: ClientSettings = {
+ browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
+ browserDefaultZoomFactor: 1.25,
+ browserDefaultAppearance: "dark",
+ browserAutoShowFloatingPreview: false,
+ confirmQuit: true,
confirmThreadArchive: true,
confirmThreadDelete: false,
contextMenuStyle: "default",
diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
index 831f06f02..28955debf 100644
--- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
+++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
@@ -152,6 +152,108 @@ describe("DesktopShellEnvironment", () => {
}),
);
+ it.effect("hydrates the locale from the login shell on macOS", () =>
+ Effect.gen(function* () {
+ const env: NodeJS.ProcessEnv = {
+ SHELL: "/bin/zsh",
+ PATH: "/usr/bin",
+ };
+
+ yield* runShellEnvironment({
+ env,
+ platform: "darwin",
+ handler: () =>
+ envOutput({
+ PATH: "/opt/homebrew/bin:/usr/bin",
+ LANG: "de_DE.UTF-8",
+ }),
+ });
+
+ assert.equal(env.LANG, "de_DE.UTF-8");
+ }),
+ );
+
+ it.effect("preserves an inherited locale over the login shell on macOS", () =>
+ Effect.gen(function* () {
+ const env: NodeJS.ProcessEnv = {
+ SHELL: "/bin/zsh",
+ PATH: "/usr/bin",
+ LANG: "en_US.UTF-8",
+ };
+
+ yield* runShellEnvironment({
+ env,
+ platform: "darwin",
+ handler: () =>
+ envOutput({
+ PATH: "/opt/homebrew/bin:/usr/bin",
+ LANG: "de_DE.UTF-8",
+ }),
+ });
+
+ assert.equal(env.LANG, "en_US.UTF-8");
+ }),
+ );
+
+ it.effect("does not mix login-shell locale categories into an inherited locale", () =>
+ Effect.gen(function* () {
+ const env: NodeJS.ProcessEnv = {
+ SHELL: "/bin/zsh",
+ PATH: "/usr/bin",
+ LANG: "en_US.UTF-8",
+ };
+
+ yield* runShellEnvironment({
+ env,
+ platform: "darwin",
+ handler: () =>
+ envOutput({
+ PATH: "/opt/homebrew/bin:/usr/bin",
+ LC_ALL: "de_DE.UTF-8",
+ }),
+ });
+
+ assert.equal(env.LANG, "en_US.UTF-8");
+ assert.equal(env.LC_ALL, undefined);
+ }),
+ );
+
+ it.effect("falls back to a UTF-8 LC_CTYPE when no locale is available on macOS", () =>
+ Effect.gen(function* () {
+ const env: NodeJS.ProcessEnv = {
+ SHELL: "/bin/zsh",
+ PATH: "/usr/bin",
+ };
+
+ yield* runShellEnvironment({
+ env,
+ platform: "darwin",
+ handler: () => envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" }),
+ });
+
+ assert.equal(env.LANG, undefined);
+ assert.equal(env.LC_ALL, undefined);
+ assert.equal(env.LC_CTYPE, "en_US.UTF-8");
+ }),
+ );
+
+ it.effect("does not apply the locale fallback on linux", () =>
+ Effect.gen(function* () {
+ const env: NodeJS.ProcessEnv = {
+ SHELL: "/bin/zsh",
+ PATH: "/usr/bin",
+ };
+
+ yield* runShellEnvironment({
+ env,
+ platform: "linux",
+ handler: () => envOutput({ PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin" }),
+ });
+
+ assert.equal(env.LANG, undefined);
+ }),
+ );
+
it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () =>
Effect.gen(function* () {
const env: NodeJS.ProcessEnv = {
diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts
index bd8aa6654..e065bf55d 100644
--- a/apps/desktop/src/shell/DesktopShellEnvironment.ts
+++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts
@@ -71,6 +71,9 @@ const LOGIN_SHELL_ENV_NAMES = [
"PATH",
"DBUS_SESSION_BUS_ADDRESS",
"DISPLAY",
+ "LANG",
+ "LC_ALL",
+ "LC_CTYPE",
"SSH_AUTH_SOCK",
"HOMEBREW_PREFIX",
"HOMEBREW_CELLAR",
@@ -84,6 +87,8 @@ const LOGIN_SHELL_ENV_NAMES = [
"WAYLAND_DISPLAY",
] as const;
const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
+const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const;
+const FALLBACK_LC_CTYPE = "en_US.UTF-8";
const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
const LOGIN_SHELL_TIMEOUT = Duration.seconds(5);
const LAUNCHCTL_TIMEOUT = Duration.seconds(2);
@@ -472,6 +477,29 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
}
}
+ // Locale variables form one precedence group: LC_ALL can override an inherited
+ // LANG or LC_CTYPE, so only hydrate the group when the process has none of them.
+ if (
+ config.platform === "darwin" &&
+ LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))
+ ) {
+ for (const name of LOCALE_ENV_NAMES) {
+ const value = trimNonEmpty(shellEnvironment[name]);
+ if (Option.isSome(value)) {
+ config.env[name] = value.value;
+ }
+ }
+
+ // GUI launches inherit no locale from launchd, so spawned agents land in the C
+ // locale and pbcopy decodes their UTF-8 output as MacRoman. Older supported
+ // macOS releases do not provide C.UTF-8, so set only LC_CTYPE to a UTF-8 locale
+ // available on those releases. Leaving LANG unset keeps C-stable collation and
+ // formatting, so output parsing is unaffected.
+ if (LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) {
+ config.env.LC_CTYPE = FALLBACK_LC_CTYPE;
+ }
+ }
+
if (
config.platform === "linux" &&
Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS))
diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts
index 112c0ab35..3d912a5d5 100644
--- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts
+++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts
@@ -26,6 +26,7 @@ function makeElectronAppLayer(
return Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
+ systemLocale: Effect.succeed("en-US"),
whenReady: Effect.void,
quit: Effect.void,
exit: () => Effect.void,
diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts
index 09c287763..595b0dd11 100644
--- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts
+++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts
@@ -31,6 +31,7 @@ const environmentInput = {
const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
+ systemLocale: Effect.succeed("en-US"),
whenReady: Effect.void,
quit: Effect.void,
exit: () => Effect.void,
diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts
index cdd35247f..3ee126224 100644
--- a/apps/desktop/src/window/DesktopWindow.test.ts
+++ b/apps/desktop/src/window/DesktopWindow.test.ts
@@ -37,6 +37,8 @@ import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopState from "../app/DesktopState.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
+import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
+import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronMenu from "../electron/ElectronMenu.ts";
import * as ElectronShell from "../electron/ElectronShell.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
@@ -61,9 +63,14 @@ const environmentInput = {
function makeFakeBrowserWindow() {
const windowListeners = new Map void>();
const webContentsListeners = new Map void>();
+ let zoomLevel = 0;
const webContents = {
copyImageAt: vi.fn(),
getURL: vi.fn(() => "t3code-dev://app/"),
+ getZoomLevel: vi.fn(() => zoomLevel),
+ setZoomLevel: vi.fn((level: number) => {
+ zoomLevel = level;
+ }),
isLoadingMainFrame: vi.fn(() => false),
on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => {
webContentsListeners.set(eventName, listener);
@@ -116,12 +123,21 @@ function makeFakeBrowserWindow() {
openDevTools: webContents.openDevTools,
reload: webContents.reload,
send: webContents.send,
+ setZoomLevel: webContents.setZoomLevel,
setAutoHideCursor: window.setAutoHideCursor,
webContentsListeners,
windowListeners,
};
}
+const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClientSettings)({
+ get: Effect.succeed(Option.none()),
+});
+
+const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({
+ quit: Effect.void,
+});
+
const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, {
iconPaths: Effect.succeed({
ico: Option.none(),
@@ -186,6 +202,7 @@ function makeTestLayer(input: {
bounds: DesktopAppSettings.DesktopWindowBounds,
) => Effect.Effect;
readonly openedExternalUrls?: unknown[];
+ readonly previewZoomReapplies?: number[];
}) {
let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS;
const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
@@ -247,8 +264,10 @@ function makeTestLayer(input: {
desktopAssetsLayer,
desktopEnvironmentLayer,
desktopAppSettingsLayer,
+ desktopClientSettingsLayer,
desktopServerExposureLayer,
DesktopState.layer,
+ electronAppLayer,
electronMenuLayer,
Layer.succeed(ElectronShell.ElectronShell, {
openExternal: (url) =>
@@ -265,6 +284,10 @@ function makeTestLayer(input: {
setMainWindow: () => Effect.void,
isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"),
getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"),
+ reapplyZoom: () =>
+ Effect.sync(() => {
+ input.previewZoomReapplies?.push(input.window.webContents.getZoomLevel());
+ }),
}),
),
),
@@ -346,7 +369,9 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
desktopAssetsLayer,
desktopEnvironmentLayer,
DesktopAppSettings.layerTest(),
+ desktopClientSettingsLayer,
desktopServerExposureLayer,
+ electronAppLayer,
electronMenuLayer,
Layer.succeed(ElectronShell.ElectronShell, {
openExternal: () => Effect.succeed(true),
@@ -484,6 +509,42 @@ describe("DesktopWindow", () => {
}),
);
+ // Chromium hands the main window's zoom level down to embedded preview
+ // guests, so every app zoom has to put the preview browser back at its own
+ // zoom or zooming the UI drags the previewed page with it.
+ it.effect("restores the preview browser's own zoom after zooming the app", () =>
+ Effect.gen(function* () {
+ const fakeWindow = makeFakeBrowserWindow();
+ const createCount = yield* Ref.make(0);
+ const mainWindow = yield* Ref.make>(Option.none());
+ const previewZoomReapplies: number[] = [];
+ const layer = makeTestLayer({
+ window: fakeWindow.window,
+ createCount,
+ mainWindow,
+ previewZoomReapplies,
+ });
+
+ yield* Effect.gen(function* () {
+ const desktopWindow = yield* DesktopWindow.DesktopWindow;
+ yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
+
+ yield* desktopWindow.zoomMain("out");
+ yield* desktopWindow.zoomMain("out");
+ yield* desktopWindow.zoomMain("in");
+ yield* desktopWindow.zoomMain("reset");
+
+ assert.deepEqual(
+ fakeWindow.setZoomLevel.mock.calls.map(([level]) => level),
+ [-0.5, -1, -0.5, 0],
+ );
+ // Recorded after the window level moved, so the preview is put back at
+ // its own zoom on every step rather than left on the inherited one.
+ assert.deepEqual(previewZoomReapplies, [-0.5, -1, -0.5, 0]);
+ }).pipe(Effect.provide(layer));
+ }),
+ );
+
it.effect("uses the persisted main window bounds when opening the window", () =>
Effect.gen(function* () {
const fakeWindow = makeFakeBrowserWindow();
diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts
index fd7c28d72..9e4ae1181 100644
--- a/apps/desktop/src/window/DesktopWindow.ts
+++ b/apps/desktop/src/window/DesktopWindow.ts
@@ -8,6 +8,8 @@ import * as Ref from "effect/Ref";
import * as Electron from "electron";
+import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
+
import * as DesktopAssets from "../app/DesktopAssets.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import { makeComponentLogger } from "../app/DesktopObservability.ts";
@@ -16,9 +18,16 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts";
import * as ElectronShell from "../electron/ElectronShell.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
-import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts";
+import {
+ MENU_ACTION_CHANNEL,
+ QUIT_SHORTCUT_CHANNEL,
+ WINDOW_FULLSCREEN_STATE_CHANNEL,
+} from "../ipc/channels.ts";
import * as PreviewManager from "../preview/Manager.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
+import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
+import * as ElectronApp from "../electron/ElectronApp.ts";
+import { makeQuitHoldHandler } from "./QuitHold.ts";
const TITLEBAR_HEIGHT = 40;
const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
@@ -51,6 +60,8 @@ type DesktopWindowRuntimeServices =
| DesktopEnvironment.DesktopEnvironment
| DesktopAssets.DesktopAssets
| DesktopAppSettings.DesktopAppSettings
+ | DesktopClientSettings.DesktopClientSettings
+ | ElectronApp.ElectronApp
| ElectronMenu.ElectronMenu
| ElectronShell.ElectronShell
| ElectronTheme.ElectronTheme
@@ -258,9 +269,13 @@ export const make = Effect.gen(function* () {
const electronWindow = yield* ElectronWindow.ElectronWindow;
const previewManager = yield* PreviewManager.PreviewManager;
const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
- // Window-side latch for the primary backend's readiness. Initial startup
- // creates the window as soon as it has a usable backend config. This latch
- // only gates later recreation after the window was closed.
+ const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
+ const electronApp = yield* ElectronApp.ElectronApp;
+ // Window-side latch for the primary backend's readiness. Set by
+ // handleBackendReady (driven by the pool's onReady callback), cleared
+ // by handleBackendNotReady (driven by onShutdown). Only consumed by
+ // createMainIfBackendReady, which gates the post-readiness window
+ // open in development and the macOS "activate without windows" path.
const backendReadyRef = yield* Ref.make(false);
// The transient "Connecting to WSL" splash window, tracked separately so it
// is never mistaken for the real main window.
@@ -528,7 +543,32 @@ export const make = Effect.gen(function* () {
// close-terminal shortcut can outlive the terminal that handled its first
// press, so reject repeats before they reach the native window accelerator.
// Deliberate presses still flow through the renderer or native menu.
+ // Chrome-style hold-to-quit: intercept the quit accelerator before the
+ // native menu sees it and only quit after the shortcut is held. The
+ // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL.
+ const quitHoldHandler = makeQuitHoldHandler({
+ platform: environment.platform,
+ isEnabled: () =>
+ runPromise(
+ Effect.map(
+ clientSettings.get,
+ Option.match({
+ onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit,
+ onSome: (settings) => settings.confirmQuit,
+ }),
+ ),
+ ),
+ notify: (state) => {
+ if (!window.isDestroyed()) {
+ window.webContents.send(QUIT_SHORTCUT_CHANNEL, state);
+ }
+ },
+ quit: () => {
+ void runPromise(electronApp.quit);
+ },
+ });
window.webContents.on("before-input-event", (event, input) => {
+ quitHoldHandler(event, input);
if (input.type !== "keyDown" || !input.isAutoRepeat) return;
const modifier = environment.platform === "darwin" ? input.meta : input.control;
if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") {
@@ -859,6 +899,10 @@ export const make = Effect.gen(function* () {
webContents.setZoomLevel(
direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5),
);
+ // Chromium pushes the new level down to embedded guests, which would zoom
+ // the previewed page along with the app UI. The preview browser keeps its
+ // own zoom, so put each guest back where the preview left it.
+ yield* previewManager.reapplyZoom();
}),
syncAppearance: Effect.gen(function* () {
const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts
new file mode 100644
index 000000000..c900a8654
--- /dev/null
+++ b/apps/desktop/src/window/QuitHold.test.ts
@@ -0,0 +1,201 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+import {
+ makeQuitHoldHandler,
+ QUIT_DOUBLE_TAP_MS,
+ QUIT_HOLD_DURATION_MS,
+ QUIT_HOLD_RELEASE_GRACE_MS,
+} from "./QuitHold.ts";
+import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts";
+
+function makeInput(overrides: Partial): QuitHoldKeyInput {
+ return {
+ type: "keyDown",
+ key: "q",
+ meta: true,
+ control: false,
+ alt: false,
+ shift: false,
+ isAutoRepeat: false,
+ ...overrides,
+ };
+}
+
+function makeHarness(options?: {
+ enabled?: boolean;
+ platform?: NodeJS.Platform;
+ isEnabled?: () => Promise;
+}) {
+ const notifications: Array = [];
+ const quit = vi.fn();
+ const handler = makeQuitHoldHandler({
+ platform: options?.platform ?? "darwin",
+ isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)),
+ notify: (state) => notifications.push(state),
+ quit,
+ });
+ const preventDefault = vi.fn();
+ const send = async (input: QuitHoldKeyInput) => {
+ handler({ preventDefault }, input);
+ // Let the isEnabled promise settle.
+ await Promise.resolve();
+ await Promise.resolve();
+ };
+ // Simulates the OS auto-repeating the held shortcut every `intervalMs`.
+ const holdFor = async (
+ durationMs: number,
+ repeatOverrides: Partial = {},
+ intervalMs = 100,
+ ) => {
+ for (let elapsed = 0; elapsed < durationMs; elapsed += intervalMs) {
+ vi.advanceTimersByTime(intervalMs);
+ await send(makeInput({ isAutoRepeat: true, ...repeatOverrides }));
+ }
+ };
+ return { notifications, quit, preventDefault, send, holdFor };
+}
+
+describe("makeQuitHoldHandler", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("shows the hint on a tap without quitting, even when the release is never seen", async () => {
+ // macOS suppresses the letter's keyUp while Cmd is held, so a tap may
+ // produce no keyUp at all. Quit must still not fire.
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ expect(harness.preventDefault).toHaveBeenCalledTimes(1);
+ expect(harness.notifications).toEqual(["down"]);
+
+ vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS);
+ expect(harness.quit).not.toHaveBeenCalled();
+ // The watchdog dismisses the hint once the press is clearly over.
+ expect(harness.notifications).toEqual(["down", "up"]);
+ });
+
+ it("quits once the shortcut auto-repeats past the hold duration", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ await harness.holdFor(QUIT_HOLD_DURATION_MS - 200);
+ expect(harness.quit).not.toHaveBeenCalled();
+ await harness.holdFor(400);
+ expect(harness.quit).toHaveBeenCalledTimes(1);
+ // Exactly one hint cycle for the whole hold.
+ expect(harness.notifications).toEqual(["down", "up"]);
+ });
+
+ it("does not quit when the hold stops before the duration", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ await harness.holdFor(500);
+ await harness.send(makeInput({ type: "keyUp" }));
+ expect(harness.notifications).toEqual(["down", "up"]);
+ vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);
+ expect(harness.quit).not.toHaveBeenCalled();
+ });
+
+ it("cancels the hold when the modifier is released first", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
+ expect(harness.notifications).toEqual(["down", "up"]);
+ vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);
+ expect(harness.quit).not.toHaveBeenCalled();
+ });
+
+ it("quits immediately on a single press when disabled", async () => {
+ const harness = makeHarness({ enabled: false });
+ await harness.send(makeInput({}));
+ expect(harness.quit).toHaveBeenCalledTimes(1);
+ // The hint is dismissed in case the quit gets cancelled downstream.
+ expect(harness.notifications).toEqual(["down", "up"]);
+ });
+
+ it("discards a stale isEnabled resolution from a superseded press", async () => {
+ // Press #1's isEnabled is still pending when the user releases and
+ // presses again; its late resolution must not act for press #2.
+ const resolvers: Array<(enabled: boolean) => void> = [];
+ const harness = makeHarness({
+ isEnabled: () => new Promise((resolve) => resolvers.push(resolve)),
+ });
+ await harness.send(makeInput({}));
+ await harness.send(makeInput({ type: "keyUp" }));
+ // Outside the double-tap window, so the second press starts a new hold.
+ vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100);
+ await harness.send(makeInput({}));
+ expect(resolvers).toHaveLength(2);
+
+ // Press #1 resolves late with "disabled" — it must not quit press #2.
+ resolvers[0]?.(false);
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(harness.quit).not.toHaveBeenCalled();
+
+ // Press #2 resolves enabled and completes a full hold.
+ resolvers[1]?.(true);
+ await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
+ expect(harness.quit).toHaveBeenCalledTimes(1);
+ });
+
+ it("quits on a quick double tap, even when the first release was never seen", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100);
+ await harness.send(makeInput({}));
+ expect(harness.quit).toHaveBeenCalledTimes(1);
+ });
+
+ it("treats two slow taps as separate presses", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ await harness.send(makeInput({ type: "keyUp" }));
+ vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100);
+ await harness.send(makeInput({}));
+ expect(harness.quit).not.toHaveBeenCalled();
+ expect(harness.notifications).toEqual(["down", "up", "down"]);
+ });
+
+ it("cancels the hold when another key interrupts it", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ await harness.holdFor(500);
+ // Shift pressed mid-hold breaks the gesture...
+ await harness.send(makeInput({ shift: true }));
+ expect(harness.notifications).toEqual(["down", "up"]);
+ // ...so later repeats past the threshold must not quit.
+ await harness.holdFor(QUIT_HOLD_DURATION_MS);
+ expect(harness.quit).not.toHaveBeenCalled();
+ });
+
+ it("does not count an interrupted press toward a double tap", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ await harness.send(makeInput({ shift: true }));
+ // A fresh press right after the interruption starts a new hold, not a
+ // double-tap quit.
+ await harness.send(makeInput({}));
+ expect(harness.quit).not.toHaveBeenCalled();
+ expect(harness.notifications).toEqual(["down", "up", "down"]);
+ });
+
+ it("ignores other shortcuts", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({ key: "w" }));
+ await harness.send(makeInput({ shift: true }));
+ await harness.send(makeInput({ meta: false }));
+ expect(harness.preventDefault).not.toHaveBeenCalled();
+ expect(harness.notifications).toEqual([]);
+ });
+
+ it("uses control on non-mac platforms", async () => {
+ const harness = makeHarness({ platform: "linux" });
+ await harness.send(makeInput({ meta: false, control: true }));
+ expect(harness.preventDefault).toHaveBeenCalledTimes(1);
+ await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true });
+ expect(harness.quit).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts
new file mode 100644
index 000000000..ea2fc7854
--- /dev/null
+++ b/apps/desktop/src/window/QuitHold.ts
@@ -0,0 +1,148 @@
+// @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime.
+
+// Chrome-style hold-to-quit. The quit accelerator is intercepted in
+// before-input-event (which runs before the native menu accelerator), and the
+// app only quits once the shortcut has been held for QUIT_HOLD_DURATION_MS.
+// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap
+// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application
+// menu itself is untouched and quits immediately.
+export const QUIT_HOLD_DURATION_MS = 1200;
+// A second quick tap of the shortcut is the user insisting: quit immediately.
+export const QUIT_DOUBLE_TAP_MS = 500;
+// "Still held" is proven by auto-repeat keydowns, not by the absence of a
+// release: macOS suppresses a letter's keyUp while the command key is down, so
+// a tap's release can go completely unseen and a release-based timer would
+// quit anyway. The press is treated as released once no key event has arrived
+// for QUIT_HOLD_RELEASE_GRACE_MS past the hold duration. Keyboards with
+// auto-repeat disabled cannot hold-to-quit and fall back to the menu's Quit.
+export const QUIT_HOLD_RELEASE_GRACE_MS = 600;
+
+export type QuitHoldState = "down" | "up";
+
+export interface QuitHoldKeyInput {
+ readonly type: string;
+ readonly key: string;
+ readonly meta: boolean;
+ readonly control: boolean;
+ readonly alt: boolean;
+ readonly shift: boolean;
+ readonly isAutoRepeat: boolean;
+}
+
+export interface QuitHoldOptions {
+ readonly platform: NodeJS.Platform;
+ readonly isEnabled: () => Promise;
+ readonly notify: (state: QuitHoldState) => void;
+ readonly quit: () => void;
+}
+
+export function makeQuitHoldHandler(
+ options: QuitHoldOptions,
+): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void {
+ const modifierKey = options.platform === "darwin" ? "meta" : "control";
+ let watchdog: NodeJS.Timeout | undefined;
+ let holding = false;
+ // Set once isEnabled resolves true; auto-repeats may only quit when armed.
+ let armed = false;
+ let heldSince = 0;
+ let lastPressAt = 0;
+ // Incremented on every new press and every release/quit so a pending
+ // isEnabled() resolution from a superseded press cannot arm (or quit for)
+ // the current one.
+ let generation = 0;
+
+ const clearWatchdog = () => {
+ if (watchdog !== undefined) {
+ clearTimeout(watchdog);
+ watchdog = undefined;
+ }
+ };
+
+ const release = () => {
+ if (!holding) return;
+ generation += 1;
+ holding = false;
+ armed = false;
+ clearWatchdog();
+ options.notify("up");
+ };
+
+ // Dismisses the overlay first: if the quit is cancelled downstream the
+ // renderer must not be left with a stuck "Hold to Quit" hint.
+ const quitNow = () => {
+ release();
+ options.quit();
+ };
+
+ return (event, input) => {
+ const key = input.key.toLowerCase();
+ if (input.type === "keyUp") {
+ if (key === "q" || key === modifierKey) release();
+ return;
+ }
+ if (input.type !== "keyDown") return;
+
+ const modifierDown = options.platform === "darwin" ? input.meta : input.control;
+ if (!modifierDown || input.alt || input.shift || key !== "q") {
+ // Any other key (or an extra modifier) pressed mid-hold breaks the
+ // gesture; without this the hold timer keeps running through the
+ // interruption and the next qualifying repeat would quit early. The
+ // interrupted press also stops counting toward a double tap — but only
+ // here, not in release(), which runs mid-restart on an unseen-release
+ // re-press and must not wipe that press's own tap timestamp.
+ if (holding && !input.isAutoRepeat) {
+ lastPressAt = 0;
+ release();
+ }
+ return;
+ }
+
+ event.preventDefault();
+
+ if (input.isAutoRepeat) {
+ if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) {
+ quitNow();
+ }
+ return;
+ }
+
+ const now = Date.now();
+ const previousPressAt = lastPressAt;
+ lastPressAt = now;
+ // A fresh keydown while "holding" means the key came back down after a
+ // release macOS never delivered — so both branches below see real taps.
+ if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) {
+ quitNow();
+ return;
+ }
+ if (holding) release();
+
+ generation += 1;
+ const pressGeneration = generation;
+ holding = true;
+ heldSince = now;
+ options.notify("down");
+ void options.isEnabled().then(
+ (enabled) => {
+ if (generation !== pressGeneration) return;
+ if (!enabled) {
+ // Hold-to-quit disabled: a single press quits immediately.
+ quitNow();
+ return;
+ }
+ armed = true;
+ // No auto-repeat by then means the key was released (possibly with a
+ // suppressed keyUp) or repeat is disabled; either way, don't quit.
+ watchdog = setTimeout(() => {
+ watchdog = undefined;
+ release();
+ }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS);
+ },
+ // A failed settings read must never strand the quit request.
+ () => {
+ if (generation !== pressGeneration) return;
+ quitNow();
+ },
+ );
+ };
+}
diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts
index 2f58c6adc..ed8911d40 100644
--- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts
+++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts
@@ -77,6 +77,7 @@ const backendConfigurationLayer = Layer.succeed(
const netLayer = Layer.succeed(NetService.NetService, {
canListenOnHost: () => Effect.succeed(true),
isPortAvailableOnLoopback: () => Effect.succeed(true),
+ hasListenerOnHost: () => Effect.succeed(false),
reserveLoopbackPort: () => Effect.succeed(41773),
findAvailablePort: (preferred) => Effect.succeed(preferred),
} satisfies NetService.NetService["Service"]);
diff --git a/apps/marketing/src/lib/macArch.test.ts b/apps/marketing/src/lib/macArch.test.ts
new file mode 100644
index 000000000..24a03f2cf
--- /dev/null
+++ b/apps/marketing/src/lib/macArch.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { macArchFromGpuRenderer } from "./macArch";
+
+describe("macArchFromGpuRenderer", () => {
+ it("detects explicit Apple Silicon renderers", () => {
+ expect(macArchFromGpuRenderer("Apple M4 Pro")).toBe("arm64");
+ });
+
+ it("prefers an Intel GPU marker even when the renderer also mentions Apple", () => {
+ expect(macArchFromGpuRenderer("ANGLE Metal Renderer: Apple, Intel Iris Plus Graphics")).toBe(
+ "x64",
+ );
+ });
+
+ it("uses x64 for ambiguous Safari and unavailable renderer values", () => {
+ expect(macArchFromGpuRenderer("Apple GPU")).toBe("x64");
+ expect(macArchFromGpuRenderer("")).toBe("x64");
+ });
+});
diff --git a/apps/marketing/src/lib/macArch.ts b/apps/marketing/src/lib/macArch.ts
new file mode 100644
index 000000000..8399c4adb
--- /dev/null
+++ b/apps/marketing/src/lib/macArch.ts
@@ -0,0 +1,16 @@
+const INTEL_GPU_PATTERN = /intel|amd|radeon|nvidia|geforce/i;
+const APPLE_SILICON_GPU_PATTERN = /\bapple\s+m\d/i;
+
+export function macArchFromGpuRenderer(renderer: string): "arm64" | "x64" {
+ if (INTEL_GPU_PATTERN.test(renderer)) {
+ return "x64";
+ }
+ if (APPLE_SILICON_GPU_PATTERN.test(renderer)) {
+ return "arm64";
+ }
+
+ // Generic "Apple GPU" renderers are ambiguous on Safari. x64 is the safe
+ // fallback because Apple Silicon can run it through Rosetta, while Intel
+ // Macs cannot run an arm64 build.
+ return "x64";
+}
diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro
index 20fae2882..37ff6b693 100644
--- a/apps/marketing/src/pages/index.astro
+++ b/apps/marketing/src/pages/index.astro
@@ -391,14 +391,33 @@ const mobileEndorsementRows = [
',
+ ),
+ ]}
+ />,
+ );
+
+ expect(markup).toContain("<script>globalThis.__t3Xss = 1</script>");
+ expect(markup).toContain(
+ "<img src="x" onerror="globalThis.__t3Xss = 2">",
+ );
+ expect(markup).not.toMatch(/",
+ ' ',
+ 'Unsafe link ',
+ "",
+ ].join(""),
+ ),
+ ]}
+ />,
+ );
+
+ expect(markup).toContain('data-markdown-details=""');
+ expect(markup).toContain("Safe details");
+ expect(markup).not.toMatch(/