authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:12:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:12:50-07:00
log50767683b396cf7065bb18072d6d14c05475b6e9
treeaa37296358fc59618834398e02ff6eb423cc2306
parentd1339f38462c206d65d3c8d8396c1ddfdfbb807c
signature Signed by PGP key B5690EEEBB952194

[codex] Preserve terminal preview link failure context (#3367)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 179 insertions(+), 1 deletions(-)

apps/web/src/components/preview/openTerminalLinkInPreview.test.ts created+130
......@@ -0,0 +1,130 @@
1import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/unstable/reactivity";
4import { afterEach, describe, expect, it, vi } from "vite-plus/test";
5
6import {
7 openTerminalLinkInPreview,
8 TerminalLinkContextMenuShowError,
9 TerminalLinkPreviewOpenError,
10} from "./openTerminalLinkInPreview";
11
12vi.mock("~/previewStateStore", () => ({
13 applyPreviewServerSnapshot: vi.fn(),
14 isPreviewSupportedInRuntime: () => true,
15}));
16
17vi.mock("~/rightPanelStore", () => ({
18 useRightPanelStore: {
19 getState: () => ({ openBrowser: vi.fn() }),
20 },
21}));
22
23const threadRef = {
24 environmentId: "local" as ScopedThreadRef["environmentId"],
25 threadId: "thread-1" as ScopedThreadRef["threadId"],
26};
27
28const snapshot: PreviewSessionSnapshot = {
29 threadId: threadRef.threadId,
30 tabId: "tab-1",
31 navStatus: { _tag: "Idle" },
32 canGoBack: false,
33 canGoForward: false,
34 updatedAt: "2026-06-20T00:00:00.000Z",
35};
36
37afterEach(() => {
38 vi.restoreAllMocks();
39});
40
41describe("openTerminalLinkInPreview", () => {
42 it("preserves context-menu failures with terminal link context before falling back", async () => {
43 const cause = new Error("menu unavailable");
44 const fallbackToBrowser = vi.fn();
45 const openPreview = vi.fn(async () => AsyncResult.success(snapshot));
46 const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined);
47
48 await openTerminalLinkInPreview({
49 url: "http://localhost:3000/path?token=secret",
50 position: { x: 12, y: 34 },
51 threadRef,
52 openPreview,
53 localApi: {
54 contextMenu: {
55 show: vi.fn(async () => {
56 throw cause;
57 }),
58 },
59 } as unknown as LocalApi,
60 fallbackToBrowser,
61 });
62
63 expect(fallbackToBrowser).toHaveBeenCalledOnce();
64 expect(openPreview).not.toHaveBeenCalled();
65 expect(reportError).toHaveBeenCalledOnce();
66 const error = reportError.mock.calls[0]?.[0];
67 expect(error).toBeInstanceOf(TerminalLinkContextMenuShowError);
68 expect(error).toMatchObject({
69 environmentId: "local",
70 threadId: "thread-1",
71 targetOrigin: "http://localhost:3000",
72 cause,
73 });
74 expect(error.message).not.toContain("menu unavailable");
75 expect(error.targetOrigin).not.toContain("secret");
76 });
77
78 it("preserves the complete preview failure cause before falling back", async () => {
79 const rpcError = new Error("preview unavailable");
80 const cause = Cause.combine(Cause.fail(rpcError), Cause.die("preview defect"));
81 const fallbackToBrowser = vi.fn();
82 const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined);
83
84 await openTerminalLinkInPreview({
85 url: "http://127.0.0.1:5173/",
86 position: { x: 12, y: 34 },
87 threadRef,
88 openPreview: async () => AsyncResult.failure(cause),
89 localApi: {
90 contextMenu: {
91 show: vi.fn(async () => "open-in-preview"),
92 },
93 } as unknown as LocalApi,
94 fallbackToBrowser,
95 });
96
97 expect(fallbackToBrowser).toHaveBeenCalledOnce();
98 expect(reportError).toHaveBeenCalledOnce();
99 const error = reportError.mock.calls[0]?.[0];
100 expect(error).toBeInstanceOf(TerminalLinkPreviewOpenError);
101 expect(error).toMatchObject({
102 environmentId: "local",
103 threadId: "thread-1",
104 targetOrigin: "http://127.0.0.1:5173",
105 cause,
106 });
107 expect(error.message).not.toContain("preview unavailable");
108 });
109
110 it("does not report or fall back when opening the preview is interrupted", async () => {
111 const fallbackToBrowser = vi.fn();
112 const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined);
113
114 await openTerminalLinkInPreview({
115 url: "http://localhost:5173/",
116 position: { x: 12, y: 34 },
117 threadRef,
118 openPreview: async () => AsyncResult.failure(Cause.interrupt()),
119 localApi: {
120 contextMenu: {
121 show: vi.fn(async () => "open-in-preview"),
122 },
123 } as unknown as LocalApi,
124 fallbackToBrowser,
125 });
126
127 expect(reportError).not.toHaveBeenCalled();
128 expect(fallbackToBrowser).not.toHaveBeenCalled();
129 });
130});
apps/web/src/components/preview/openTerminalLinkInPreview.ts+49-1
......@@ -1,10 +1,37 @@
11import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts";
2import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
23import { isPreviewableUrl } from "@t3tools/shared/preview";
4import * as Schema from "effect/Schema";
35
46import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
57import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";
68import { useRightPanelStore } from "~/rightPanelStore";
79
10const terminalLinkErrorContext = {
11 environmentId: Schema.String,
12 threadId: Schema.String,
13 targetOrigin: Schema.String,
14 cause: Schema.Defect(),
15};
16
17export class TerminalLinkContextMenuShowError extends Schema.TaggedErrorClass<TerminalLinkContextMenuShowError>()(
18 "TerminalLinkContextMenuShowError",
19 terminalLinkErrorContext,
20) {
21 override get message(): string {
22 return `Failed to show the context menu for terminal link ${this.targetOrigin}.`;
23 }
24}
25
26export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass<TerminalLinkPreviewOpenError>()(
27 "TerminalLinkPreviewOpenError",
28 terminalLinkErrorContext,
29) {
30 override get message(): string {
31 return `Failed to open terminal link ${this.targetOrigin} in preview for thread ${this.threadId}.`;
32 }
33}
34
835interface OpenTerminalLinkInPreviewInput<E> {
936 readonly url: string;
1037 readonly position: { x: number; y: number };
......@@ -27,6 +54,12 @@ export async function openTerminalLinkInPreview<E>(
2754 return;
2855 }
2956
57 const errorContext = {
58 environmentId: input.threadRef.environmentId,
59 threadId: input.threadRef.threadId,
60 targetOrigin: new URL(input.url).origin,
61 };
62
3063 let choice: "open-in-preview" | "open-in-browser" | null;
3164 try {
3265 choice = await input.localApi.contextMenu.show(
......@@ -36,7 +69,13 @@ export async function openTerminalLinkInPreview<E>(
3669 ],
3770 input.position,
3871 );
39 } catch {
72 } catch (cause) {
73 console.error(
74 new TerminalLinkContextMenuShowError({
75 ...errorContext,
76 cause,
77 }),
78 );
4079 input.fallbackToBrowser();
4180 return;
4281 }
......@@ -47,6 +86,15 @@ export async function openTerminalLinkInPreview<E>(
4786 input: { threadId: input.threadRef.threadId, url: input.url },
4887 });
4988 if (result._tag === "Failure") {
89 if (isAtomCommandInterrupted(result)) {
90 return;
91 }
92 console.error(
93 new TerminalLinkPreviewOpenError({
94 ...errorContext,
95 cause: result.cause,
96 }),
97 );
5098 input.fallbackToBrowser();
5199 return;
52100 }