authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 16:40:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 16:40:02-07:00
loge0c80c198cfbb2afe04528cd6a7ec1d2f21606dd
tree30e117ffea751cf01e3adcc6750e9903f0c05b61
parent3bfbc37dbf37095816eda500f3f1e05cb98bb95b
signature Signed by PGP key B5690EEEBB952194

fix(web): images, video, HTML and PDF preview in a thread before its first message (#17352)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

3 files changed, 101 insertions(+), 25 deletions(-)

apps/web/src/components/files/FilePreviewPanel.test.ts+30
......@@ -1,3 +1,4 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
12import { ProjectReadFileError } from "@t3tools/contracts";
23import * as Schema from "effect/Schema";
34import { describe, expect, it } from "vite-plus/test";
......@@ -13,6 +14,7 @@ import {
1314 resolveFilePreviewPath,
1415 setMarkdownTaskChecked,
1516 shouldShowFileExplorer,
17 workspaceAssetResource,
1618} from "./filePreviewMode";
1719
1820const decodeReadError = Schema.decodeSync(ProjectReadFileError);
......@@ -189,3 +191,31 @@ describe("resolveFilePreviewPath", () => {
189191 }
190192 });
191193});
194
195describe("workspaceAssetResource", () => {
196 const input = {
197 kind: "workspace-file" as const,
198 threadRef: {
199 environmentId: "env" as ScopedThreadRef["environmentId"],
200 threadId: "thread-1" as ScopedThreadRef["threadId"],
201 },
202 workspaceRoot: "/repo",
203 absolutePath: "/repo/docs/index.html",
204 };
205
206 it("names the thread once the server knows it", () => {
207 expect(workspaceAssetResource({ ...input, draft: false })).toEqual({
208 _tag: "workspace-file",
209 threadId: "thread-1",
210 path: "/repo/docs/index.html",
211 });
212 });
213
214 it("names the workspace root for a draft, which the server cannot resolve from a thread", () => {
215 expect(workspaceAssetResource({ ...input, draft: true })).toEqual({
216 _tag: "draft-workspace-file",
217 cwd: "/repo",
218 path: "/repo/docs/index.html",
219 });
220 });
221});
apps/web/src/components/files/FilePreviewPanel.tsx+53-24
......@@ -106,6 +106,7 @@ import {
106106 resolveFilePreviewPath,
107107 setMarkdownTaskChecked,
108108 shouldShowFileExplorer,
109 workspaceAssetResource,
109110} from "./filePreviewMode";
110111import { useFileSaveCoordinator } from "./useFileSaveCoordinator";
111112import {
......@@ -147,18 +148,23 @@ type FilePostRender = <LAnnotation>(
147148function WorkspaceImagePreview(props: {
148149 readonly environmentId: EnvironmentId;
149150 readonly threadRef: ScopedThreadRef;
151 /** The thread is a draft the server does not know yet. */
152 readonly draft: boolean;
150153 readonly absolutePath: string;
151154 readonly workspaceRoot: string;
152155 readonly alt: string;
153156 readonly workspaceMutationId: string | null;
154157}) {
155158 const resource = useMemo(
156 () => ({
157 _tag: "workspace-file" as const,
158 threadId: props.threadRef.threadId,
159 path: props.absolutePath,
160 }),
161 [props.threadRef.threadId, props.absolutePath],
159 () =>
160 workspaceAssetResource({
161 kind: "workspace-file",
162 threadRef: props.threadRef,
163 draft: props.draft,
164 workspaceRoot: props.workspaceRoot,
165 absolutePath: props.absolutePath,
166 }),
167 [props.threadRef, props.draft, props.workspaceRoot, props.absolutePath],
162168 );
163169 const assetUrl = useAssetUrlState(props.environmentId, resource);
164170 const [failedUrl, setFailedUrl] = useState<string | null>(null);
......@@ -213,6 +219,8 @@ function WorkspaceImagePreview(props: {
213219function WorkspaceBrowserPreview(props: {
214220 readonly environmentId: EnvironmentId;
215221 readonly threadRef: ScopedThreadRef;
222 /** The thread is a draft the server does not know yet. */
223 readonly draft: boolean;
216224 readonly absolutePath: string;
217225 readonly workspaceRoot: string;
218226 readonly title: string;
......@@ -221,12 +229,15 @@ function WorkspaceBrowserPreview(props: {
221229 const insideWorkspace =
222230 mediaFileReference(props.absolutePath, props.workspaceRoot).relativePath !== undefined;
223231 const resource = useMemo(
224 () => ({
225 _tag: insideWorkspace ? ("workspace-file" as const) : ("media-file" as const),
226 threadId: props.threadRef.threadId,
227 path: props.absolutePath,
228 }),
229 [insideWorkspace, props.threadRef.threadId, props.absolutePath],
232 () =>
233 workspaceAssetResource({
234 kind: insideWorkspace ? "workspace-file" : "media-file",
235 threadRef: props.threadRef,
236 draft: props.draft,
237 workspaceRoot: props.workspaceRoot,
238 absolutePath: props.absolutePath,
239 }),
240 [insideWorkspace, props.threadRef, props.draft, props.workspaceRoot, props.absolutePath],
230241 );
231242 const assetUrl = useAssetUrlState(props.environmentId, resource);
232243 const revisionSuffix =
......@@ -260,18 +271,23 @@ function WorkspaceBrowserPreview(props: {
260271function WorkspaceVideoPreview(props: {
261272 readonly environmentId: EnvironmentId;
262273 readonly threadRef: ScopedThreadRef;
274 /** The thread is a draft the server does not know yet. */
275 readonly draft: boolean;
263276 readonly absolutePath: string;
264277 readonly workspaceRoot: string;
265278 readonly name: string;
266279 readonly workspaceMutationId: string | null;
267280}) {
268281 const resource = useMemo(
269 () => ({
270 _tag: "media-file" as const,
271 threadId: props.threadRef.threadId,
272 path: props.absolutePath,
273 }),
274 [props.threadRef.threadId, props.absolutePath],
282 () =>
283 workspaceAssetResource({
284 kind: "media-file",
285 threadRef: props.threadRef,
286 draft: props.draft,
287 workspaceRoot: props.workspaceRoot,
288 absolutePath: props.absolutePath,
289 }),
290 [props.threadRef, props.draft, props.workspaceRoot, props.absolutePath],
275291 );
276292 const assetUrl = useAssetUrlState(props.environmentId, resource);
277293 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource);
......@@ -314,17 +330,23 @@ function WorkspaceVideoPreview(props: {
314330function WorkspaceAudioPreview(props: {
315331 readonly environmentId: EnvironmentId;
316332 readonly threadRef: ScopedThreadRef;
333 /** The thread is a draft the server does not know yet. */
334 readonly draft: boolean;
317335 readonly absolutePath: string;
336 readonly workspaceRoot: string;
318337 readonly name: string;
319338 readonly workspaceMutationId: string | null;
320339}) {
321340 const resource = useMemo(
322 () => ({
323 _tag: "media-file" as const,
324 threadId: props.threadRef.threadId,
325 path: props.absolutePath,
326 }),
327 [props.threadRef.threadId, props.absolutePath],
341 () =>
342 workspaceAssetResource({
343 kind: "media-file",
344 threadRef: props.threadRef,
345 draft: props.draft,
346 workspaceRoot: props.workspaceRoot,
347 absolutePath: props.absolutePath,
348 }),
349 [props.threadRef, props.draft, props.workspaceRoot, props.absolutePath],
328350 );
329351 const assetUrl = useAssetUrlState(props.environmentId, resource);
330352 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource);
......@@ -1022,6 +1044,8 @@ export default function FilePreviewPanel({
10221044}: FilePreviewPanelProps) {
10231045 const relativePath =
10241046 attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath;
1047 // A draft's composer target is its draft id; a thread the server knows is a ref.
1048 const draft = typeof composerDraftTarget === "string";
10251049 const { resolvedTheme } = useTheme();
10261050 const wordWrap = useClientSettings((settings) => settings.wordWrap);
10271051 const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope);
......@@ -1334,6 +1358,7 @@ export default function FilePreviewPanel({
13341358 key={`${environmentId}:${threadRef.threadId}:${absolutePath}`}
13351359 environmentId={environmentId}
13361360 threadRef={threadRef}
1361 draft={draft}
13371362 absolutePath={absolutePath}
13381363 workspaceRoot={cwd}
13391364 name={relativePath}
......@@ -1344,7 +1369,9 @@ export default function FilePreviewPanel({
13441369 key={`${environmentId}:${threadRef.threadId}:${absolutePath}`}
13451370 environmentId={environmentId}
13461371 threadRef={threadRef}
1372 draft={draft}
13471373 absolutePath={absolutePath}
1374 workspaceRoot={cwd}
13481375 name={relativePath}
13491376 workspaceMutationId={workspaceMutationId}
13501377 />
......@@ -1353,6 +1380,7 @@ export default function FilePreviewPanel({
13531380 key={absolutePath}
13541381 environmentId={environmentId}
13551382 threadRef={threadRef}
1383 draft={draft}
13561384 absolutePath={absolutePath}
13571385 workspaceRoot={cwd}
13581386 alt={relativePath}
......@@ -1363,6 +1391,7 @@ export default function FilePreviewPanel({
13631391 key={absolutePath}
13641392 environmentId={environmentId}
13651393 threadRef={threadRef}
1394 draft={draft}
13661395 absolutePath={absolutePath}
13671396 workspaceRoot={cwd}
13681397 title={relativePath}
apps/web/src/components/files/filePreviewMode.ts+18-1
......@@ -1,4 +1,4 @@
1import type { ProjectReadFileError } from "@t3tools/contracts";
1import type { AssetResource, ProjectReadFileError, ScopedThreadRef } from "@t3tools/contracts";
22import { isAbsolutePath, workspaceRelativeFilePath } from "@t3tools/shared/path";
33
44/** Resolve workspace links before choosing between the explorer and a file preview. */
......@@ -57,3 +57,20 @@ export function setMarkdownTaskChecked(
5757
5858 return `${markdown.slice(0, markerOffset + 1)}${checked ? "x" : " "}${markdown.slice(markerOffset + 2)}`;
5959}
60
61/**
62 * The asset for a file the panel shows. A draft has no thread on the server
63 * yet, so it names its workspace root instead of a thread to resolve one from.
64 */
65export function workspaceAssetResource(input: {
66 readonly kind: "workspace-file" | "media-file";
67 readonly threadRef: ScopedThreadRef;
68 readonly draft: boolean;
69 readonly workspaceRoot: string;
70 readonly absolutePath: string;
71}): AssetResource {
72 if (input.draft) {
73 return { _tag: "draft-workspace-file", cwd: input.workspaceRoot, path: input.absolutePath };
74 }
75 return { _tag: input.kind, threadId: input.threadRef.threadId, path: input.absolutePath };
76}