From 9d4abcbee17f27de47ad5309d188e5a3f96a35a3 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:56:29 +0000 Subject: [PATCH 1/9] Preview workspace image files Co-authored-by: Codex Co-authored-by: codex --- apps/web/src/browser/openFileInPreview.ts | 10 +- apps/web/src/components/ChatMarkdown.tsx | 4 +- apps/web/src/components/ChatView.tsx | 61 ++++++++++- apps/web/src/components/DiffPanel.tsx | 102 ++++++++++++++---- .../src/components/files/FilePreviewPanel.tsx | 18 ++-- 5 files changed, 156 insertions(+), 39 deletions(-) diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index b89b87c9289..b4bbefb1327 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -10,6 +10,11 @@ import { type AtomCommandResult, mapAtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; +import { + isWorkspaceBrowserPreviewPath, + isWorkspaceImagePreviewPath, + isWorkspacePreviewEntryPath, +} from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -22,8 +27,9 @@ import { } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; -export const isBrowserPreviewFile = (path: string): boolean => - /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); +export const isBrowserPreviewFile = isWorkspaceBrowserPreviewPath; +export const isImagePreviewFile = isWorkspaceImagePreviewPath; +export const isWorkspacePreviewFile = isWorkspacePreviewEntryPath; export class BrowserPreviewUnavailableError extends Data.TaggedError( "BrowserPreviewUnavailableError", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 711a545d90a..e315e4b83d9 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -76,7 +76,7 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { - isBrowserPreviewFile, + isWorkspacePreviewFile, openFileInPreview, openUrlInPreview, BrowserPreviewUnavailableError, @@ -1470,7 +1470,7 @@ function ChatMarkdown({ onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && - isBrowserPreviewFile(fileLinkMeta.filePath) + isWorkspacePreviewFile(fileLinkMeta.filePath) ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) : undefined } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cf5bb9de5e9..cac7b81a5df 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -110,6 +110,8 @@ import { type RightPanelSurface, useRightPanelStore, } from "../rightPanelStore"; +import { isImagePreviewFile, openFileInPreview } from "../browser/openFileInPreview"; +import { resolvePathLinkTarget } from "../terminal-links"; import { isPreviewSupportedInRuntime, setActivePreviewTab, @@ -179,7 +181,12 @@ import { import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; -import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; +import { + useEnvironmentHttpBaseUrl, + useEnvironments, + usePrimaryEnvironment, +} from "../state/environments"; +import { assetEnvironment } from "../state/assets"; import { useProject, useProjects, @@ -228,6 +235,7 @@ import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { Button } from "./ui/button"; import { buildVersionMismatchDismissalKey, @@ -1012,6 +1020,9 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + }); const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); @@ -1339,6 +1350,9 @@ function ChatViewContent(props: ChatViewProps) { ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; const activeProject = useProject(activeProjectRef); + const activeEnvironmentHttpBaseUrl = useEnvironmentHttpBaseUrl( + activeProject?.environmentId ?? null, + ); const activeEnvironmentShell = useEnvironmentQuery( activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, ); @@ -2740,9 +2754,50 @@ function ChatViewContent(props: ChatViewProps) { const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); + const openFallback = () => { + useRightPanelStore.getState().openFile(activeThreadRef, relativePath); + }; + if ( + !isImagePreviewFile(relativePath) || + !isPreviewSupportedInRuntime() || + !activeWorkspaceRoot || + !activeEnvironmentHttpBaseUrl + ) { + openFallback(); + return; + } + + const absolutePath = resolvePathLinkTarget(relativePath, activeWorkspaceRoot); + void (async () => { + const result = await openFileInPreview({ + threadRef: activeThreadRef, + filePath: absolutePath, + httpBaseUrl: activeEnvironmentHttpBaseUrl, + createAssetUrl, + openPreview, + }); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Unable to open image preview", + description: error instanceof Error ? error.message : "Opening the file instead.", + }), + ); + openFallback(); + })(); }, - [activeProject, activeThreadRef], + [ + activeEnvironmentHttpBaseUrl, + activeProject, + activeThreadRef, + activeWorkspaceRoot, + createAssetUrl, + openPreview, + ], ); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index f39af581d5a..341bb553a72 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -21,6 +21,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { type DraftId } from "../composerDraftStore"; import { openDiffFilePrimaryAction } from "../diffFileActions"; +import { isImagePreviewFile, openFileInPreview } from "../browser/openFileInPreview"; +import { resolvePathLinkTarget } from "../terminal-links"; +import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; import { cn } from "~/lib/utils"; import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; @@ -61,10 +64,16 @@ import { } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { useEnvironmentQuery } from "../state/query"; +import { assetEnvironment } from "../state/assets"; +import { previewEnvironment } from "../state/preview"; import { serverEnvironment } from "../state/server"; import { reviewEnvironment } from "../state/review"; import { vcsEnvironment } from "../state/vcs"; +import { useEnvironmentHttpBaseUrl } from "../state/environments"; +import { useAtomCommand } from "../state/use-atom-command"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; +import { stackedThreadToast, toastManager } from "./ui/toast"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; @@ -213,6 +222,11 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff : null, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot; + const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(activeThread?.environmentId ?? null); + const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + }); + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); const serverConfig = useAtomValue( serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), ); @@ -445,30 +459,72 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff const openDiffFile = useCallback( (filePath: string) => { - openDiffFilePrimaryAction({ - threadRef: routeThreadRef, - filePath, - activeCwd, - openInEditor: (targetPath) => { - void (async () => { - const result = await openInPreferredEditor(targetPath); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - console.warn("Failed to open diff file in editor.", { - operation: "open-diff-file", - ...(routeThreadRef - ? { - environmentId: routeThreadRef.environmentId, - threadId: routeThreadRef.threadId, - } - : {}), - ...safeErrorLogAttributes(squashAtomCommandFailure(result)), - }); - } - })(); - }, - }); + const openFallback = () => { + openDiffFilePrimaryAction({ + threadRef: routeThreadRef, + filePath, + activeCwd, + openInEditor: (targetPath) => { + void (async () => { + const result = await openInPreferredEditor(targetPath); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + console.warn("Failed to open diff file in editor.", { + operation: "open-diff-file", + ...(routeThreadRef + ? { + environmentId: routeThreadRef.environmentId, + threadId: routeThreadRef.threadId, + } + : {}), + ...safeErrorLogAttributes(squashAtomCommandFailure(result)), + }); + } + })(); + }, + }); + }; + + if ( + !routeThreadRef || + !activeCwd || + !environmentHttpBaseUrl || + !isPreviewSupportedInRuntime() || + !isImagePreviewFile(filePath) + ) { + openFallback(); + return; + } + + void (async () => { + const result = await openFileInPreview({ + threadRef: routeThreadRef, + filePath: resolvePathLinkTarget(filePath, activeCwd), + httpBaseUrl: environmentHttpBaseUrl, + createAssetUrl, + openPreview, + }); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Unable to open image preview", + description: error instanceof Error ? error.message : "Opening the file instead.", + }), + ); + openFallback(); + })(); }, - [activeCwd, openInPreferredEditor, routeThreadRef], + [ + activeCwd, + createAssetUrl, + environmentHttpBaseUrl, + openInPreferredEditor, + openPreview, + routeThreadRef, + ], ); const toggleDiffFileCollapsed = useCallback( (fileKey: string) => { diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 89176cd4525..80b81ee376d 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -15,7 +15,7 @@ import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "luci import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; +import { isWorkspacePreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { useTheme } from "~/hooks/useTheme"; @@ -633,8 +633,8 @@ export default function FilePreviewPanel({ isMarkdown && markdownView.path === relativePath && (revealLine === null || markdownView.revealRequestId === revealRequestId); - const canOpenInBrowser = - relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); + const canOpenInPreview = + relativePath !== null && isPreviewSupportedInRuntime() && isWorkspacePreviewFile(relativePath); const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; const breadcrumbs = useMemo( () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), @@ -661,7 +661,7 @@ export default function FilePreviewPanel({ }); }; - const handleOpenInBrowser = useCallback(() => { + const handleOpenInPreview = useCallback(() => { if (!absolutePath || !environmentHttpBaseUrl) return; void (async () => { const result = await openFileInPreview({ @@ -678,7 +678,7 @@ export default function FilePreviewPanel({ toastManager.add( stackedThreadToast({ type: "error", - title: "Unable to open file in browser", + title: "Unable to open file in preview", description: error instanceof Error ? error.message : "An error occurred.", }), ); @@ -757,15 +757,15 @@ export default function FilePreviewPanel({ ) : null} - {canOpenInBrowser ? ( + {canOpenInPreview ? ( @@ -773,7 +773,7 @@ export default function FilePreviewPanel({ } /> - Open file in preview browser + Open file in preview ) : null} From 0a79553bf637abaf3fa7e85ddc4d43e3bd7673f2 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:12:14 +0000 Subject: [PATCH 2/9] Add markdown preview failure fallback Co-authored-by: Codex Co-authored-by: codex --- apps/web/src/components/ChatMarkdown.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e315e4b83d9..712f473579b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1065,6 +1065,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ description: error instanceof Error ? error.message : "An error occurred.", }), ); + handleOpenInFilePreview(); } catch (cause) { reportMarkdownActionFailure( { operation: "open-file-in-browser", target: targetPath }, @@ -1077,9 +1078,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ description: cause instanceof Error ? cause.message : "An error occurred.", }), ); + handleOpenInFilePreview(); } })(); - }, [onOpenInBrowser, targetPath]); + }, [handleOpenInFilePreview, onOpenInBrowser, targetPath]); const handleCopy = useCallback( (value: string, title: string) => { From 64464dc09ea8728cb0a8eaa14774365701d9ff7f Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:17:49 +0000 Subject: [PATCH 3/9] Clarify markdown preview fallback toast Co-authored-by: Codex Co-authored-by: codex --- apps/web/src/components/ChatMarkdown.tsx | 19 +++++++++++++------ apps/web/src/components/ChatView.tsx | 6 +++++- apps/web/src/components/DiffPanel.tsx | 6 +++++- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 712f473579b..014fe17aad3 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1060,9 +1060,12 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: error instanceof Error ? error.message : "An error occurred.", + type: "warning", + title: "Unable to open file preview", + description: + error instanceof Error + ? `${error.message} Opening the file instead.` + : "Opening the file instead.", }), ); handleOpenInFilePreview(); @@ -1073,9 +1076,12 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); toastManager.add( stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: cause instanceof Error ? cause.message : "An error occurred.", + type: "warning", + title: "Unable to open file preview", + description: + cause instanceof Error + ? `${cause.message} Opening the file instead.` + : "Opening the file instead.", }), ); handleOpenInFilePreview(); @@ -1470,6 +1476,7 @@ function ChatMarkdown({ threadRef={threadRef} onOpen={openInPreferredEditor} onOpenInBrowser={ + fileLinkMeta.workspaceRelativePath !== null && threadRef && isPreviewSupportedInRuntime() && isWorkspacePreviewFile(fileLinkMeta.filePath) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cac7b81a5df..126a76be1fe 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2776,7 +2776,11 @@ function ChatViewContent(props: ChatViewProps) { createAssetUrl, openPreview, }); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + if (result._tag === "Success") { + return; + } + if (isAtomCommandInterrupted(result)) { + openFallback(); return; } const error = squashAtomCommandFailure(result); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 341bb553a72..bd559e2aa7b 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -503,7 +503,11 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff createAssetUrl, openPreview, }); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + if (result._tag === "Success") { + return; + } + if (isAtomCommandInterrupted(result)) { + openFallback(); return; } const error = squashAtomCommandFailure(result); From 3e14a9a343dcfc2b2e36fca36ecc11ecf6d4666a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:44:35 -0700 Subject: [PATCH 4/9] Retrigger service conventions check Co-authored-by: codex From 93c06abcd0197936903533d117d2c8c403759b3c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:32:05 -0700 Subject: [PATCH 5/9] Ignore superseded image preview requests Co-authored-by: codex --- .../web/src/browser/openFileInPreview.test.ts | 62 +++++++++++++++++++ apps/web/src/browser/openFileInPreview.ts | 7 +++ apps/web/src/components/ChatView.tsx | 14 +++++ apps/web/src/components/DiffPanel.tsx | 15 +++++ 4 files changed, 98 insertions(+) create mode 100644 apps/web/src/browser/openFileInPreview.test.ts diff --git a/apps/web/src/browser/openFileInPreview.test.ts b/apps/web/src/browser/openFileInPreview.test.ts new file mode 100644 index 00000000000..ec86cff252a --- /dev/null +++ b/apps/web/src/browser/openFileInPreview.test.ts @@ -0,0 +1,62 @@ +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, expect, it } from "vite-plus/test"; + +import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; + +import { type OpenPreviewMutation, openUrlInPreview } from "./openFileInPreview"; + +const threadRef = { + environmentId: "local" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], +}; + +const snapshot = (tabId: string, url: string): PreviewSessionSnapshot => ({ + threadId: threadRef.threadId, + tabId, + navStatus: { _tag: "Success", url, title: "" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-06-21T00:00:00.000Z", +}); + +beforeEach(() => { + resetPreviewStateForTests(); + useRightPanelStore.setState({ byThreadKey: {} }); +}); + +it("does not apply an older preview response after a newer request", async () => { + const firstController = new AbortController(); + const firstSnapshot = snapshot("tab-1", "https://assets.test/first.png"); + const secondSnapshot = snapshot("tab-2", "https://assets.test/second.png"); + let resolveFirst!: (result: AtomCommandResult) => void; + const openPreview: OpenPreviewMutation = ({ input }) => + input.url === "https://assets.test/first.png" + ? new Promise>((resolve) => { + resolveFirst = resolve; + }) + : Promise.resolve(AsyncResult.success(secondSnapshot)); + + const firstRequest = openUrlInPreview({ + threadRef, + url: "https://assets.test/first.png", + openPreview, + signal: firstController.signal, + }); + firstController.abort(); + + await openUrlInPreview({ + threadRef, + url: "https://assets.test/second.png", + openPreview, + }); + resolveFirst(AsyncResult.success(firstSnapshot)); + await firstRequest; + + expect(readThreadPreviewState(threadRef).snapshot).toEqual(secondSnapshot); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).surfaces, + ).toEqual([{ id: "browser:tab-2", kind: "preview", resourceId: "tab-2" }]); +}); diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index b4bbefb1327..d07be0f5281 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -46,12 +46,14 @@ export async function openUrlInPreview(input: { readonly threadRef: ScopedThreadRef; readonly url: string; readonly openPreview: OpenPreviewMutation; + readonly signal?: AbortSignal; }): Promise> { const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { threadId: input.threadRef.threadId, url: input.url }, }); return mapAtomCommandResult(result, (snapshot) => { + if (input.signal?.aborted) return; applyPreviewServerSnapshot(input.threadRef, snapshot); rememberPreviewUrl(input.threadRef, input.url); useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); @@ -67,6 +69,7 @@ export async function openFileInPreview(input: { readonly input: { readonly resource: AssetResource }; }) => Promise>; readonly openPreview: OpenPreviewMutation; + readonly signal?: AbortSignal; }): Promise> { if (!isPreviewSupportedInRuntime()) { return AsyncResult.failure( @@ -87,6 +90,9 @@ export async function openFileInPreview(input: { }, }, }); + if (input.signal?.aborted) { + return AsyncResult.success(undefined); + } if (assetResult._tag === "Failure") { return AsyncResult.failure(assetResult.cause); } @@ -100,5 +106,6 @@ export async function openFileInPreview(input: { threadRef: input.threadRef, url: assetUrl, openPreview: input.openPreview, + ...(input.signal ? { signal: input.signal } : {}), }); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 126a76be1fe..1c2cbe200df 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1023,6 +1023,7 @@ function ChatViewContent(props: ChatViewProps) { const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); + const filePreviewAbortControllerRef = useRef(null); const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); @@ -2751,9 +2752,16 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); }, [activeProject, activeThreadRef]); + useEffect( + () => () => { + filePreviewAbortControllerRef.current?.abort(); + }, + [activeThreadKey], + ); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; + filePreviewAbortControllerRef.current?.abort(); const openFallback = () => { useRightPanelStore.getState().openFile(activeThreadRef, relativePath); }; @@ -2767,6 +2775,8 @@ function ChatViewContent(props: ChatViewProps) { return; } + const abortController = new AbortController(); + filePreviewAbortControllerRef.current = abortController; const absolutePath = resolvePathLinkTarget(relativePath, activeWorkspaceRoot); void (async () => { const result = await openFileInPreview({ @@ -2775,7 +2785,11 @@ function ChatViewContent(props: ChatViewProps) { httpBaseUrl: activeEnvironmentHttpBaseUrl, createAssetUrl, openPreview, + signal: abortController.signal, }); + if (abortController.signal.aborted) { + return; + } if (result._tag === "Success") { return; } diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index bd559e2aa7b..d38aec9a61c 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -227,6 +227,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff reportFailure: false, }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + const filePreviewAbortControllerRef = useRef(null); const serverConfig = useAtomValue( serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), ); @@ -457,8 +458,16 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff codeViewRef.current?.scrollTo({ type: "item", id: file.fileKey, align: "start" }); }, [codeViewFiles, selectedFilePath, selectedFileRevealRequestId]); + useEffect( + () => () => { + filePreviewAbortControllerRef.current?.abort(); + }, + [routeThreadRef?.environmentId, routeThreadRef?.threadId], + ); + const openDiffFile = useCallback( (filePath: string) => { + filePreviewAbortControllerRef.current?.abort(); const openFallback = () => { openDiffFilePrimaryAction({ threadRef: routeThreadRef, @@ -495,6 +504,8 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff return; } + const abortController = new AbortController(); + filePreviewAbortControllerRef.current = abortController; void (async () => { const result = await openFileInPreview({ threadRef: routeThreadRef, @@ -502,7 +513,11 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff httpBaseUrl: environmentHttpBaseUrl, createAssetUrl, openPreview, + signal: abortController.signal, }); + if (abortController.signal.aborted) { + return; + } if (result._tag === "Success") { return; } From 78581ed86baa6d26150bd5d9410a6010d292df7a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:46:09 -0700 Subject: [PATCH 6/9] Share preview request ordering across surfaces Co-authored-by: codex --- .../web/src/browser/openFileInPreview.test.ts | 5 +- apps/web/src/browser/openFileInPreview.ts | 133 ++++++++++++------ 2 files changed, 93 insertions(+), 45 deletions(-) diff --git a/apps/web/src/browser/openFileInPreview.test.ts b/apps/web/src/browser/openFileInPreview.test.ts index ec86cff252a..b58d0553eff 100644 --- a/apps/web/src/browser/openFileInPreview.test.ts +++ b/apps/web/src/browser/openFileInPreview.test.ts @@ -27,8 +27,7 @@ beforeEach(() => { useRightPanelStore.setState({ byThreadKey: {} }); }); -it("does not apply an older preview response after a newer request", async () => { - const firstController = new AbortController(); +it("does not apply an older preview response after another caller starts a newer request", async () => { const firstSnapshot = snapshot("tab-1", "https://assets.test/first.png"); const secondSnapshot = snapshot("tab-2", "https://assets.test/second.png"); let resolveFirst!: (result: AtomCommandResult) => void; @@ -43,9 +42,7 @@ it("does not apply an older preview response after a newer request", async () => threadRef, url: "https://assets.test/first.png", openPreview, - signal: firstController.signal, }); - firstController.abort(); await openUrlInPreview({ threadRef, diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index d07be0f5281..1ac82cf796e 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -6,6 +6,7 @@ import type { PreviewSessionSnapshot, ScopedThreadRef, } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { type AtomCommandResult, mapAtomCommandResult, @@ -42,22 +43,65 @@ export type OpenPreviewMutation = (input: { readonly input: PreviewOpenInput; }) => Promise>; -export async function openUrlInPreview(input: { - readonly threadRef: ScopedThreadRef; - readonly url: string; - readonly openPreview: OpenPreviewMutation; - readonly signal?: AbortSignal; -}): Promise> { +interface PreviewRequest { + readonly isCurrent: () => boolean; + readonly complete: () => void; +} + +const activePreviewRequestByThread = new Map(); + +const beginPreviewRequest = ( + threadRef: ScopedThreadRef, + signal: AbortSignal | undefined, +): PreviewRequest => { + const threadKey = scopedThreadKey(threadRef); + const requestId = Symbol(); + activePreviewRequestByThread.set(threadKey, requestId); + return { + isCurrent: () => + activePreviewRequestByThread.get(threadKey) === requestId && signal?.aborted !== true, + complete: () => { + if (activePreviewRequestByThread.get(threadKey) === requestId) { + activePreviewRequestByThread.delete(threadKey); + } + }, + }; +}; + +const openUrlForPreviewRequest = async ( + input: { + readonly threadRef: ScopedThreadRef; + readonly url: string; + readonly openPreview: OpenPreviewMutation; + }, + request: PreviewRequest, +): Promise> => { const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { threadId: input.threadRef.threadId, url: input.url }, }); + if (!request.isCurrent()) { + return AsyncResult.success(undefined); + } return mapAtomCommandResult(result, (snapshot) => { - if (input.signal?.aborted) return; applyPreviewServerSnapshot(input.threadRef, snapshot); rememberPreviewUrl(input.threadRef, input.url); useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); }); +}; + +export async function openUrlInPreview(input: { + readonly threadRef: ScopedThreadRef; + readonly url: string; + readonly openPreview: OpenPreviewMutation; + readonly signal?: AbortSignal; +}): Promise> { + const request = beginPreviewRequest(input.threadRef, input.signal); + try { + return await openUrlForPreviewRequest(input, request); + } finally { + request.complete(); + } } export async function openFileInPreview(input: { @@ -71,41 +115,48 @@ export async function openFileInPreview(input: { readonly openPreview: OpenPreviewMutation; readonly signal?: AbortSignal; }): Promise> { - if (!isPreviewSupportedInRuntime()) { - return AsyncResult.failure( - Cause.fail( - new BrowserPreviewUnavailableError({ - message: "The integrated browser is unavailable in this runtime.", - }), - ), - ); - } - const assetResult = await input.createAssetUrl({ - environmentId: input.threadRef.environmentId, - input: { - resource: { - _tag: "workspace-file", - threadId: input.threadRef.threadId, - path: input.filePath, + const request = beginPreviewRequest(input.threadRef, input.signal); + try { + if (!isPreviewSupportedInRuntime()) { + return AsyncResult.failure( + Cause.fail( + new BrowserPreviewUnavailableError({ + message: "The integrated browser is unavailable in this runtime.", + }), + ), + ); + } + const assetResult = await input.createAssetUrl({ + environmentId: input.threadRef.environmentId, + input: { + resource: { + _tag: "workspace-file", + threadId: input.threadRef.threadId, + path: input.filePath, + }, }, - }, - }); - if (input.signal?.aborted) { - return AsyncResult.success(undefined); - } - if (assetResult._tag === "Failure") { - return AsyncResult.failure(assetResult.cause); - } - const assetUrl = resolveAssetUrl(input.httpBaseUrl, assetResult.value.relativeUrl); - if (assetUrl === null) { - return AsyncResult.failure( - Cause.die(new Error("The environment returned an invalid asset URL.")), + }); + if (!request.isCurrent()) { + return AsyncResult.success(undefined); + } + if (assetResult._tag === "Failure") { + return AsyncResult.failure(assetResult.cause); + } + const assetUrl = resolveAssetUrl(input.httpBaseUrl, assetResult.value.relativeUrl); + if (assetUrl === null) { + return AsyncResult.failure( + Cause.die(new Error("The environment returned an invalid asset URL.")), + ); + } + return await openUrlForPreviewRequest( + { + threadRef: input.threadRef, + url: assetUrl, + openPreview: input.openPreview, + }, + request, ); + } finally { + request.complete(); } - return openUrlInPreview({ - threadRef: input.threadRef, - url: assetUrl, - openPreview: input.openPreview, - ...(input.signal ? { signal: input.signal } : {}), - }); } From 49a9c25a67fe76c8c3a49d678ab6e2aea5cb48ae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:42:58 -0700 Subject: [PATCH 7/9] fix(web): structure preview asset URL failures Co-authored-by: codex --- .../web/src/browser/openFileInPreview.test.ts | 64 +++++++++++++- apps/web/src/browser/openFileInPreview.ts | 87 +++++++++++++++---- apps/web/src/components/ChatMarkdown.tsx | 27 +++--- 3 files changed, 147 insertions(+), 31 deletions(-) diff --git a/apps/web/src/browser/openFileInPreview.test.ts b/apps/web/src/browser/openFileInPreview.test.ts index b58d0553eff..dc11eced32e 100644 --- a/apps/web/src/browser/openFileInPreview.test.ts +++ b/apps/web/src/browser/openFileInPreview.test.ts @@ -1,15 +1,22 @@ import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { beforeEach, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; -import { type OpenPreviewMutation, openUrlInPreview } from "./openFileInPreview"; +import { + BrowserPreviewUnavailableError, + isBrowserPreviewAssetUrlInvalidError, + type OpenPreviewMutation, + openFileInPreview, + openUrlInPreview, +} from "./openFileInPreview"; const threadRef = { - environmentId: "local" as ScopedThreadRef["environmentId"], + environmentId: "environment-1" as ScopedThreadRef["environmentId"], threadId: "thread-1" as ScopedThreadRef["threadId"], }; @@ -27,6 +34,57 @@ beforeEach(() => { useRightPanelStore.setState({ byThreadKey: {} }); }); +afterEach(() => vi.unstubAllGlobals()); + +describe("openFileInPreview", () => { + it("uses the fixed unavailable-runtime message", () => { + expect(new BrowserPreviewUnavailableError().message).toBe( + "The integrated browser is unavailable in this runtime.", + ); + }); + + it("reports invalid asset URLs with safe context and the exact parser cause", async () => { + vi.stubGlobal("window", { desktopBridge: { preview: {} } }); + const parserCause = new TypeError("invalid URL"); + const InvalidUrl = vi.fn(function InvalidUrl() { + throw parserCause; + }); + vi.stubGlobal("URL", InvalidUrl); + const openPreview = vi.fn(); + const httpBaseUrl = "not a URL"; + const relativeUrl = "/api/assets/signed-secret-token/docs/report.pdf"; + const expiresAt = Date.now(); + + const result = await openFileInPreview({ + threadRef, + filePath: "docs/report.pdf", + httpBaseUrl, + createAssetUrl: async () => AsyncResult.success({ relativeUrl, expiresAt }), + openPreview, + }); + const error = result._tag === "Failure" ? Cause.squash(result.cause) : undefined; + + expect(isBrowserPreviewAssetUrlInvalidError(error)).toBe(true); + if (!isBrowserPreviewAssetUrlInvalidError(error)) { + throw new Error("Expected BrowserPreviewAssetUrlInvalidError"); + } + expect(error).toMatchObject({ + environmentId: "environment-1", + threadId: "thread-1", + filePath: "docs/report.pdf", + httpBaseUrlLength: httpBaseUrl.length, + relativeUrlLength: relativeUrl.length, + expiresAt, + }); + expect(error.cause).toBe(parserCause); + expect(error.message).toBe("The environment returned an invalid asset URL."); + expect(error).not.toHaveProperty("httpBaseUrl"); + expect(error).not.toHaveProperty("relativeUrl"); + expect(JSON.stringify(error)).not.toContain("signed-secret-token"); + expect(openPreview).not.toHaveBeenCalled(); + }); +}); + it("does not apply an older preview response after another caller starts a newer request", async () => { const firstSnapshot = snapshot("tab-1", "https://assets.test/first.png"); const secondSnapshot = snapshot("tab-2", "https://assets.test/second.png"); diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 1ac82cf796e..4888a8843aa 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -17,10 +17,9 @@ import { isWorkspacePreviewEntryPath, } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; -import { resolveAssetUrl } from "~/assets/assetUrls"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime, @@ -32,11 +31,54 @@ export const isBrowserPreviewFile = isWorkspaceBrowserPreviewPath; export const isImagePreviewFile = isWorkspaceImagePreviewPath; export const isWorkspacePreviewFile = isWorkspacePreviewEntryPath; -export class BrowserPreviewUnavailableError extends Data.TaggedError( +export class BrowserPreviewUnavailableError extends Schema.TaggedErrorClass()( "BrowserPreviewUnavailableError", -)<{ - readonly message: string; -}> {} + {}, +) { + override get message(): string { + return "The integrated browser is unavailable in this runtime."; + } +} + +export class BrowserPreviewThreadContextUnavailableError extends Schema.TaggedErrorClass()( + "BrowserPreviewThreadContextUnavailableError", + {}, +) { + override get message(): string { + return "Thread context is unavailable."; + } +} + +export class BrowserPreviewEnvironmentDisconnectedError extends Schema.TaggedErrorClass()( + "BrowserPreviewEnvironmentDisconnectedError", + { + environmentId: Schema.String, + threadId: Schema.String, + }, +) { + override get message(): string { + return "Environment is not connected."; + } +} + +export class BrowserPreviewAssetUrlInvalidError extends Schema.TaggedErrorClass()( + "BrowserPreviewAssetUrlInvalidError", + { + environmentId: Schema.String, + threadId: Schema.String, + filePath: Schema.String, + httpBaseUrlLength: Schema.Int, + relativeUrlLength: Schema.Int, + expiresAt: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "The environment returned an invalid asset URL."; + } +} + +export const isBrowserPreviewAssetUrlInvalidError = Schema.is(BrowserPreviewAssetUrlInvalidError); export type OpenPreviewMutation = (input: { readonly environmentId: EnvironmentId; @@ -114,17 +156,16 @@ export async function openFileInPreview(input: { }) => Promise>; readonly openPreview: OpenPreviewMutation; readonly signal?: AbortSignal; -}): Promise> { +}): Promise< + AtomCommandResult< + void, + AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserPreviewAssetUrlInvalidError + > +> { const request = beginPreviewRequest(input.threadRef, input.signal); try { if (!isPreviewSupportedInRuntime()) { - return AsyncResult.failure( - Cause.fail( - new BrowserPreviewUnavailableError({ - message: "The integrated browser is unavailable in this runtime.", - }), - ), - ); + return AsyncResult.failure(Cause.fail(new BrowserPreviewUnavailableError())); } const assetResult = await input.createAssetUrl({ environmentId: input.threadRef.environmentId, @@ -142,10 +183,22 @@ export async function openFileInPreview(input: { if (assetResult._tag === "Failure") { return AsyncResult.failure(assetResult.cause); } - const assetUrl = resolveAssetUrl(input.httpBaseUrl, assetResult.value.relativeUrl); - if (assetUrl === null) { + let assetUrl: string; + try { + assetUrl = new URL(assetResult.value.relativeUrl, input.httpBaseUrl).toString(); + } catch (cause) { return AsyncResult.failure( - Cause.die(new Error("The environment returned an invalid asset URL.")), + Cause.fail( + new BrowserPreviewAssetUrlInvalidError({ + environmentId: input.threadRef.environmentId, + threadId: input.threadRef.threadId, + filePath: input.filePath, + httpBaseUrlLength: input.httpBaseUrl.length, + relativeUrlLength: assetResult.value.relativeUrl.length, + expiresAt: assetResult.value.expiresAt, + cause, + }), + ), ); } return await openUrlForPreviewRequest( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 014fe17aad3..f5ac502254c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -79,7 +79,8 @@ import { isWorkspacePreviewFile, openFileInPreview, openUrlInPreview, - BrowserPreviewUnavailableError, + BrowserPreviewEnvironmentDisconnectedError, + BrowserPreviewThreadContextUnavailableError, } from "../browser/openFileInPreview"; class CodeHighlightErrorBoundary extends React.Component< @@ -1292,12 +1293,8 @@ function ChatMarkdown({ (url: string) => { if (!threadRef) { return Promise.resolve( - AsyncResult.failure( - Cause.fail( - new BrowserPreviewUnavailableError({ - message: "Thread context is unavailable.", - }), - ), + AsyncResult.failure( + Cause.fail(new BrowserPreviewThreadContextUnavailableError()), ), ); } @@ -1307,12 +1304,20 @@ function ChatMarkdown({ ); const openMarkdownFileInPreview = useCallback( (path: string) => { - if (!threadRef || preparedConnection._tag === "None") { + if (!threadRef) { + return Promise.resolve( + AsyncResult.failure( + Cause.fail(new BrowserPreviewThreadContextUnavailableError()), + ), + ); + } + if (preparedConnection._tag === "None") { return Promise.resolve( - AsyncResult.failure( + AsyncResult.failure( Cause.fail( - new BrowserPreviewUnavailableError({ - message: "Environment is not connected.", + new BrowserPreviewEnvironmentDisconnectedError({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, }), ), ), From 881936d6840b8a493bed917ee3f72444178c99b2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:34:36 -0700 Subject: [PATCH 8/9] Structure browser preview availability errors Co-authored-by: codex --- .../web/src/browser/openFileInPreview.test.ts | 23 ++++++++++++++++--- apps/web/src/browser/openFileInPreview.ts | 14 +++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/apps/web/src/browser/openFileInPreview.test.ts b/apps/web/src/browser/openFileInPreview.test.ts index dc11eced32e..ce80a42cc64 100644 --- a/apps/web/src/browser/openFileInPreview.test.ts +++ b/apps/web/src/browser/openFileInPreview.test.ts @@ -37,10 +37,27 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()); describe("openFileInPreview", () => { - it("uses the fixed unavailable-runtime message", () => { - expect(new BrowserPreviewUnavailableError().message).toBe( - "The integrated browser is unavailable in this runtime.", + it("reports an unavailable runtime with thread context", async () => { + vi.stubGlobal("window", {}); + + const result = await openFileInPreview({ + threadRef, + filePath: "docs/report.pdf", + httpBaseUrl: "https://environment.test", + createAssetUrl: vi.fn(), + openPreview: vi.fn(), + }); + const error = result._tag === "Failure" ? Cause.squash(result.cause) : undefined; + + expect(error).toEqual( + new BrowserPreviewUnavailableError({ + environmentId: "environment-1", + threadId: "thread-1", + }), ); + expect(error).toMatchObject({ + message: "The integrated browser is unavailable in this runtime.", + }); }); it("reports invalid asset URLs with safe context and the exact parser cause", async () => { diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 4888a8843aa..31cf78f3c17 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -33,7 +33,10 @@ export const isWorkspacePreviewFile = isWorkspacePreviewEntryPath; export class BrowserPreviewUnavailableError extends Schema.TaggedErrorClass()( "BrowserPreviewUnavailableError", - {}, + { + environmentId: Schema.String, + threadId: Schema.String, + }, ) { override get message(): string { return "The integrated browser is unavailable in this runtime."; @@ -165,7 +168,14 @@ export async function openFileInPreview(input: { const request = beginPreviewRequest(input.threadRef, input.signal); try { if (!isPreviewSupportedInRuntime()) { - return AsyncResult.failure(Cause.fail(new BrowserPreviewUnavailableError())); + return AsyncResult.failure( + Cause.fail( + new BrowserPreviewUnavailableError({ + environmentId: input.threadRef.environmentId, + threadId: input.threadRef.threadId, + }), + ), + ); } const assetResult = await input.createAssetUrl({ environmentId: input.threadRef.environmentId, From 00787484b6c4729fe8f18f6b4c73985e38f616ea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:45:27 -0700 Subject: [PATCH 9/9] fix(web): preserve markdown preview fallback Co-authored-by: codex --- apps/web/src/components/ChatMarkdown.tsx | 39 +++++++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index f5ac502254c..80e95308b2a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -719,7 +719,9 @@ interface MarkdownFileLinkProps { theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen: (targetPath: string) => Promise>; - onOpenInBrowser?: (() => Promise>) | undefined; + onOpenInBrowser?: + | ((signal: AbortSignal) => Promise>) + | undefined; className?: string | undefined; } @@ -1001,6 +1003,15 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onOpenInBrowser, className, }: MarkdownFileLinkProps) { + const browserPreviewAbortControllerRef = useRef(null); + + useEffect( + () => () => { + browserPreviewAbortControllerRef.current?.abort(); + }, + [], + ); + const handleOpenInEditor = useCallback(() => { void (async () => { try { @@ -1048,10 +1059,20 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ if (!onOpenInBrowser) { return; } + browserPreviewAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + browserPreviewAbortControllerRef.current = abortController; void (async () => { try { - const result = await onOpenInBrowser(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + const result = await onOpenInBrowser(abortController.signal); + if (abortController.signal.aborted) { + return; + } + if (result._tag === "Success") { + return; + } + if (isAtomCommandInterrupted(result)) { + handleOpenInFilePreview(); return; } reportMarkdownActionFailure( @@ -1071,6 +1092,9 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); handleOpenInFilePreview(); } catch (cause) { + if (abortController.signal.aborted) { + return; + } reportMarkdownActionFailure( { operation: "open-file-in-browser", target: targetPath }, cause, @@ -1086,6 +1110,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }), ); handleOpenInFilePreview(); + } finally { + if (browserPreviewAbortControllerRef.current === abortController) { + browserPreviewAbortControllerRef.current = null; + } } })(); }, [handleOpenInFilePreview, onOpenInBrowser, targetPath]); @@ -1303,7 +1331,7 @@ function ChatMarkdown({ [openPreview, threadRef], ); const openMarkdownFileInPreview = useCallback( - (path: string) => { + (path: string, signal: AbortSignal) => { if (!threadRef) { return Promise.resolve( AsyncResult.failure( @@ -1329,6 +1357,7 @@ function ChatMarkdown({ httpBaseUrl: preparedConnection.value.httpBaseUrl, createAssetUrl, openPreview, + signal, }); }, [createAssetUrl, openPreview, preparedConnection, threadRef], @@ -1485,7 +1514,7 @@ function ChatMarkdown({ threadRef && isPreviewSupportedInRuntime() && isWorkspacePreviewFile(fileLinkMeta.filePath) - ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) + ? (signal) => openMarkdownFileInPreview(fileLinkMeta.filePath, signal) : undefined } className={props.className}