Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
35c2674
fix(web): refresh embedded assets in markdown file previews
Saikrishna1876 Oct 12, 2026
011812b
Merge branch 'main' into fix/svg-file-viewer
Saikrishna1876 Oct 13, 2026
510965a
Merge branch 'main' into fix/svg-file-viewer
Saikrishna1876 Oct 14, 2026
aacc8bc
Merge branch 'main' into fix/svg-file-viewer
Saikrishna1876 Oct 14, 2026
9ed18a1
Merge branch 'main' into fix/svg-file-viewer
Saikrishna1876 Oct 15, 2026
4710ca0
Merge branch 'main' into fix/svg-file-viewer
Saikrishna1876 Oct 15, 2026
c65b7ba
Merge branch 'main' into fix/svg-file-viewer
Saikrishna1876 Oct 17, 2026
d55aaab
Merge branch 'main' into fix/svg-file-viewer
Saikrishna1876 Oct 17, 2026
b6462c1
Merge branch 'main' into fix/svg-file-viewer
Saikrishna1876 Oct 18, 2026
a34fad6
Merge branch 'main' into fix/svg-file-viewer
Saikrishna1876 Oct 18, 2026
19e1323
Merge branch 'main' into fix/svg-file-viewer
Saikrishna1876 Oct 18, 2026
fa28d5d
Merge remote-tracking branch 'origin/main' into fix/svg-file-viewer
juliusmarminge Oct 19, 2026
2cc0179
test(web): trim markdown preview asset refresh tests to the behavior
juliusmarminge Oct 19, 2026
ccf899c
fix(web): markdown file previews refresh embedded assets after worksp…
juliusmarminge Oct 19, 2026
38f7bb2
Merge remote-tracking branch 'origin/main' into fix/svg-file-viewer
juliusmarminge Oct 19, 2026
70b6e81
test(web): reproduce cached and pending preview asset regressions
juliusmarminge Oct 19, 2026
435b226
fix(web): retain markdown image refreshes through pending requests
juliusmarminge Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions apps/web/src/assets/assetUrls.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useAtomValue } from "@effect/atom-react";
import {
type AssetUrlState,
type AssetUrlState as RuntimeAssetUrlState,
assetUrlStateFromResult,
blockedAssetUrlState,
EMPTY_ASSET_URL_ATOM,
Expand All @@ -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,
Expand All @@ -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(
Expand Down
177 changes: 153 additions & 24 deletions apps/web/src/components/ChatMarkdown.assets.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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<AssetCreateUrlResult>>());
// 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();
Expand All @@ -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,<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>#${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(
<ChatMarkdown
cwd="/repo"
threadRef={{
environmentId: EnvironmentId.make("media-environment"),
threadId: ThreadId.make("media-thread"),
}}
text="[Open image](/tmp/image.png)"
onImageExpand={onImageExpand}
/>,
),
return {
container,
render: (children: ReactNode) =>
act(async () => {
root.render(
<RegistryContext.Provider value={registry}>{children}</RegistryContext.Provider>,
);
}),
};
}

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(
<ChatMarkdown
cwd="/repo"
threadRef={{
environmentId: EnvironmentId.make("media-environment"),
threadId: ThreadId.make("media-thread"),
}}
text="[Open image](/tmp/image.png)"
onImageExpand={onImageExpand}
/>,
);
const link = container.querySelector<HTMLAnchorElement>('a[href="/tmp/image.png"]');
expect(link).not.toBeNull();
Expand All @@ -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 (
<ChatMarkdown
cwd="/repo"
imageBaseDir="/repo"
threadRef={threadRef}
text="![Diagram](diagram.svg)"
workspaceMutationId={workspaceMutationId}
/>
);
}

function renderedImage(container: HTMLElement) {
return container.querySelector<HTMLImageElement>('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<AssetCreateUrlResult>((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);
},
);
42 changes: 42 additions & 0 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ import {
readEnvironmentScope,
readPreparedConnection,
useEnvironmentScope,
usePreparedConnection,
} from "../state/session";
import { previewEnvironment } from "../state/preview";
import { useAtomCommand } from "../state/use-atom-command";
Expand Down Expand Up @@ -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<ReactMarkdownOptions["remarkPlugins"]>;
renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined;
Expand Down Expand Up @@ -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<string | null | undefined>(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
Expand Down Expand Up @@ -2299,6 +2337,7 @@ function useChatMarkdownState({
onUseArtifactTemplate,
onRunShellCommand,
imageBaseDir,
workspaceMutationId,
onImageExpand,
renderContextReference,
headingLevelOffset = 0,
Expand Down Expand Up @@ -2688,6 +2727,7 @@ function useChatMarkdownState({
renderContextReference,
headingLevelOffset,
imageBaseDir,
workspaceMutationId,
inlineCodeFileLinkMetaByText,
isStreaming,
linkTargetPreference,
Expand Down Expand Up @@ -3136,6 +3176,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
githubMedia,
imageBaseDir,
threadRef,
workspaceMutationId,
renderContextReference,
} = use(ChatMarkdownRendererContext);
const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;
Expand Down Expand Up @@ -3247,6 +3288,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
standalone={standalone}
style={authoredSizeStyle}
workspaceRoot={cwd}
workspaceMutationId={workspaceMutationId}
onImageExpand={imageExpand}
/>
);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/files/FileMarkdownPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}
/>
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,7 @@ function RenderedMarkdownSurface({
relativePath,
contents,
threadRef,
workspaceMutationId,
readOnly,
onPendingChange,
}: Omit<
Expand All @@ -916,6 +917,7 @@ function RenderedMarkdownSurface({
> & {
threadRef: ScopedThreadRef;
readOnly: boolean;
workspaceMutationId: string | null;
}) {
const saveCoordinator = useFileSaveCoordinator({
environmentId,
Expand All @@ -931,6 +933,7 @@ function RenderedMarkdownSurface({
cwd={cwd}
relativePath={relativePath}
threadRef={threadRef}
workspaceMutationId={workspaceMutationId}
onTaskListChange={
readOnly
? undefined
Expand Down Expand Up @@ -1389,6 +1392,7 @@ export default function FilePreviewPanel({
relativePath={relativePath}
threadRef={threadRef}
contents={file.data.contents}
workspaceMutationId={workspaceMutationId}
readOnly={isHostFile || !canWriteFiles}
onPendingChange={onPendingChange}
/>
Expand Down
Loading