diff --git a/apps/web/index.html b/apps/web/index.html index 828439e178a1..13518deb6e5d 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -439,13 +439,23 @@ object-fit: contain; } + /* App CSS locks body and #root scrolling once main loads, so the error screen scrolls itself. */ + #boot-shell:has(> #boot-error) { + height: 100%; + overflow-x: hidden; + overflow-y: auto; + } + #boot-error { --boot-error-fg: #262626; --boot-error-bg: #ffffff; + --boot-error-line: color-mix(in srgb, currentColor 12%, transparent); display: flex; flex-direction: column; align-items: flex-start; + align-self: flex-start; width: min(400px, calc(100% - 48px)); + padding: clamp(24px, 22vh, 220px) 0 24px; font-size: 13px; line-height: 20px; } @@ -469,36 +479,134 @@ #boot-error p { margin: 4px 0 0; - opacity: 0.6; + opacity: 0.7; overflow-wrap: anywhere; text-wrap: pretty; } - #boot-error code { + #boot-error code, + #boot-error pre { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; } + #boot-error a { + color: inherit; + text-decoration-line: underline; + text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); + text-underline-offset: 3px; + } + + #boot-error a:hover { + text-decoration-color: currentColor; + } + #boot-error button { - border: 0; + height: 32px; + padding: 0 14px; + border: 1px solid var(--boot-error-line); + border-radius: 6px; + background: transparent; + color: inherit; font: inherit; + font-weight: 500; cursor: pointer; } - #boot-error button:focus-visible { + #boot-error button[aria-disabled="true"] { + opacity: 0.6; + cursor: default; + } + + #boot-error :is(button, a, summary, pre):focus-visible { outline: 2px solid var(--boot-error-fg); outline-offset: 2px; } + #boot-error-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; + } + + #boot-error #boot-error-reload { + border-color: var(--boot-error-fg); + background: var(--boot-error-fg); + color: var(--boot-error-bg); + } + + #boot-error-reload:hover { + opacity: 0.9; + } + + #boot-error #boot-error-update-note { + margin-top: 8px; + font-size: 12px; + line-height: 18px; + } + + #boot-error details { + width: 100%; + margin-top: 32px; + } + + #boot-error summary { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 4px; + list-style: none; + opacity: 0.7; + cursor: pointer; + } + + #boot-error summary::-webkit-details-marker { + display: none; + } + + #boot-error summary::before { + content: ""; + width: 5px; + height: 5px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + } + + #boot-error details[open] summary::before { + transform: rotate(45deg); + } + + #boot-error summary:hover, + #boot-error details[open] summary { + opacity: 1; + } + + #boot-error details > :not(summary) { + display: grid; + gap: 8px; + margin-top: 20px; + } + + #boot-error details > a { + display: block; + width: fit-content; + } + + #boot-error details p { + margin: 0; + font-size: 12px; + line-height: 18px; + } + #boot-error-command { display: flex; align-items: center; gap: 8px; - box-sizing: border-box; - width: 100%; padding: 4px 4px 4px 10px; - border: 1px solid color-mix(in srgb, currentColor 10%, transparent); + border: 1px solid var(--boot-error-line); border-radius: 6px; } @@ -506,49 +614,58 @@ flex: 1; min-width: 0; opacity: 0.8; - user-select: all; } - #boot-error-copy { + #boot-error .boot-error-icon { display: grid; flex: none; place-items: center; - width: 24px; + min-width: 24px; height: 24px; + padding: 0 4px; + border: 0; border-radius: 4px; - background: transparent; - color: inherit; - opacity: 0.6; + font-size: 12px; + font-weight: 400; + opacity: 0.7; } - #boot-error-copy:hover { + #boot-error .boot-error-icon:hover { opacity: 1; background: color-mix(in srgb, currentColor 8%, transparent); } - #boot-error-triage { - width: 100%; - margin-top: 40px; + #boot-error-details { + max-height: 200px; + margin: 0; + padding: 10px 12px; + overflow: auto; + border: 1px solid var(--boot-error-line); + border-radius: 6px; + font-size: 11px; + line-height: 16px; + white-space: pre-wrap; + opacity: 0.8; } - #boot-error-triage p { - margin: 0 0 8px; - font-size: 12px; - line-height: 18px; + #boot-error-report { + display: flex; + align-items: center; + gap: 16px; } - #boot-error-reload { - height: 32px; - margin-top: 16px; - padding: 0 14px; - border-radius: 6px; - background: var(--boot-error-fg); - color: var(--boot-error-bg); - font-weight: 500; + #boot-error-report button { + height: 28px; + padding: 0 10px; } - #boot-error-reload:hover { - opacity: 0.9; + .boot-error-sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } T3 Code (Beta) diff --git a/apps/web/src/lib/bootError.test.ts b/apps/web/src/lib/bootError.test.ts index ff02a25d3bff..e04e8bef2cfb 100644 --- a/apps/web/src/lib/bootError.test.ts +++ b/apps/web/src/lib/bootError.test.ts @@ -1,7 +1,12 @@ -import type { DesktopBridge, DesktopCliCommandState } from "@t3tools/contracts"; +import type { DesktopBridge, DesktopCliCommandState, DesktopUpdateState } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { resolveTriageCommand } from "./bootError"; +import { + type BootErrorEnvironment, + buildErrorReport, + resolveTriageCommand, + updateActionFor, +} from "./bootError"; const desktopWith = (getState: () => Promise) => ({ cliCommand: { getState } }) as unknown as DesktopBridge; @@ -52,3 +57,77 @@ describe("resolveTriageCommand", () => { ).resolves.toBe("npx t3 triage"); }); }); + +const environment: BootErrorEnvironment = { + version: "0.1.3-nightly.20261026.3707", + channel: "nightly", + surface: "web", + platform: "MacIntel", + userAgent: "Test", + time: "2026-10-26T00:00:00.000Z", +}; + +describe("buildErrorReport", () => { + it("drops hosts and tokens from URLs in the error", () => { + const error = new TypeError( + "Failed to fetch dynamically imported module: http://studio.tail1234.ts.net:3773/assets/a.js?pair=SECRET#token=SECRET", + { + cause: new Error( + "ws://studio.tail1234.ts.net:3773/ws?token=SECRET closed, main-a.js?sig=SECRET", + ), + }, + ); + error.stack = `TypeError: ${error.message}\n at http://studio.tail1234.ts.net:3773/src/main.tsx?t=1:10:5`; + const report = buildErrorReport(error, environment); + expect(report).not.toContain("SECRET"); + expect(report).not.toContain("tail1234"); + expect(report).toContain("module: /assets/a.js\n at /src/main.tsx:10:5"); + expect(report).toContain("Caused by: Error: /ws closed, main-a.js"); + expect(report).toContain("Version: 0.1.3-nightly.20261026.3707"); + }); + + it("keeps the header when a Firefox stack frame starts with the error name", () => { + const error = new Error("boot failed"); + error.stack = "ErrorBoundary@/assets/main.js:1:2"; + expect(buildErrorReport(error, environment)).toMatch( + /^Error: boot failed\nErrorBoundary@\/assets\/main.js:1:2\n/, + ); + const unnamed = new Error(); + unnamed.stack = "ErrorBoundary@/assets/main.js:1:2"; + expect(buildErrorReport(unnamed, environment)).toMatch(/^Error\nErrorBoundary@/); + }); + + it("stops at cause cycles and survives values that are not errors", () => { + const loop = new Error("outer"); + loop.cause = loop; + expect(buildErrorReport(loop, environment).match(/outer/g)).toHaveLength(1); + expect(buildErrorReport("plain text", environment)).toMatch(/^plain text\n/); + expect(buildErrorReport({ code: 42 }, environment)).toMatch(/^\{"code":42\}\n/); + expect(buildErrorReport(Object.create(null), environment)).toMatch(/^\{\}\n/); + expect(buildErrorReport(undefined, environment)).toMatch(/^\(no error message\)\n/); + }); + + it("cuts long errors but keeps the environment", () => { + const report = buildErrorReport("x".repeat(20_000), environment); + expect(report).toContain("[truncated]"); + expect(report.length).toBeLessThan(7_000); + expect(report).toContain("Time: 2026-10-26T00:00:00.000Z"); + }); +}); + +describe("updateActionFor", () => { + it("offers an action only when a newer build exists", () => { + const state = (status: DesktopUpdateState["status"], downloadPercent: number | null = null) => + ({ status, downloadPercent }) as DesktopUpdateState; + expect(updateActionFor(state("available"))).toEqual({ kind: "download" }); + expect(updateActionFor(state("downloading", 41.6))).toEqual({ + kind: "downloading", + percent: 42, + }); + expect(updateActionFor(state("downloaded"))).toEqual({ kind: "install" }); + for (const status of ["disabled", "idle", "checking", "up-to-date", "error"] as const) { + expect(updateActionFor(state(status))).toBeNull(); + } + expect(updateActionFor(undefined)).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/bootError.ts b/apps/web/src/lib/bootError.ts index 4653c5144bb5..4b30fd7aa024 100644 --- a/apps/web/src/lib/bootError.ts +++ b/apps/web/src/lib/bootError.ts @@ -1,6 +1,15 @@ -import type { DesktopBridge } from "@t3tools/contracts"; +import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; const FALLBACK_TRIAGE_COMMAND = "npx t3 triage"; +const DOWNLOAD_URL = "https://t3.codes/download"; +const ISSUE_URL = "https://github.com/pingdotgg/t3code/issues/new"; +const LAST_FAILURE_KEY = "t3code:boot-failed-at"; +const REPEAT_FAILURE_MS = 5 * 60_000; +const MAX_CAUSES = 3; +const MAX_ERROR_CHARS = 6_000; +const COPY_BLOCKED = "Copy was blocked. The text is selected so you can copy it manually."; +const UPDATE_DOWNLOAD_FAILED = "The update download failed. Try Download update again."; +const UPDATE_INSTALL_FAILED = "The update install failed. Try Restart to update again."; /** The Windows launcher is a `.cmd` run from PowerShell, which needs `&` to run a quoted path. */ function launcherCommand(launcherPath: string) { @@ -28,6 +37,120 @@ export async function resolveTriageCommand(bridge: DesktopBridge | undefined) { return FALLBACK_TRIAGE_COMMAND; } +/** The update button only appears when the desktop updater has a newer build to offer. */ +export function updateActionFor(state: DesktopUpdateState | null | undefined) { + switch (state?.status) { + case "available": + return { kind: "download" } as const; + case "downloading": + return { kind: "downloading", percent: Math.round(state.downloadPercent ?? 0) } as const; + case "downloaded": + return { kind: "install" } as const; + default: + return null; + } +} + +function safeString(value: unknown) { + try { + return String(value); + } catch { + return ""; + } +} + +function describeOne(value: unknown) { + if (value instanceof Error) { + const header = value.message ? `${value.name}: ${value.message}` : value.name; + // V8 stacks start with the header; Safari and Firefox stacks do not. + const stack = typeof value.stack === "string" ? value.stack : ""; + return stack === header || stack.startsWith(`${header}\n`) + ? stack + : [header, stack].filter(Boolean).join("\n"); + } + if (typeof value === "string") return value; + try { + return JSON.stringify(value) ?? safeString(value); + } catch { + return safeString(value); + } +} + +/** Hosts can name private machines, and pairing tokens live in query strings and hashes. */ +function redact(text: string) { + return text + .replace(/\b[a-z][\w+.-]*:\/\/[^\s/?#'"()]+/gi, "") + .replace(/[?#][^\s'"()]*=[^\s'"()]*?((?::\d+){1,2})?(?=[\s'"()]|$)/g, "$1"); +} + +export interface BootErrorEnvironment { + readonly version: string; + readonly channel: string; + readonly surface: "desktop" | "web"; + readonly platform: string; + readonly userAgent: string; + readonly time: string; +} + +/** Plain text that is shown and copied as-is, so users see exactly what they share. */ +export function buildErrorReport(error: unknown, environment: BootErrorEnvironment) { + const parts: Array = []; + const seen = new Set(); + let current = error; + while (current != null && !seen.has(current) && parts.length <= MAX_CAUSES) { + seen.add(current); + const text = describeOne(current); + parts.push(parts.length === 0 ? text : `Caused by: ${text}`); + current = current instanceof Error ? current.cause : undefined; + } + let details = redact(parts.join("\n") || "(no error message)"); + if (details.length > MAX_ERROR_CHARS) { + details = `${details.slice(0, MAX_ERROR_CHARS)}\n[truncated]`; + } + return [ + details, + "", + `Version: ${environment.version}`, + `Channel: ${environment.channel}`, + `Surface: ${environment.surface}`, + `Platform: ${environment.platform}`, + `User agent: ${environment.userAgent}`, + `Time: ${environment.time}`, + ].join("\n"); +} + +function clientPlatform(bridge: DesktopBridge | undefined) { + try { + return bridge?.getClientPlatform?.() ?? navigator.platform; + } catch { + return navigator.platform; + } +} + +function readEnvironment(bridge: DesktopBridge | undefined): BootErrorEnvironment { + const version = import.meta.env.APP_VERSION; + return { + version, + channel: + import.meta.env.VITE_RELEASE_CHANNEL || /-(nightly|preview)\./.exec(version)?.[1] || "stable", + surface: bridge ? "desktop" : "web", + platform: clientPlatform(bridge), + userAgent: navigator.userAgent, + time: new Date().toISOString(), + }; +} + +/** Opens More options by default when startup already failed in this tab a moment ago. */ +function isRepeatFailure(now: number) { + try { + const previous = Number(sessionStorage.getItem(LAST_FAILURE_KEY)); + sessionStorage.setItem(LAST_FAILURE_KEY, String(now)); + return now - previous < REPEAT_FAILURE_MS; + } catch { + return false; + } +} + const icon = (paths: string) => ``; const COPY_ICON = icon( @@ -35,65 +158,238 @@ const COPY_ICON = icon( ); const CHECK_ICON = icon(''); +function element( + tag: K, + props: Partial = {}, + children: ReadonlyArray = [], +) { + const node = Object.assign(document.createElement(tag), props); + node.append(...children); + return node; +} + +function selectContents(target: HTMLElement) { + const range = document.createRange(); + range.selectNodeContents(target); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} + +function copySelection() { + try { + return document.execCommand("copy"); + } catch { + return false; + } +} + +/** Plain http has no Clipboard API, but the legacy copy command still works on a selection. */ +function copyText(target: HTMLElement, onCopied: () => void, onBlocked: (hint: string) => void) { + const fallback = () => { + selectContents(target); + if (copySelection()) return onCopied(); + onBlocked( + /Mac|iPhone|iPad/.test(navigator.platform) ? "Press ⌘C to copy" : "Press Ctrl+C to copy", + ); + }; + const clipboard = window.isSecureContext ? navigator.clipboard : undefined; + if (typeof clipboard?.writeText !== "function") return fallback(); + clipboard.writeText(target.textContent ?? "").then(onCopied, fallback); +} + +function mountUpdateButton( + bridge: DesktopBridge, + button: HTMLButtonElement, + note: HTMLParagraphElement, + announce: (text: string) => void, +) { + let action: ReturnType = null; + const render = (state: DesktopUpdateState) => { + action = updateActionFor(state); + button.hidden = action === null; + // aria-disabled keeps keyboard focus on the button while the download runs. + button.setAttribute("aria-disabled", String(action?.kind === "downloading")); + if (action?.kind === "download") button.textContent = "Download update"; + if (action?.kind === "downloading") + button.textContent = `Downloading update (${action.percent}%)`; + if (action?.kind === "install") button.textContent = "Restart to update"; + const failure = !action + ? "" + : state.errorContext === "download" + ? UPDATE_DOWNLOAD_FAILED + : state.errorContext === "install" + ? UPDATE_INSTALL_FAILED + : ""; + if (failure === note.textContent) return; + note.textContent = failure; + note.hidden = !failure; + if (failure) announce(failure); + }; + button.addEventListener("click", () => { + const run = + action?.kind === "install" + ? bridge.installUpdate() + : action?.kind === "download" + ? bridge.downloadUpdate() + : undefined; + run?.then( + (result) => render(result.state), + (error: unknown) => console.warn("Could not update T3 Code.", error), + ); + }); + // A pushed state is newer than the initial read, so the read must not overwrite it. + let pushed = false; + try { + bridge.onUpdateState((state) => { + pushed = true; + render(state); + }); + bridge.getUpdateState().then( + (state) => pushed || render(state), + (error: unknown) => console.warn("Could not read the desktop update state.", error), + ); + } catch (error) { + console.warn("Could not read the desktop update state.", error); + } +} + export function showBootError(error: unknown) { console.error("T3 Code failed to start.", error); const bootShell = document.getElementById("boot-shell"); if (!bootShell) return; - const content = document.createElement("div"); - content.id = "boot-error"; - content.setAttribute("role", "alert"); + const bridge = window.desktopBridge; + const environment = readEnvironment(bridge); + const offline = !bridge && navigator.onLine === false; - const title = document.createElement("h1"); - title.textContent = "T3 Code could not load"; - content.append(title); + const status = element("span", { className: "boot-error-sr" }); + status.setAttribute("aria-live", "polite"); + // Clearing first makes screen readers announce a repeated message. + let announcement: ReturnType | undefined; + const announce = (text: string) => { + clearTimeout(announcement); + status.textContent = ""; + announcement = setTimeout(() => (status.textContent = text), 100); + }; - if (import.meta.env.DEV && error instanceof Error) { - const detail = document.createElement("p"); - detail.append(Object.assign(document.createElement("code"), { textContent: error.message })); - content.append(detail); - } + const message = element("div", {}, [ + element("h1", { textContent: "T3 Code failed to start" }), + element("p", { + textContent: offline + ? "This browser appears to be offline. Check the connection, then reload." + : "We are not sure why. Try a reload first.", + }), + ]); + message.setAttribute("role", "alert"); - const reload = document.createElement("button"); - reload.id = "boot-error-reload"; - reload.type = "button"; - reload.textContent = "Reload"; + const reload = element("button", { + id: "boot-error-reload", + type: "button", + textContent: "Reload", + }); reload.addEventListener("click", () => window.location.reload()); + const update = element("button", { id: "boot-error-update", type: "button", hidden: true }); + const updateNote = element("p", { id: "boot-error-update-note", hidden: true }); + if (bridge) mountUpdateButton(bridge, update, updateNote, announce); - const hint = document.createElement("p"); - 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; - 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.id = "boot-error-copy"; - copy.type = "button"; - copy.title = "Copy command"; - copy.setAttribute("aria-label", "Copy command"); - copy.innerHTML = COPY_ICON; - copy.addEventListener("click", () => { - navigator.clipboard.writeText(command.textContent ?? "").then( - () => { - copy.innerHTML = CHECK_ICON; - copy.setAttribute("aria-label", "Copied"); - }, - (copyError) => console.warn("Could not copy the triage command.", copyError), - ); - }); - commandRow.append(copy); + const command = element("code", { textContent: FALLBACK_TRIAGE_COMMAND }); + const copyCommand = element("button", { + className: "boot-error-icon", + type: "button", + title: "Copy command", + innerHTML: COPY_ICON, + }); + copyCommand.setAttribute("aria-label", "Copy command"); + copyCommand.addEventListener("click", () => + copyText( + command, + () => { + copyCommand.innerHTML = CHECK_ICON; + copyCommand.title = "Copied"; + copyCommand.setAttribute("aria-label", "Copied"); + announce("Copied to clipboard."); + }, + (hint) => { + copyCommand.textContent = hint; + copyCommand.title = hint; + copyCommand.setAttribute("aria-label", hint); + announce(COPY_BLOCKED); + }, + ), + ); + void resolveTriageCommand(bridge).then((value) => (command.textContent = value)); + + let report = "(no error message)"; + try { + report = buildErrorReport(error, environment); + } catch (reportError) { + console.warn("Could not describe the startup error.", reportError); } + const details = element("pre", { id: "boot-error-details", tabIndex: 0, textContent: report }); + const copyDetails = element("button", { type: "button", textContent: "Copy error details" }); + copyDetails.addEventListener("click", () => + copyText( + details, + () => { + copyDetails.textContent = "Copied"; + announce("Copied to clipboard."); + }, + (hint) => { + copyDetails.textContent = hint; + announce(COPY_BLOCKED); + }, + ), + ); - const triage = document.createElement("div"); - triage.id = "boot-error-triage"; - triage.append(hint, commandRow); + const issue = new URL(ISSUE_URL); + issue.searchParams.set("template", "bug_report.yml"); + issue.searchParams.set("title", "T3 Code failed to start"); + issue.searchParams.set("area", "apps/web"); + issue.searchParams.set("version", `${environment.version} (${environment.channel})`); + issue.searchParams.set("environment", `${environment.surface}, ${environment.platform}`); + const link = (href: string, textContent: string) => + element("a", { href, textContent, target: "_blank", rel: "noreferrer" }); + const nightly = environment.channel === "nightly"; - content.append(reload, triage); - bootShell.replaceChildren(content); + const more = element("details", { open: isRepeatFailure(Date.now()) }, [ + element("summary", { textContent: "More options if it keeps failing" }), + ...(bridge + ? [ + link( + nightly ? `${DOWNLOAD_URL}?channel=nightly` : DOWNLOAD_URL, + nightly + ? "Download and reinstall the latest nightly build" + : "Download and reinstall T3 Code", + ), + ] + : []), + element("div", {}, [ + element("p", { + textContent: + "Run this on the computer that runs T3 Code. Your agent can check the local logs and help you file a bug report.", + }), + element("div", { id: "boot-error-command" }, [command, copyCommand]), + ]), + element("div", {}, [ + details, + element("p", { + textContent: "Details may include local file paths and names. Check before sharing.", + }), + element("div", { id: "boot-error-report" }, [ + copyDetails, + link(issue.href, "File a bug report on GitHub"), + ]), + ]), + ]); - void resolveTriageCommand(window.desktopBridge).then((value) => (command.textContent = value)); + bootShell.replaceChildren( + element("div", { id: "boot-error" }, [ + message, + element("div", { id: "boot-error-actions" }, [reload, update]), + updateNote, + more, + status, + ]), + ); }