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 @@...@@ -1,3 +1,4 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
1import { ProjectReadFileError } from "@t3tools/contracts";2import { ProjectReadFileError } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
3import { describe, expect, it } from "vite-plus/test";4import { describe, expect, it } from "vite-plus/test";
...@@ -13,6 +14,7 @@ import {...@@ -13,6 +14,7 @@ import {
13 resolveFilePreviewPath,14 resolveFilePreviewPath,
14 setMarkdownTaskChecked,15 setMarkdownTaskChecked,
15 shouldShowFileExplorer,16 shouldShowFileExplorer,
17 workspaceAssetResource,
16} from "./filePreviewMode";18} from "./filePreviewMode";
1719
18const decodeReadError = Schema.decodeSync(ProjectReadFileError);20const decodeReadError = Schema.decodeSync(ProjectReadFileError);
...@@ -189,3 +191,31 @@ describe("resolveFilePreviewPath", () => {...@@ -189,3 +191,31 @@ describe("resolveFilePreviewPath", () => {
189 }191 }
190 });192 });
191});193});
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 {...@@ -106,6 +106,7 @@ import {
106 resolveFilePreviewPath,106 resolveFilePreviewPath,
107 setMarkdownTaskChecked,107 setMarkdownTaskChecked,
108 shouldShowFileExplorer,108 shouldShowFileExplorer,
109 workspaceAssetResource,
109} from "./filePreviewMode";110} from "./filePreviewMode";
110import { useFileSaveCoordinator } from "./useFileSaveCoordinator";111import { useFileSaveCoordinator } from "./useFileSaveCoordinator";
111import {112import {
...@@ -147,18 +148,23 @@ type FilePostRender = <LAnnotation>(...@@ -147,18 +148,23 @@ type FilePostRender = <LAnnotation>(
147function WorkspaceImagePreview(props: {148function WorkspaceImagePreview(props: {
148 readonly environmentId: EnvironmentId;149 readonly environmentId: EnvironmentId;
149 readonly threadRef: ScopedThreadRef;150 readonly threadRef: ScopedThreadRef;
151 /** The thread is a draft the server does not know yet. */
152 readonly draft: boolean;
150 readonly absolutePath: string;153 readonly absolutePath: string;
151 readonly workspaceRoot: string;154 readonly workspaceRoot: string;
152 readonly alt: string;155 readonly alt: string;
153 readonly workspaceMutationId: string | null;156 readonly workspaceMutationId: string | null;
154}) {157}) {
155 const resource = useMemo(158 const resource = useMemo(
156 () => ({159 () =>
157 _tag: "workspace-file" as const,160 workspaceAssetResource({
158 threadId: props.threadRef.threadId,161 kind: "workspace-file",
159 path: props.absolutePath,162 threadRef: props.threadRef,
160 }),163 draft: props.draft,
161 [props.threadRef.threadId, props.absolutePath],164 workspaceRoot: props.workspaceRoot,
165 absolutePath: props.absolutePath,
166 }),
167 [props.threadRef, props.draft, props.workspaceRoot, props.absolutePath],
162 );168 );
163 const assetUrl = useAssetUrlState(props.environmentId, resource);169 const assetUrl = useAssetUrlState(props.environmentId, resource);
164 const [failedUrl, setFailedUrl] = useState<string | null>(null);170 const [failedUrl, setFailedUrl] = useState<string | null>(null);
...@@ -213,6 +219,8 @@ function WorkspaceImagePreview(props: {...@@ -213,6 +219,8 @@ function WorkspaceImagePreview(props: {
213function WorkspaceBrowserPreview(props: {219function WorkspaceBrowserPreview(props: {
214 readonly environmentId: EnvironmentId;220 readonly environmentId: EnvironmentId;
215 readonly threadRef: ScopedThreadRef;221 readonly threadRef: ScopedThreadRef;
222 /** The thread is a draft the server does not know yet. */
223 readonly draft: boolean;
216 readonly absolutePath: string;224 readonly absolutePath: string;
217 readonly workspaceRoot: string;225 readonly workspaceRoot: string;
218 readonly title: string;226 readonly title: string;
...@@ -221,12 +229,15 @@ function WorkspaceBrowserPreview(props: {...@@ -221,12 +229,15 @@ function WorkspaceBrowserPreview(props: {
221 const insideWorkspace =229 const insideWorkspace =
222 mediaFileReference(props.absolutePath, props.workspaceRoot).relativePath !== undefined;230 mediaFileReference(props.absolutePath, props.workspaceRoot).relativePath !== undefined;
223 const resource = useMemo(231 const resource = useMemo(
224 () => ({232 () =>
225 _tag: insideWorkspace ? ("workspace-file" as const) : ("media-file" as const),233 workspaceAssetResource({
226 threadId: props.threadRef.threadId,234 kind: insideWorkspace ? "workspace-file" : "media-file",
227 path: props.absolutePath,235 threadRef: props.threadRef,
228 }),236 draft: props.draft,
229 [insideWorkspace, props.threadRef.threadId, props.absolutePath],237 workspaceRoot: props.workspaceRoot,
238 absolutePath: props.absolutePath,
239 }),
240 [insideWorkspace, props.threadRef, props.draft, props.workspaceRoot, props.absolutePath],
230 );241 );
231 const assetUrl = useAssetUrlState(props.environmentId, resource);242 const assetUrl = useAssetUrlState(props.environmentId, resource);
232 const revisionSuffix =243 const revisionSuffix =
...@@ -260,18 +271,23 @@ function WorkspaceBrowserPreview(props: {...@@ -260,18 +271,23 @@ function WorkspaceBrowserPreview(props: {
260function WorkspaceVideoPreview(props: {271function WorkspaceVideoPreview(props: {
261 readonly environmentId: EnvironmentId;272 readonly environmentId: EnvironmentId;
262 readonly threadRef: ScopedThreadRef;273 readonly threadRef: ScopedThreadRef;
274 /** The thread is a draft the server does not know yet. */
275 readonly draft: boolean;
263 readonly absolutePath: string;276 readonly absolutePath: string;
264 readonly workspaceRoot: string;277 readonly workspaceRoot: string;
265 readonly name: string;278 readonly name: string;
266 readonly workspaceMutationId: string | null;279 readonly workspaceMutationId: string | null;
267}) {280}) {
268 const resource = useMemo(281 const resource = useMemo(
269 () => ({282 () =>
270 _tag: "media-file" as const,283 workspaceAssetResource({
271 threadId: props.threadRef.threadId,284 kind: "media-file",
272 path: props.absolutePath,285 threadRef: props.threadRef,
273 }),286 draft: props.draft,
274 [props.threadRef.threadId, props.absolutePath],287 workspaceRoot: props.workspaceRoot,
288 absolutePath: props.absolutePath,
289 }),
290 [props.threadRef, props.draft, props.workspaceRoot, props.absolutePath],
275 );291 );
276 const assetUrl = useAssetUrlState(props.environmentId, resource);292 const assetUrl = useAssetUrlState(props.environmentId, resource);
277 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource);293 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource);
...@@ -314,17 +330,23 @@ function WorkspaceVideoPreview(props: {...@@ -314,17 +330,23 @@ function WorkspaceVideoPreview(props: {
314function WorkspaceAudioPreview(props: {330function WorkspaceAudioPreview(props: {
315 readonly environmentId: EnvironmentId;331 readonly environmentId: EnvironmentId;
316 readonly threadRef: ScopedThreadRef;332 readonly threadRef: ScopedThreadRef;
333 /** The thread is a draft the server does not know yet. */
334 readonly draft: boolean;
317 readonly absolutePath: string;335 readonly absolutePath: string;
336 readonly workspaceRoot: string;
318 readonly name: string;337 readonly name: string;
319 readonly workspaceMutationId: string | null;338 readonly workspaceMutationId: string | null;
320}) {339}) {
321 const resource = useMemo(340 const resource = useMemo(
322 () => ({341 () =>
323 _tag: "media-file" as const,342 workspaceAssetResource({
324 threadId: props.threadRef.threadId,343 kind: "media-file",
325 path: props.absolutePath,344 threadRef: props.threadRef,
326 }),345 draft: props.draft,
327 [props.threadRef.threadId, props.absolutePath],346 workspaceRoot: props.workspaceRoot,
347 absolutePath: props.absolutePath,
348 }),
349 [props.threadRef, props.draft, props.workspaceRoot, props.absolutePath],
328 );350 );
329 const assetUrl = useAssetUrlState(props.environmentId, resource);351 const assetUrl = useAssetUrlState(props.environmentId, resource);
330 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource);352 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource);
...@@ -1022,6 +1044,8 @@ export default function FilePreviewPanel({...@@ -1022,6 +1044,8 @@ export default function FilePreviewPanel({
1022}: FilePreviewPanelProps) {1044}: FilePreviewPanelProps) {
1023 const relativePath =1045 const relativePath =
1024 attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath;1046 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";
1025 const { resolvedTheme } = useTheme();1049 const { resolvedTheme } = useTheme();
1026 const wordWrap = useClientSettings((settings) => settings.wordWrap);1050 const wordWrap = useClientSettings((settings) => settings.wordWrap);
1027 const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope);1051 const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope);
...@@ -1334,6 +1358,7 @@ export default function FilePreviewPanel({...@@ -1334,6 +1358,7 @@ export default function FilePreviewPanel({
1334 key={`${environmentId}:${threadRef.threadId}:${absolutePath}`}1358 key={`${environmentId}:${threadRef.threadId}:${absolutePath}`}
1335 environmentId={environmentId}1359 environmentId={environmentId}
1336 threadRef={threadRef}1360 threadRef={threadRef}
1361 draft={draft}
1337 absolutePath={absolutePath}1362 absolutePath={absolutePath}
1338 workspaceRoot={cwd}1363 workspaceRoot={cwd}
1339 name={relativePath}1364 name={relativePath}
...@@ -1344,7 +1369,9 @@ export default function FilePreviewPanel({...@@ -1344,7 +1369,9 @@ export default function FilePreviewPanel({
1344 key={`${environmentId}:${threadRef.threadId}:${absolutePath}`}1369 key={`${environmentId}:${threadRef.threadId}:${absolutePath}`}
1345 environmentId={environmentId}1370 environmentId={environmentId}
1346 threadRef={threadRef}1371 threadRef={threadRef}
1372 draft={draft}
1347 absolutePath={absolutePath}1373 absolutePath={absolutePath}
1374 workspaceRoot={cwd}
1348 name={relativePath}1375 name={relativePath}
1349 workspaceMutationId={workspaceMutationId}1376 workspaceMutationId={workspaceMutationId}
1350 />1377 />
...@@ -1353,6 +1380,7 @@ export default function FilePreviewPanel({...@@ -1353,6 +1380,7 @@ export default function FilePreviewPanel({
1353 key={absolutePath}1380 key={absolutePath}
1354 environmentId={environmentId}1381 environmentId={environmentId}
1355 threadRef={threadRef}1382 threadRef={threadRef}
1383 draft={draft}
1356 absolutePath={absolutePath}1384 absolutePath={absolutePath}
1357 workspaceRoot={cwd}1385 workspaceRoot={cwd}
1358 alt={relativePath}1386 alt={relativePath}
...@@ -1363,6 +1391,7 @@ export default function FilePreviewPanel({...@@ -1363,6 +1391,7 @@ export default function FilePreviewPanel({
1363 key={absolutePath}1391 key={absolutePath}
1364 environmentId={environmentId}1392 environmentId={environmentId}
1365 threadRef={threadRef}1393 threadRef={threadRef}
1394 draft={draft}
1366 absolutePath={absolutePath}1395 absolutePath={absolutePath}
1367 workspaceRoot={cwd}1396 workspaceRoot={cwd}
1368 title={relativePath}1397 title={relativePath}
apps/web/src/components/files/filePreviewMode.ts+18-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { ProjectReadFileError } from "@t3tools/contracts";1import type { AssetResource, ProjectReadFileError, ScopedThreadRef } from "@t3tools/contracts";
2import { isAbsolutePath, workspaceRelativeFilePath } from "@t3tools/shared/path";2import { isAbsolutePath, workspaceRelativeFilePath } from "@t3tools/shared/path";
33
4/** Resolve workspace links before choosing between the explorer and a file preview. */4/** Resolve workspace links before choosing between the explorer and a file preview. */
...@@ -57,3 +57,20 @@ export function setMarkdownTaskChecked(...@@ -57,3 +57,20 @@ export function setMarkdownTaskChecked(
5757
58 return `${markdown.slice(0, markerOffset + 1)}${checked ? "x" : " "}${markdown.slice(markerOffset + 2)}`;58 return `${markdown.slice(0, markerOffset + 1)}${checked ? "x" : " "}${markdown.slice(markerOffset + 2)}`;
59}59}
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}