From 5034edafd9191ee903272c0305ce81cfc5e852c0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 21:41:16 -0700 Subject: [PATCH 1/4] feat(web): boot error screen recommends a triage command When the web bundle fails to start, the screen now shows a command that runs `t3 triage` on this machine. Desktop asks its bridge: `t3 triage` when the CLI is on PATH, else the bundled launcher path, which needs no Node. Everything else, and any failure, falls back to `npx t3 triage`. Co-Authored-By: Claude Opus 5.5 --- .../desktop/src/app/DesktopCliCommand.test.ts | 8 ++- apps/desktop/src/app/DesktopCliCommand.ts | 16 ++++-- apps/web/index.html | 13 +++++ apps/web/src/lib/bootError.test.ts | 50 ++++++++++++++++++ apps/web/src/lib/bootError.ts | 52 +++++++++++++++++++ packages/contracts/src/ipc.ts | 2 + 6 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/lib/bootError.test.ts diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index db56aa3c83b03..3cdfdb6cfa383 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -100,9 +100,15 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const command = yield* commandIn({ home }); const link = path.join(home, ".local", "bin", "t3"); - expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); + expect(yield* command.state).toEqual({ + supported: true, + installedPath: null, + onPath: false, + launcherPath: null, + }); const installed = yield* command.install; expect(installed.installedPath).toBe(link); + expect(installed.launcherPath).toBe(path.join(home, ".t3", "bin", "t3")); expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); expect((yield* command.install).installedPath).toBe(link); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 95d844e602124..2ae070d638263 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -150,18 +150,26 @@ export const make = Effect.gen(function* () { const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () { if (!environment.isPackaged) { - return { supported: false, installedPath: null, onPath: false } as const; + return { supported: false, installedPath: null, onPath: false, launcherPath: null } as const; } + const launcherPath = (yield* exists(launcher)) ? launcher : null; const installed = yield* installedAt; const shadowedBy = yield* foreignFirstOnPath; const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {}; if (Option.isNone(installed)) { - return { supported: true, installedPath: null, onPath: false, ...shadow }; + return { supported: true, installedPath: null, onPath: false, launcherPath, ...shadow }; } const first = yield* firstOnPath; const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); - return { supported: true, installedPath: installed.value, onPath, ...shadow }; - }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); + return { supported: true, installedPath: installed.value, onPath, launcherPath, ...shadow }; + }).pipe( + Effect.orElseSucceed(() => ({ + supported: false, + installedPath: null, + onPath: false, + launcherPath: null, + })), + ); const ensureLauncher = DesktopCliShim.install.pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), diff --git a/apps/web/index.html b/apps/web/index.html index 6c01298b624d9..54854af23874c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -462,6 +462,19 @@ font: inherit; cursor: pointer; } + + #boot-error-command { + display: flex; + align-items: center; + gap: 12px; + max-width: 100%; + } + + #boot-error code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow-wrap: anywhere; + user-select: all; + } T3 Code (Beta) diff --git a/apps/web/src/lib/bootError.test.ts b/apps/web/src/lib/bootError.test.ts new file mode 100644 index 0000000000000..64869e0be0050 --- /dev/null +++ b/apps/web/src/lib/bootError.test.ts @@ -0,0 +1,50 @@ +import type { DesktopBridge, DesktopCliCommandState } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { resolveTriageCommand } from "./bootError"; + +const desktopWith = (getState: () => Promise) => + ({ cliCommand: { getState } }) as unknown as DesktopBridge; + +const notOnPath = (launcherPath: string | null): DesktopCliCommandState => ({ + supported: launcherPath !== null, + installedPath: null, + onPath: false, + launcherPath, +}); + +describe("resolveTriageCommand", () => { + it("prefers t3 on PATH, then the desktop launcher", async () => { + const onPath = { ...notOnPath("/home/me/.t3/bin/t3"), onPath: true }; + await expect(resolveTriageCommand(desktopWith(async () => onPath))).resolves.toBe("t3 triage"); + await expect( + resolveTriageCommand(desktopWith(async () => notOnPath("/home/me/.t3/bin/t3"))), + ).resolves.toBe("/home/me/.t3/bin/t3 triage"); + }); + + it("quotes launcher paths for the shell that runs them", async () => { + await expect( + resolveTriageCommand(desktopWith(async () => notOnPath("/Users/O'Neil/.t3/bin/t3"))), + ).resolves.toBe(`'/Users/O'"'"'Neil/.t3/bin/t3' triage`); + await expect( + resolveTriageCommand(desktopWith(async () => notOnPath("C:\\Users\\Me\\.t3\\bin\\t3.cmd"))), + ).resolves.toBe("C:\\Users\\Me\\.t3\\bin\\t3.cmd triage"); + await expect( + resolveTriageCommand( + desktopWith(async () => notOnPath("C:\\Users\\Jo O'Neil\\.t3\\bin\\t3.cmd")), + ), + ).resolves.toBe("& 'C:\\Users\\Jo O''Neil\\.t3\\bin\\t3.cmd' triage"); + }); + + it("falls back to npx without a usable desktop launcher", async () => { + using _warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await expect(resolveTriageCommand(undefined)).resolves.toBe("npx t3 triage"); + await expect(resolveTriageCommand({} as DesktopBridge)).resolves.toBe("npx t3 triage"); + await expect(resolveTriageCommand(desktopWith(async () => notOnPath(null)))).resolves.toBe( + "npx t3 triage", + ); + await expect( + resolveTriageCommand(desktopWith(() => Promise.reject(new Error("ipc closed")))), + ).resolves.toBe("npx t3 triage"); + }); +}); diff --git a/apps/web/src/lib/bootError.ts b/apps/web/src/lib/bootError.ts index b7c4690d45718..992a9b0e76e8a 100644 --- a/apps/web/src/lib/bootError.ts +++ b/apps/web/src/lib/bootError.ts @@ -1,3 +1,31 @@ +import type { DesktopBridge } from "@t3tools/contracts"; + +const FALLBACK_TRIAGE_COMMAND = "npx t3 triage"; + +/** The Windows launcher is a `.cmd` run from PowerShell, which needs `&` to run a quoted path. */ +function launcherCommand(launcherPath: string) { + if (launcherPath.endsWith(".cmd")) { + return /^[\w:\\.-]+$/.test(launcherPath) + ? launcherPath + : `& '${launcherPath.replace(/['\u2018\u2019\u201A\u201B]/g, "$&$&")}'`; + } + return /^[\w@%+=:,./-]+$/.test(launcherPath) + ? launcherPath + : `'${launcherPath.replaceAll("'", `'"'"'`)}'`; +} + +/** Desktop can run its bundled launcher without Node; everything else falls back to npx. */ +export async function resolveTriageCommand(bridge: DesktopBridge | undefined) { + try { + const state = await bridge?.cliCommand?.getState(); + if (state?.onPath) return "t3 triage"; + if (state?.launcherPath) return `${launcherCommand(state.launcherPath)} triage`; + } catch (error) { + console.warn("Could not read the desktop CLI state.", error); + } + return FALLBACK_TRIAGE_COMMAND; +} + export function showBootError(error: unknown) { console.error("T3 Code failed to start.", error); const bootShell = document.getElementById("boot-shell"); @@ -22,5 +50,29 @@ export function showBootError(error: unknown) { reload.textContent = "Reload"; reload.addEventListener("click", () => window.location.reload()); content.append(reload); + + const hint = document.createElement("p"); + hint.textContent = "If Reload does not fix the problem, run this command in a terminal:"; + const command = document.createElement("code"); + command.textContent = FALLBACK_TRIAGE_COMMAND; + const commandRow = document.createElement("div"); + commandRow.id = "boot-error-command"; + commandRow.append(command); + // The clipboard API exists only in secure contexts, such as HTTPS and the desktop app. + if (window.isSecureContext) { + const copy = document.createElement("button"); + copy.type = "button"; + copy.textContent = "Copy"; + copy.addEventListener("click", () => { + navigator.clipboard.writeText(command.textContent ?? "").then( + () => (copy.textContent = "Copied"), + (copyError) => console.warn("Could not copy the triage command.", copyError), + ); + }); + commandRow.append(copy); + } + content.append(hint, commandRow); bootShell.replaceChildren(content); + + void resolveTriageCommand(window.desktopBridge).then((value) => (command.textContent = value)); } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 34d4162081751..b353eca721199 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -321,6 +321,8 @@ export const DesktopCliCommandStateSchema = Schema.Struct({ installedPath: Schema.NullOr(Schema.String), onPath: Schema.Boolean, shadowedBy: Schema.optionalKey(Schema.String), + /** The app's bundled `t3` launcher, which runs without Node or a PATH entry. */ + launcherPath: Schema.NullOr(Schema.String), }); export type DesktopCliCommandState = typeof DesktopCliCommandStateSchema.Type; From f53e6fa147ffb4ac6558042291a95c1f19f301ca Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 21:48:46 -0700 Subject: [PATCH 2/4] fix(web): clearer hierarchy and spacing on the boot error screen One left-aligned column: title, muted hint, the command in a single block with an icon copy button, then a filled Reload button. Colors follow the light, dark, and selected boot themes. Co-Authored-By: Claude Opus 5.5 --- apps/web/index.html | 99 +++++++++++++++++++++++++++++------ apps/web/src/lib/bootError.ts | 44 +++++++++++----- 2 files changed, 112 insertions(+), 31 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 54854af23874c..f73e644d99eda 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -440,41 +440,106 @@ } #boot-error { - display: grid; - justify-items: center; - gap: 16px; - max-width: 600px; - padding: 24px; - text-align: center; + --boot-error-fg: #262626; + --boot-error-bg: #ffffff; + display: flex; + flex-direction: column; + align-items: flex-start; + width: min(400px, calc(100% - 48px)); + font-size: 13px; + line-height: 20px; } - #boot-error p { + html.dark #boot-error { + --boot-error-fg: #f5f5f5; + --boot-error-bg: #0a0a0a; + } + + html[data-theme-selected="true"] #boot-error { + --boot-error-fg: var(--boot-foreground); + --boot-error-bg: var(--boot-background); + } + + #boot-error h1 { margin: 0; + font-size: 15px; + font-weight: 600; + line-height: 22px; + } + + #boot-error p { + margin: 4px 0 0; + opacity: 0.6; + overflow-wrap: anywhere; + text-wrap: pretty; + } + + #boot-error code { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 12px; overflow-wrap: anywhere; } #boot-error button { - padding: 8px 16px; - border: 1px solid currentColor; - border-radius: 4px; - background: transparent; - color: inherit; + border: 0; font: inherit; cursor: pointer; } + #boot-error button:focus-visible { + outline: 2px solid var(--boot-error-fg); + outline-offset: 2px; + } + #boot-error-command { display: flex; align-items: center; - gap: 12px; - max-width: 100%; + gap: 8px; + box-sizing: border-box; + width: 100%; + margin-top: 16px; + padding: 6px 6px 6px 12px; + border: 1px solid color-mix(in srgb, currentColor 12%, transparent); + border-radius: 8px; + background: color-mix(in srgb, currentColor 4%, transparent); } - #boot-error code { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - overflow-wrap: anywhere; + #boot-error-command code { + flex: 1; + min-width: 0; user-select: all; } + + #boot-error-copy { + display: grid; + flex: none; + place-items: center; + width: 28px; + height: 28px; + border-radius: 6px; + background: transparent; + color: inherit; + opacity: 0.6; + } + + #boot-error-copy:hover { + opacity: 1; + background: color-mix(in srgb, currentColor 8%, transparent); + } + + #boot-error-reload { + height: 32px; + margin-top: 24px; + padding: 0 14px; + border-radius: 6px; + background: var(--boot-error-fg); + color: var(--boot-error-bg); + font-weight: 500; + } + + #boot-error-reload:hover { + opacity: 0.9; + } T3 Code (Beta) diff --git a/apps/web/src/lib/bootError.ts b/apps/web/src/lib/bootError.ts index 992a9b0e76e8a..15e8ab5bb0224 100644 --- a/apps/web/src/lib/bootError.ts +++ b/apps/web/src/lib/bootError.ts @@ -26,6 +26,13 @@ export async function resolveTriageCommand(bridge: DesktopBridge | undefined) { return FALLBACK_TRIAGE_COMMAND; } +const icon = (paths: string) => + ``; +const COPY_ICON = icon( + '', +); +const CHECK_ICON = icon(''); + export function showBootError(error: unknown) { console.error("T3 Code failed to start.", error); const bootShell = document.getElementById("boot-shell"); @@ -35,24 +42,20 @@ export function showBootError(error: unknown) { content.id = "boot-error"; content.setAttribute("role", "alert"); - const message = document.createElement("p"); - message.textContent = "T3 Code could not load."; - content.append(message); + const title = document.createElement("h1"); + title.textContent = "T3 Code could not load"; + content.append(title); if (import.meta.env.DEV && error instanceof Error) { const detail = document.createElement("p"); - detail.textContent = error.message; + detail.append(Object.assign(document.createElement("code"), { textContent: error.message })); content.append(detail); } - const reload = document.createElement("button"); - reload.type = "button"; - reload.textContent = "Reload"; - reload.addEventListener("click", () => window.location.reload()); - content.append(reload); - const hint = document.createElement("p"); - hint.textContent = "If Reload does not fix the problem, run this command in a terminal:"; + hint.textContent = + "Try reloading. If the problem continues, run this in a terminal to find the cause."; + const command = document.createElement("code"); command.textContent = FALLBACK_TRIAGE_COMMAND; const commandRow = document.createElement("div"); @@ -61,17 +64,30 @@ export function showBootError(error: unknown) { // The clipboard API exists only in secure contexts, such as HTTPS and the desktop app. if (window.isSecureContext) { const copy = document.createElement("button"); + copy.id = "boot-error-copy"; copy.type = "button"; - copy.textContent = "Copy"; + copy.title = "Copy command"; + copy.setAttribute("aria-label", "Copy command"); + copy.innerHTML = COPY_ICON; copy.addEventListener("click", () => { navigator.clipboard.writeText(command.textContent ?? "").then( - () => (copy.textContent = "Copied"), + () => { + copy.innerHTML = CHECK_ICON; + copy.setAttribute("aria-label", "Copied"); + }, (copyError) => console.warn("Could not copy the triage command.", copyError), ); }); commandRow.append(copy); } - content.append(hint, commandRow); + + const reload = document.createElement("button"); + reload.id = "boot-error-reload"; + reload.type = "button"; + reload.textContent = "Reload"; + reload.addEventListener("click", () => window.location.reload()); + + content.append(hint, commandRow, reload); bootShell.replaceChildren(content); void resolveTriageCommand(window.desktopBridge).then((value) => (command.textContent = value)); From 359b57ac0e6146a8ddfddf812afbbe25abae3c46 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 21:52:09 -0700 Subject: [PATCH 3/4] fix(web): recommend t3 triage only when the desktop launcher exists Windows reports the CLI as on PATH from its PATH entry alone, so a missing launcher could still produce `t3 triage`. Require the launcher. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/bootError.test.ts | 4 ++++ apps/web/src/lib/bootError.ts | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/bootError.test.ts b/apps/web/src/lib/bootError.test.ts index 64869e0be0050..ff02a25d3bff5 100644 --- a/apps/web/src/lib/bootError.test.ts +++ b/apps/web/src/lib/bootError.test.ts @@ -43,6 +43,10 @@ describe("resolveTriageCommand", () => { await expect(resolveTriageCommand(desktopWith(async () => notOnPath(null)))).resolves.toBe( "npx t3 triage", ); + const staleWindowsPathEntry = { ...notOnPath(null), supported: true, onPath: true }; + await expect( + resolveTriageCommand(desktopWith(async () => staleWindowsPathEntry)), + ).resolves.toBe("npx t3 triage"); await expect( resolveTriageCommand(desktopWith(() => Promise.reject(new Error("ipc closed")))), ).resolves.toBe("npx t3 triage"); diff --git a/apps/web/src/lib/bootError.ts b/apps/web/src/lib/bootError.ts index 15e8ab5bb0224..c078aa3a2ee1d 100644 --- a/apps/web/src/lib/bootError.ts +++ b/apps/web/src/lib/bootError.ts @@ -18,8 +18,10 @@ function launcherCommand(launcherPath: string) { export async function resolveTriageCommand(bridge: DesktopBridge | undefined) { try { const state = await bridge?.cliCommand?.getState(); - if (state?.onPath) return "t3 triage"; - if (state?.launcherPath) return `${launcherCommand(state.launcherPath)} triage`; + // Windows reports onPath from its PATH entry alone; only an existing launcher proves it runs. + if (state?.launcherPath) { + return state.onPath ? "t3 triage" : `${launcherCommand(state.launcherPath)} triage`; + } } catch (error) { console.warn("Could not read the desktop CLI state.", error); } From df12a30e715fcc53073b2ae5d5d70aefdfe5b9a6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 22:25:29 -0700 Subject: [PATCH 4/4] fix(web): boot error screen leads with Reload, triage sits below Reload is the primary action under the title. The triage command moves into a quieter section below it, so it no longer reads as the main CTA. Co-Authored-By: Claude Opus 5.5 --- apps/web/index.html | 28 +++++++++++++++++++--------- apps/web/src/lib/bootError.ts | 19 +++++++++++-------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index f73e644d99eda..828439e178a11 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -497,16 +497,15 @@ gap: 8px; box-sizing: border-box; width: 100%; - margin-top: 16px; - padding: 6px 6px 6px 12px; - border: 1px solid color-mix(in srgb, currentColor 12%, transparent); - border-radius: 8px; - background: color-mix(in srgb, currentColor 4%, transparent); + padding: 4px 4px 4px 10px; + border: 1px solid color-mix(in srgb, currentColor 10%, transparent); + border-radius: 6px; } #boot-error-command code { flex: 1; min-width: 0; + opacity: 0.8; user-select: all; } @@ -514,9 +513,9 @@ display: grid; flex: none; place-items: center; - width: 28px; - height: 28px; - border-radius: 6px; + width: 24px; + height: 24px; + border-radius: 4px; background: transparent; color: inherit; opacity: 0.6; @@ -527,9 +526,20 @@ background: color-mix(in srgb, currentColor 8%, transparent); } + #boot-error-triage { + width: 100%; + margin-top: 40px; + } + + #boot-error-triage p { + margin: 0 0 8px; + font-size: 12px; + line-height: 18px; + } + #boot-error-reload { height: 32px; - margin-top: 24px; + margin-top: 16px; padding: 0 14px; border-radius: 6px; background: var(--boot-error-fg); diff --git a/apps/web/src/lib/bootError.ts b/apps/web/src/lib/bootError.ts index c078aa3a2ee1d..4653c5144bb5a 100644 --- a/apps/web/src/lib/bootError.ts +++ b/apps/web/src/lib/bootError.ts @@ -54,9 +54,14 @@ export function showBootError(error: unknown) { content.append(detail); } + const reload = document.createElement("button"); + reload.id = "boot-error-reload"; + reload.type = "button"; + reload.textContent = "Reload"; + reload.addEventListener("click", () => window.location.reload()); + const hint = document.createElement("p"); - hint.textContent = - "Try reloading. If the problem continues, run this in a terminal to find the cause."; + hint.textContent = "If reloading does not help, run this in a terminal to find the cause:"; const command = document.createElement("code"); command.textContent = FALLBACK_TRIAGE_COMMAND; @@ -83,13 +88,11 @@ export function showBootError(error: unknown) { commandRow.append(copy); } - const reload = document.createElement("button"); - reload.id = "boot-error-reload"; - reload.type = "button"; - reload.textContent = "Reload"; - reload.addEventListener("click", () => window.location.reload()); + const triage = document.createElement("div"); + triage.id = "boot-error-triage"; + triage.append(hint, commandRow); - content.append(hint, commandRow, reload); + content.append(reload, triage); bootShell.replaceChildren(content); void resolveTriageCommand(window.desktopBridge).then((value) => (command.textContent = value));