From a0ac2892b92e1e35669d55e6a05aab2cfe50e473 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:23:25 -0700 Subject: [PATCH 1/2] refactor(web): preserve terminal preview link failures Co-authored-by: codex --- .../preview/openTerminalLinkInPreview.test.ts | 109 ++++++++++++++++++ .../preview/openTerminalLinkInPreview.ts | 46 +++++++- 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/preview/openTerminalLinkInPreview.test.ts diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts new file mode 100644 index 00000000000..830cd1c07bd --- /dev/null +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -0,0 +1,109 @@ +import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + openTerminalLinkInPreview, + TerminalLinkContextMenuShowError, + TerminalLinkPreviewOpenError, +} from "./openTerminalLinkInPreview"; + +vi.mock("~/previewStateStore", () => ({ + applyPreviewServerSnapshot: vi.fn(), + isPreviewSupportedInRuntime: () => true, +})); + +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { + getState: () => ({ openBrowser: vi.fn() }), + }, +})); + +const threadRef = { + environmentId: "local" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], +}; + +const snapshot: PreviewSessionSnapshot = { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-06-20T00:00:00.000Z", +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("openTerminalLinkInPreview", () => { + it("preserves context-menu failures with terminal link context before falling back", async () => { + const cause = new Error("menu unavailable"); + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await openTerminalLinkInPreview({ + url: "http://localhost:3000/path?token=secret", + position: { x: 12, y: 34 }, + threadRef, + openPreview, + localApi: { + contextMenu: { + show: vi.fn(async () => { + throw cause; + }), + }, + } as unknown as LocalApi, + fallbackToBrowser, + }); + + expect(fallbackToBrowser).toHaveBeenCalledOnce(); + expect(openPreview).not.toHaveBeenCalled(); + expect(reportError).toHaveBeenCalledOnce(); + const error = reportError.mock.calls[0]?.[0]; + expect(error).toBeInstanceOf(TerminalLinkContextMenuShowError); + expect(error).toMatchObject({ + environmentId: "local", + threadId: "thread-1", + targetOrigin: "http://localhost:3000", + cause, + }); + expect(error.message).not.toContain("menu unavailable"); + expect(error.targetOrigin).not.toContain("secret"); + }); + + it("preserves the complete preview failure cause before falling back", async () => { + const rpcError = new Error("preview unavailable"); + const cause = Cause.combine(Cause.fail(rpcError), Cause.die("preview defect")); + const fallbackToBrowser = vi.fn(); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await openTerminalLinkInPreview({ + url: "http://127.0.0.1:5173/", + position: { x: 12, y: 34 }, + threadRef, + openPreview: async () => AsyncResult.failure(cause), + localApi: { + contextMenu: { + show: vi.fn(async () => "open-in-preview"), + }, + } as unknown as LocalApi, + fallbackToBrowser, + }); + + expect(fallbackToBrowser).toHaveBeenCalledOnce(); + expect(reportError).toHaveBeenCalledOnce(); + const error = reportError.mock.calls[0]?.[0]; + expect(error).toBeInstanceOf(TerminalLinkPreviewOpenError); + expect(error).toMatchObject({ + environmentId: "local", + threadId: "thread-1", + targetOrigin: "http://127.0.0.1:5173", + cause, + }); + expect(error.message).not.toContain("preview unavailable"); + }); +}); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 216cce060e2..50c9c36900a 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -1,10 +1,36 @@ import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts"; import { isPreviewableUrl } from "@t3tools/shared/preview"; +import * as Schema from "effect/Schema"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; +const terminalLinkErrorContext = { + environmentId: Schema.String, + threadId: Schema.String, + targetOrigin: Schema.String, + cause: Schema.Defect(), +}; + +export class TerminalLinkContextMenuShowError extends Schema.TaggedErrorClass()( + "TerminalLinkContextMenuShowError", + terminalLinkErrorContext, +) { + override get message(): string { + return `Failed to show the context menu for terminal link ${this.targetOrigin}.`; + } +} + +export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass()( + "TerminalLinkPreviewOpenError", + terminalLinkErrorContext, +) { + override get message(): string { + return `Failed to open terminal link ${this.targetOrigin} in preview for thread ${this.threadId}.`; + } +} + interface OpenTerminalLinkInPreviewInput { readonly url: string; readonly position: { x: number; y: number }; @@ -27,6 +53,12 @@ export async function openTerminalLinkInPreview( return; } + const errorContext = { + environmentId: input.threadRef.environmentId, + threadId: input.threadRef.threadId, + targetOrigin: new URL(input.url).origin, + }; + let choice: "open-in-preview" | "open-in-browser" | null; try { choice = await input.localApi.contextMenu.show( @@ -36,7 +68,13 @@ export async function openTerminalLinkInPreview( ], input.position, ); - } catch { + } catch (cause) { + console.error( + new TerminalLinkContextMenuShowError({ + ...errorContext, + cause, + }), + ); input.fallbackToBrowser(); return; } @@ -47,6 +85,12 @@ export async function openTerminalLinkInPreview( input: { threadId: input.threadRef.threadId, url: input.url }, }); if (result._tag === "Failure") { + console.error( + new TerminalLinkPreviewOpenError({ + ...errorContext, + cause: result.cause, + }), + ); input.fallbackToBrowser(); return; } From 799e00576b8ab74873a510a3ad731eb917ac00aa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:30:25 -0700 Subject: [PATCH 2/2] fix(web): ignore interrupted preview opens Co-authored-by: codex --- .../preview/openTerminalLinkInPreview.test.ts | 21 +++++++++++++++++++ .../preview/openTerminalLinkInPreview.ts | 4 ++++ 2 files changed, 25 insertions(+) diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 830cd1c07bd..47f03761f6b 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -106,4 +106,25 @@ describe("openTerminalLinkInPreview", () => { }); expect(error.message).not.toContain("preview unavailable"); }); + + it("does not report or fall back when opening the preview is interrupted", async () => { + const fallbackToBrowser = vi.fn(); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await openTerminalLinkInPreview({ + url: "http://localhost:5173/", + position: { x: 12, y: 34 }, + threadRef, + openPreview: async () => AsyncResult.failure(Cause.interrupt()), + localApi: { + contextMenu: { + show: vi.fn(async () => "open-in-preview"), + }, + } as unknown as LocalApi, + fallbackToBrowser, + }); + + expect(reportError).not.toHaveBeenCalled(); + expect(fallbackToBrowser).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 50c9c36900a..312eab9eb35 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -1,4 +1,5 @@ import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { isPreviewableUrl } from "@t3tools/shared/preview"; import * as Schema from "effect/Schema"; @@ -85,6 +86,9 @@ export async function openTerminalLinkInPreview( input: { threadId: input.threadRef.threadId, url: input.url }, }); if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + return; + } console.error( new TerminalLinkPreviewOpenError({ ...errorContext,