diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 1c77e91f7ace..38a282279f8f 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { - type AssetUrlState, + type AssetUrlState as RuntimeAssetUrlState, assetUrlStateFromResult, blockedAssetUrlState, EMPTY_ASSET_URL_ATOM, @@ -15,7 +15,9 @@ import { useFilesystemReadAccess } from "~/state/filesystem"; import { usePreparedConnection } from "~/state/session"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; -export { resolveAssetUrl, type AssetUrlState } from "@t3tools/client-runtime/state/assets"; +export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; + +export type AssetUrlState = RuntimeAssetUrlState & { readonly waiting?: boolean }; export function useAssetUrlState( environmentId: EnvironmentId | null, @@ -29,10 +31,13 @@ export function useAssetUrlState( : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); if (blocked !== null) return blocked; - return assetUrlStateFromResult( - result, - preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, - ); + return { + ...assetUrlStateFromResult( + result, + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, + ), + ...(result.waiting ? { waiting: true } : {}), + }; } export function useAssetUrlRefresh( diff --git a/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx b/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx index 6e6d5fc368f4..ef33e24e2100 100644 --- a/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx @@ -1,10 +1,12 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; +import { RegistryContext } from "@effect/atom-react"; +import { type AssetCreateUrlResult, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { AtomRegistry } from "effect/reactivity"; +import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; -const mint = vi.hoisted(() => vi.fn()); +const mint = vi.hoisted(() => vi.fn<() => Promise>()); // Atoms derived from the server config run in the real registry over a missing config. vi.mock(import("../state/server"), async (original) => { const actual = await original(); @@ -15,47 +17,89 @@ vi.mock(import("../state/server"), async (original) => { serverEnvironment: { ...actual.serverEnvironment, configValueAtom: (() => noConfig) as never }, }; }); -vi.mock(import("../state/use-atom-query-runner"), async (original) => ({ +// Keep the real query runner and registry: refreshes during waiting requests are skipped. +vi.mock(import("../state/assets"), async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + const family = Atom.family((_key: string) => + Atom.make(Effect.tryPromise(() => mint())).pipe(Atom.keepAlive), + ); + return { + ...actual, + assetEnvironment: { + ...actual.assetEnvironment, + createUrl: (target) => family(JSON.stringify(target)), + }, + }; +}); +vi.mock(import("../state/filesystem"), async (original) => ({ ...(await original()), - useAtomQueryRunner: (() => mint) as never, + useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), })); vi.mock(import("../state/session"), async (original) => ({ ...(await original()), readEnvironmentScope: () => false, useEnvironmentScope: () => false, readPreparedConnection: (() => ({ httpBaseUrl: "https://host.test" })) as never, + usePreparedConnection: (() => ({ + _tag: "Some", + value: { httpBaseUrl: "https://host.test" }, + })) as never, })); import ChatMarkdown from "./ChatMarkdown"; const roots: Root[] = []; -beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true)); +let registry: AtomRegistry.AtomRegistry; + +const imageUrl = (version: string) => + `data:image/svg+xml,#${version}`; +const assetResult = (version: string): AssetCreateUrlResult => ({ + relativeUrl: imageUrl(version), + expiresAt: 1, +}); + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + registry = AtomRegistry.make(); + mint.mockReset().mockResolvedValue(assetResult("updated")); +}); afterEach(async () => { for (const root of roots.splice(0)) await act(async () => root.unmount()); + registry.dispose(); document.body.replaceChildren(); vi.unstubAllGlobals(); }); -it("opens host media through server authorization before the client grant loads", async () => { - mint.mockResolvedValue( - AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }), - ); - const onImageExpand = vi.fn(); +function createTestRoot() { const container = document.body.appendChild(document.createElement("div")); const root = createRoot(container); roots.push(root); - await act(async () => - root.render( - , - ), + return { + container, + render: (children: ReactNode) => + act(async () => { + root.render( + {children}, + ); + }), + }; +} + +it("opens host media through server authorization before the client grant loads", async () => { + mint.mockResolvedValue({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }); + const onImageExpand = vi.fn(); + const { container, render } = createTestRoot(); + await render( + , ); const link = container.querySelector('a[href="/tmp/image.png"]'); expect(link).not.toBeNull(); @@ -68,3 +112,88 @@ it("opens host media through server authorization before the client grant loads" ), ); }); + +const threadRef = { + environmentId: EnvironmentId.make("media-environment"), + threadId: ThreadId.make("media-thread"), +}; + +function markdown(workspaceMutationId?: string | null) { + return ( + + ); +} + +function renderedImage(container: HTMLElement) { + return container.querySelector('img[alt="Diagram"]')?.getAttribute("src"); +} + +it("ordinary chat keeps its embedded asset URL across renders and reopening", async () => { + const { container, render } = createTestRoot(); + await render(markdown()); + expect(renderedImage(container)).toBe(imageUrl("updated")); + mint.mockResolvedValue(assetResult("changed")); + + await render(markdown()); + await render(null); + await render(markdown()); + + expect(renderedImage(container)).toBe(imageUrl("updated")); + expect(mint).toHaveBeenCalledTimes(1); +}); + +it.each(["URL", "failure"] as const)( + "reopening a file preview with no workspace mutation replaces its cached %s", + async (cached) => { + if (cached === "failure") mint.mockRejectedValueOnce(new Error("Image missing")); + else mint.mockResolvedValueOnce(assetResult("stale")); + const { container, render } = createTestRoot(); + await render(markdown(null)); + expect(renderedImage(container)).toBe(cached === "URL" ? imageUrl("stale") : undefined); + await render(null); + + mint.mockResolvedValue(assetResult("updated")); + await render(markdown(null)); + + expect(renderedImage(container)).toBe(imageUrl("updated")); + expect(mint).toHaveBeenCalledTimes(2); + }, +); + +it.each(["initial", "refresh"] as const)( + "shows the latest workspace edit after a pending %s asset request settles", + async (request) => { + let resolvePending!: (value: AssetCreateUrlResult) => void; + const pending = new Promise((resolve) => { + resolvePending = resolve; + }); + const { container, render } = createTestRoot(); + if (request === "refresh") { + await render(markdown("m0")); + expect(renderedImage(container)).toBe(imageUrl("updated")); + } + + mint.mockReturnValueOnce(pending).mockResolvedValue(assetResult("latest")); + await render(markdown("m1")); + const callsBeforeEdits = mint.mock.calls.length; + await render(markdown("m2")); + await render(markdown("m3")); + expect(mint).toHaveBeenCalledTimes(callsBeforeEdits); + + await act(async () => { + resolvePending(assetResult("before-edits")); + await pending; + }); + + expect(renderedImage(container)).toBe(imageUrl("latest")); + expect(mint).toHaveBeenCalledTimes(callsBeforeEdits + 1); + await render(markdown("m3")); + expect(mint).toHaveBeenCalledTimes(callsBeforeEdits + 1); + }, +); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a608ecb0298b..9ec6ec2db051 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -189,6 +189,7 @@ import { readEnvironmentScope, readPreparedConnection, useEnvironmentScope, + usePreparedConnection, } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; @@ -235,6 +236,7 @@ interface ChatMarkdownProps { onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; onRunShellCommand?: ((command: string) => void) | undefined; imageBaseDir?: string | undefined; + workspaceMutationId?: string | null | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; extraRemarkPlugins?: NonNullable; renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined; @@ -1574,11 +1576,47 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props readonly framed?: boolean | undefined; readonly fallbackSrc?: string | undefined; readonly workspaceRoot?: string | undefined; + readonly workspaceMutationId?: string | null | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { const assetUrl = useAssetUrlState(props.environmentId, props.resource); const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); + const connectionReady = usePreparedConnection(props.environmentId)._tag === "Some"; const resource = props.resource; + const refreshToken = JSON.stringify([props.environmentId, resource, props.workspaceMutationId]); + const handledRefreshToken = useRef(undefined); + const assetLoading = assetUrl._tag === "Loading" || assetUrl.waiting === true; + useEffect(() => { + if (!connectionReady) { + handledRefreshToken.current = null; + return; + } + if ( + props.workspaceMutationId === undefined || + props.kind === "video" || + (resource._tag !== "media-file" && resource._tag !== "workspace-file") || + handledRefreshToken.current === refreshToken + ) { + return; + } + if (assetLoading) { + // Only the initial mount shares a pending request; edits and reconnects need a new one. + if (handledRefreshToken.current === undefined) { + handledRefreshToken.current = refreshToken; + } + return; + } + handledRefreshToken.current = refreshToken; + void refreshAssetUrl().catch(() => undefined); + }, [ + assetLoading, + connectionReady, + props.kind, + props.workspaceMutationId, + refreshAssetUrl, + refreshToken, + resource._tag, + ]); const path = resource._tag === "media-file" ? resource.path @@ -2299,6 +2337,7 @@ function useChatMarkdownState({ onUseArtifactTemplate, onRunShellCommand, imageBaseDir, + workspaceMutationId, onImageExpand, renderContextReference, headingLevelOffset = 0, @@ -2688,6 +2727,7 @@ function useChatMarkdownState({ renderContextReference, headingLevelOffset, imageBaseDir, + workspaceMutationId, inlineCodeFileLinkMetaByText, isStreaming, linkTargetPreference, @@ -3136,6 +3176,7 @@ const CHAT_MARKDOWN_COMPONENTS = { githubMedia, imageBaseDir, threadRef, + workspaceMutationId, renderContextReference, } = use(ChatMarkdownRendererContext); const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; @@ -3247,6 +3288,7 @@ const CHAT_MARKDOWN_COMPONENTS = { standalone={standalone} style={authoredSizeStyle} workspaceRoot={cwd} + workspaceMutationId={workspaceMutationId} onImageExpand={imageExpand} /> ); diff --git a/apps/web/src/components/files/FileMarkdownPreview.tsx b/apps/web/src/components/files/FileMarkdownPreview.tsx index 2d4fd14e4b6e..62f1dc794a1b 100644 --- a/apps/web/src/components/files/FileMarkdownPreview.tsx +++ b/apps/web/src/components/files/FileMarkdownPreview.tsx @@ -8,6 +8,7 @@ export function FileMarkdownPreview(props: { readonly relativePath: string; readonly text: string; readonly threadRef: ScopedThreadRef; + readonly workspaceMutationId: string | null; readonly onTaskListChange?: | ((input: { readonly markerOffset: number; readonly checked: boolean }) => void) | undefined; @@ -27,6 +28,7 @@ export function FileMarkdownPreview(props: { cwd={props.cwd} imageBaseDir={imageBaseDir} threadRef={props.threadRef} + workspaceMutationId={props.workspaceMutationId} className="mx-auto max-w-4xl px-6 py-5" onTaskListChange={props.onTaskListChange} /> diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index fac7852f1e7f..9302b43d9eed 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -903,6 +903,7 @@ function RenderedMarkdownSurface({ relativePath, contents, threadRef, + workspaceMutationId, readOnly, onPendingChange, }: Omit< @@ -916,6 +917,7 @@ function RenderedMarkdownSurface({ > & { threadRef: ScopedThreadRef; readOnly: boolean; + workspaceMutationId: string | null; }) { const saveCoordinator = useFileSaveCoordinator({ environmentId, @@ -931,6 +933,7 @@ function RenderedMarkdownSurface({ cwd={cwd} relativePath={relativePath} threadRef={threadRef} + workspaceMutationId={workspaceMutationId} onTaskListChange={ readOnly ? undefined @@ -1389,6 +1392,7 @@ export default function FilePreviewPanel({ relativePath={relativePath} threadRef={threadRef} contents={file.data.contents} + workspaceMutationId={workspaceMutationId} readOnly={isHostFile || !canWriteFiles} onPendingChange={onPendingChange} />