authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 19:37:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 19:37:41-07:00
log922bd692251bc803c12a3fab159efe83c957bb70
treeeb779ea33c073ce16ab93e59bddfea60768d12b2
parent31eeb443305a4e11c8b20a7c8ff2b3f5e841eb0f
signature Signed by PGP key B5690EEEBB952194

refactor(media): unify file and media previews across clients (#9253)

Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

52 files changed, 1582 insertions(+), 1740 deletions(-)

apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts+13-143
......@@ -1,31 +1,15 @@
11import {
2 fileBasename,
3 formatFilePathPosition,
24 inlineCodeFilePathCandidate,
3 isConventionalFilePosition,
5 normalizeMarkdownLinkDestination,
6 parseMarkdownFileLink,
47} from "@t3tools/client-runtime/markdown-links";
58import { videoMimeType } from "@t3tools/shared/video";
69
710import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
811
9const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
10const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
11const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
12const RELATIVE_FILE_PATH_PATTERN =
13 /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
14const RELATIVE_FILE_NAME_PATTERN =
15 /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
1612const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
17const POSIX_FILE_ROOT_PREFIXES = [
18 "/Users/",
19 "/home/",
20 "/tmp/",
21 "/var/",
22 "/etc/",
23 "/opt/",
24 "/mnt/",
25 "/Volumes/",
26 "/private/",
27 "/root/",
28] as const;
2913
3014export type MarkdownLinkPresentation =
3115 | {
......@@ -246,112 +230,13 @@ const FILE_ICON_BY_EXTENSION: Readonly<Record<string, MarkdownFileIcon>> = {
246230 zsh: "bash",
247231};
248232
249function safeDecode(value: string): string {
250 try {
251 return decodeURIComponent(value);
252 } catch {
253 return value;
254 }
255}
256
257function normalizeDestination(value: string): string {
258 const trimmed = value.trim();
259 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
260}
261
262233/** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */
263234export function normalizeNativeMarkdownUrl(value: string): string {
264235 return value.startsWith("//") ? `https:${value}` : value;
265236}
266237
267function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null {
268 try {
269 const parsed = new URL(href);
270 if (parsed.protocol.toLowerCase() !== "file:") {
271 return null;
272 }
273 const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname;
274 const rawPath = uncHostname
275 ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}`
276 : parsed.pathname;
277 const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath;
278 return { path, hash: parsed.hash };
279 } catch {
280 return null;
281 }
282}
283
284function stripSearchAndHash(value: string): { readonly path: string; readonly hash: string } {
285 const hashIndex = value.indexOf("#");
286 const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
287 const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";
288 const queryIndex = pathWithSearch.indexOf("?");
289 return {
290 path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch,
291 hash,
292 };
293}
294
295function splitFilePosition(
296 path: string,
297 hash: string,
298): { readonly path: string; readonly line?: number; readonly column?: number } {
299 const suffixMatch = path.match(/:(\d+)(?::(\d+))?$/);
300 const hashMatch = suffixMatch ? null : hash.match(/^#L(\d+)(?:C(\d+))?$/i);
301 const match = suffixMatch ?? hashMatch;
302 if (!match?.[1]) {
303 return { path };
304 }
305
306 const line = Number.parseInt(match[1], 10);
307 const column = match[2] ? Number.parseInt(match[2], 10) : undefined;
308 const pathWithoutPosition = suffixMatch ? path.slice(0, -suffixMatch[0].length) : path;
309 return {
310 path: pathWithoutPosition,
311 ...(line > 0 ? { line } : {}),
312 ...(column !== undefined && column > 0 ? { column } : {}),
313 };
314}
315
316function looksLikePosixFilesystemPath(path: string): boolean {
317 if (!path.startsWith("/")) {
318 return false;
319 }
320 if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) {
321 return true;
322 }
323 if (POSITION_SUFFIX_PATTERN.test(path)) {
324 return true;
325 }
326 const basename = path.slice(path.lastIndexOf("/") + 1);
327 return /\.[A-Za-z0-9_-]+$/.test(basename);
328}
329
330function looksLikeFilePath(value: string): boolean {
331 if (WINDOWS_DRIVE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value)) {
332 return true;
333 }
334 if (RELATIVE_PATH_PREFIX_PATTERN.test(value)) {
335 return true;
336 }
337 if (value.startsWith("/")) {
338 return looksLikePosixFilesystemPath(value);
339 }
340 if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) {
341 return true;
342 }
343 if (isConventionalFilePosition(value)) return true;
344 return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value);
345}
346
347function fileLabel(value: string): string {
348 const normalized = value.replaceAll("\\", "/");
349 const basename = normalized.slice(normalized.lastIndexOf("/") + 1);
350 return basename || normalized;
351}
352
353238export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
354 const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase();
239 const basename = fileBasename(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase();
355240 if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video";
356241 const exactIcon = FILE_ICON_BY_NAME[basename];
357242 if (exactIcon) return exactIcon;
......@@ -367,7 +252,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
367252}
368253
369254export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation {
370 const normalized = normalizeDestination(href);
255 const normalized = normalizeMarkdownLinkDestination(href);
371256 try {
372257 const parsed = new URL(normalizeNativeMarkdownUrl(normalized));
373258 if (parsed.protocol === "http:" || parsed.protocol === "https:") {
......@@ -381,31 +266,16 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese
381266 // Relative paths and non-URL link destinations are handled below.
382267 }
383268
384 const source = normalized.toLowerCase().startsWith("file:")
385 ? fileUrlTarget(normalized)
386 : stripSearchAndHash(normalized);
387 const decodedSource = source
388 ? { path: safeDecode(source.path.trim()), hash: safeDecode(source.hash.trim()) }
389 : null;
390 const fileTarget = decodedSource
391 ? splitFilePosition(decodedSource.path, decodedSource.hash)
392 : null;
393 const targetWithPosition = fileTarget
394 ? `${fileTarget.path}${
395 fileTarget.line
396 ? `:${fileTarget.line}${fileTarget.column ? `:${fileTarget.column}` : ""}`
397 : ""
398 }`
399 : null;
400 if (fileTarget && targetWithPosition && looksLikeFilePath(targetWithPosition)) {
269 const target = parseMarkdownFileLink(normalized);
270 if (target) {
401271 return {
402272 kind: "file",
403273 href: normalized,
404 icon: resolveMarkdownFileIcon(fileTarget.path),
405 label: fileLabel(targetWithPosition),
406 path: fileTarget.path,
407 ...(fileTarget.line ? { line: fileTarget.line } : {}),
408 ...(fileTarget.column ? { column: fileTarget.column } : {}),
274 icon: resolveMarkdownFileIcon(target.path),
275 label: fileBasename(formatFilePathPosition(target)),
276 path: target.path,
277 ...(target.line ? { line: target.line } : {}),
278 ...(target.column ? { column: target.column } : {}),
409279 };
410280 }
411281
apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift+2
......@@ -144,6 +144,8 @@ final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource,
144144 type = detectedType
145145 } else if CGPDFDocument(download as CFURL) != nil {
146146 type = .pdf
147 } else if URL(fileURLWithPath: title).pathExtension.lowercased() == "svg" {
148 type = .svg
147149 } else {
148150 throw URLError(.cannotDecodeContentData)
149151 }
apps/mobile/src/components/ComposerAttachmentStrip.tsx+12-43
......@@ -1,12 +1,12 @@
11import { SymbolView } from "../components/AppSymbol";
22import { videoMimeType } from "@t3tools/shared/video";
3import { useEffect, useRef, useState } from "react";
4import { Alert, Image, Pressable, ScrollView, View } from "react-native";
3import { useMemo } from "react";
4import { Image, Pressable, ScrollView, View } from "react-native";
55
66import { AppText as Text } from "./AppText";
77import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";
88import { VideoAttachmentTile } from "./VideoAttachmentTile";
9import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
9import type { MediaActionsSource } from "../lib/mediaActions";
1010import { PresentationSource } from "./NativePresentation";
1111import type { FilePreviewSource } from "./FilePreviewModal";
1212import { isPdfFile } from "../lib/filePreview";
......@@ -175,46 +175,16 @@ function ComposerVideoAttachment(props: {
175175 const { attachment } = props;
176176 const sourceIdentifier = `draft:${attachment.id}`;
177177 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
178 const shareRef = useRef<AbortController | null>(null);
179 const [sharing, setSharing] = useState(false);
180 useEffect(
181 () => () => {
182 shareRef.current?.abort();
183 shareRef.current = null;
184 },
185 [],
178 const actionsSource = useMemo<MediaActionsSource>(
179 () => ({
180 name: attachment.name,
181 mimeType: videoMimeType(attachment) ?? attachment.mimeType,
182 sourceIdentifier,
183 attachment,
184 }),
185 [attachment, sourceIdentifier],
186186 );
187187
188 const onShare = () => {
189 if (shareRef.current) return;
190 const controller = new AbortController();
191 shareRef.current = controller;
192 setSharing(true);
193 void (async () => {
194 const preview = await loadLocalAttachmentPreview(attachment, controller.signal);
195 if (!preview) return;
196 try {
197 await preview.share(controller.signal, sourceIdentifier);
198 } finally {
199 preview.dispose();
200 }
201 })()
202 .catch((error: unknown) => {
203 if (!controller.signal.aborted) {
204 Alert.alert(
205 "Could not share video",
206 error instanceof Error ? error.message : "Try again.",
207 );
208 }
209 })
210 .finally(() => {
211 if (shareRef.current === controller) {
212 shareRef.current = null;
213 setSharing(false);
214 }
215 });
216 };
217
218188 return (
219189 <VideoAttachmentTile
220190 name={attachment.name}
......@@ -222,8 +192,7 @@ function ComposerVideoAttachment(props: {
222192 thumbnailSource={attachment}
223193 compact={props.compact}
224194 onPress={() => props.onPressVideo(attachment, sourceIdentifier)}
225 onShare={onShare}
226 disabled={sharing}
195 actionsSource={actionsSource}
227196 style={style}
228197 />
229198 );
apps/mobile/src/components/FilePreview.ios.tsx+1-6
......@@ -3,7 +3,6 @@ import { useEffect, useEffectEvent, useId } from "react";
33import { Alert } from "react-native";
44
55import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
6import { MediaImagePreview } from "./MediaImagePreview";
76
87const NativeControls = requireNativeModule<{
98 presentFile(
......@@ -47,9 +46,5 @@ export function FilePreview(props: {
4746 readonly source: ResolvedFilePreviewSource;
4847 readonly onRequestClose: () => void;
4948}) {
50 return props.source.kind === "image" && props.source.actionsSource ? (
51 <MediaImagePreview {...props} />
52 ) : (
53 <NativeFilePreview {...props} />
54 );
49 return <NativeFilePreview {...props} />;
5550}
apps/mobile/src/components/MediaActionsMenu.tsx+3-1
......@@ -1,6 +1,6 @@
11import { MenuView } from "@react-native-menu/menu";
22import type { ReactElement } from "react";
3import { Platform, View, type PressableProps } from "react-native";
3import { Platform, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
44
55import type { useMediaActions } from "../lib/mediaActions";
66import { SymbolView } from "./AppSymbol";
......@@ -10,6 +10,7 @@ export function MediaActionsMenu(props: {
1010 readonly media: ReturnType<typeof useMediaActions>;
1111 readonly inModal?: boolean;
1212 readonly children?: ReactElement<PressableProps>;
13 readonly style?: StyleProp<ViewStyle>;
1314}) {
1415 if (props.media.actions.length === 0) return props.children ?? null;
1516 // Android's normal anchored menu lives in the app-root portal, behind native modals.
......@@ -18,6 +19,7 @@ export function MediaActionsMenu(props: {
1819 return (
1920 <Menu
2021 title={props.media.title}
22 style={props.style}
2123 shouldOpenOnLongPress={props.children !== undefined}
2224 actions={props.media.actions.map(({ id, title, disabled }) => ({
2325 id,
apps/mobile/src/components/MediaImagePreview.tsx+1-1
......@@ -42,7 +42,7 @@ function ImagePreviewHeader() {
4242 );
4343}
4444
45/** Chat and workspace media retain source actions on both platforms; other files use native previews. */
45/** Android keeps media actions in its in-app image viewer. iOS uses Quick Look. */
4646export function MediaImagePreview(props: MediaImagePreviewProps) {
4747 return (
4848 <ImagePreviewContext value={props}>
apps/mobile/src/components/MediaVideoPlayer.tsx+35-46
......@@ -101,8 +101,8 @@ interface MediaVideoPlayerProps {
101101 readonly thumbnailVisible?: boolean;
102102 readonly unavailable?: boolean;
103103 readonly expanded?: boolean;
104 readonly autoPlay?: boolean;
104105 readonly paused?: boolean;
105 readonly onExpand?: () => void;
106106 readonly actionsSource?: MediaActionsSource;
107107}
108108
......@@ -122,62 +122,51 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) {
122122 <LoadedMediaVideo
123123 uri={props.uri ?? playbackUri}
124124 resolvePlaybackUri={props.resolvePlaybackUri}
125 playRequested={!props.expanded}
125 playRequested={!props.expanded || props.autoPlay === true}
126126 paused={(props.paused ?? false) || mediaActions.sharing}
127127 />
128128 ) : (
129 <Pressable
130 accessibilityRole="button"
131 accessibilityLabel={`Play ${props.name}`}
132 accessibilityState={{ disabled: props.uri === null || props.unavailable === true }}
133 disabled={props.uri === null || props.unavailable === true}
134 onPress={() => setPlaybackUri(props.uri)}
135 className="flex-1 items-center justify-center gap-2 px-4"
136 >
137 {!props.unavailable ? (
138 <VideoThumbnailImage
139 cacheKey={props.thumbnailKey}
140 source={props.thumbnailVisible === false ? null : props.uri}
141 contentFit="contain"
142 />
143 ) : null}
144 {props.unavailable ? (
145 <AppText className="text-sm text-white/80">Video unavailable</AppText>
146 ) : props.uri === null ? (
147 <ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" />
148 ) : (
149 <>
129 <MediaActionsMenu media={mediaActions} inModal={props.expanded} style={{ flex: 1 }}>
130 <Pressable
131 accessibilityRole="button"
132 accessibilityLabel={`Play ${props.name}`}
133 accessibilityHint={
134 mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined
135 }
136 accessibilityState={{ disabled: props.uri === null || props.unavailable === true }}
137 // Stays pressable so the long-press menu still opens on a failed or unsigned tile.
138 onPress={() => {
139 if (props.uri !== null && !props.unavailable) setPlaybackUri(props.uri);
140 }}
141 className="flex-1 items-center justify-center px-4"
142 >
143 {!props.unavailable ? (
144 <VideoThumbnailImage
145 cacheKey={props.thumbnailKey}
146 source={props.thumbnailVisible === false ? null : props.uri}
147 contentFit="contain"
148 />
149 ) : null}
150 {props.unavailable ? (
151 <AppText className="text-sm text-white/80">Video unavailable</AppText>
152 ) : props.uri === null ? (
153 <ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" />
154 ) : (
150155 <View className="size-12 items-center justify-center rounded-full bg-black/60">
151156 <SymbolView name="play" size={28} tintColor="#ffffff" type="monochrome" />
152157 </View>
158 )}
159 {props.uri !== null && !props.unavailable ? (
153160 <AppText
154 className="rounded bg-black/60 px-2 py-1 text-center text-xs text-white"
155 numberOfLines={2}
161 className="absolute right-2 bottom-2 max-w-[75%] rounded bg-black/60 px-2 py-1 text-right text-xs text-white"
162 numberOfLines={1}
156163 >
157164 {props.name}
158165 </AppText>
159 </>
160 )}
161 </Pressable>
166 ) : null}
167 </Pressable>
168 </MediaActionsMenu>
162169 )}
163 {props.onExpand ? (
164 <Pressable
165 accessibilityRole="button"
166 accessibilityLabel={`Expand ${props.name}`}
167 onPress={() => {
168 setPlaybackUri(null);
169 props.onExpand?.();
170 }}
171 className="absolute right-1 top-1 min-h-11 min-w-11 items-center justify-center rounded-md bg-black/60 px-2"
172 >
173 <AppText className="text-xs text-white">Expand</AppText>
174 </Pressable>
175 ) : null}
176 {props.actionsSource ? (
177 <View className="absolute left-1 top-1">
178 <MediaActionsMenu media={mediaActions} inModal={props.expanded} />
179 </View>
180 ) : null}
181170 </View>
182171 );
183172}
apps/mobile/src/components/MediaVideoPreviewModal.tsx deleted-96
......@@ -1,96 +0,0 @@
1import { useEffect } from "react";
2import { Keyboard, Modal, Pressable, View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
4
5import { useMediaActions } from "../lib/mediaActions";
6import { MediaActionsMenu } from "./MediaActionsMenu";
7import {
8 mediaVideoPreviewUri,
9 mediaVideoThumbnailKey,
10 type MediaVideoPreviewSource,
11} from "../lib/videoPreviewSource";
12import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets";
13import { usePreparedConnection } from "../state/session";
14import { AppText } from "./AppText";
15import { SymbolView } from "./AppSymbol";
16import { MediaVideoPlayer } from "./MediaVideoPlayer";
17import { MediaSourceCaption } from "./MediaSourceCaption";
18
19/** Media files stream in place. A client-side copy is made only for an explicit share. */
20export function MediaVideoPreviewModal(props: {
21 readonly source: MediaVideoPreviewSource;
22 readonly onRequestClose: () => void;
23}) {
24 const { source } = props;
25 const insets = useSafeAreaInsets();
26 const environmentId = "environmentId" in source ? source.environmentId : null;
27 const connection = usePreparedConnection(environmentId);
28 const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null);
29 const refreshAssetUrl = useRefreshAssetUrl(
30 environmentId,
31 "resource" in source ? source.resource : null,
32 );
33 const resolvePlaybackUri =
34 "resource" in source
35 ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl())
36 : undefined;
37 const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null);
38 const mediaActions = useMediaActions(source.actionsSource, props.onRequestClose);
39 const unavailable =
40 uri === null &&
41 environmentId !== null &&
42 (connection._tag === "None" || asset._tag === "Failure");
43
44 useEffect(() => Keyboard.dismiss(), []);
45 return (
46 <Modal
47 visible
48 animationType="fade"
49 presentationStyle="fullScreen"
50 onRequestClose={props.onRequestClose}
51 statusBarTranslucent
52 navigationBarTranslucent
53 >
54 <View
55 className="flex-1 bg-black"
56 style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
57 >
58 <View className="min-h-14 flex-row items-center gap-3 pl-4 pr-2">
59 <AppText className="flex-1 font-t3-medium text-base text-white" numberOfLines={2}>
60 {source.name}
61 </AppText>
62 <MediaActionsMenu media={mediaActions} inModal />
63 <Pressable
64 accessibilityRole="button"
65 accessibilityLabel="Close video"
66 onPress={props.onRequestClose}
67 className="size-12 items-center justify-center"
68 >
69 <SymbolView name="xmark" size={20} tintColor="#ffffff" type="monochrome" />
70 </Pressable>
71 </View>
72 <MediaSourceCaption source={mediaActions.title} />
73 <MediaVideoPlayer
74 uri={uri}
75 resolvePlaybackUri={resolvePlaybackUri}
76 name={source.name}
77 thumbnailKey={mediaVideoThumbnailKey(source)}
78 unavailable={unavailable}
79 paused={mediaActions.sharing}
80 expanded
81 />
82 <Pressable
83 accessibilityRole="button"
84 accessibilityLabel="Save or share video"
85 disabled={uri === null || mediaActions.sharing}
86 onPress={mediaActions.share}
87 className="mx-4 my-3 min-h-12 items-center justify-center rounded-xl bg-white/15 px-4"
88 >
89 <AppText className="font-t3-medium text-base text-white">
90 {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"}
91 </AppText>
92 </Pressable>
93 </View>
94 </Modal>
95 );
96}
apps/mobile/src/components/VideoAttachmentMenu.tsx deleted-53
......@@ -1,53 +0,0 @@
1import type { ReactElement } from "react";
2import { Platform, type PressableProps } from "react-native";
3
4import { ControlPillMenu } from "./ControlPill";
5import { PresentationSource } from "./NativePresentation";
6
7export function VideoAttachmentMenu(props: {
8 readonly sourceIdentifier: string;
9 readonly onOpen: () => void;
10 readonly onShare?: () => void;
11 readonly disabled?: boolean;
12 readonly children: ReactElement<PressableProps>;
13}) {
14 return (
15 <PresentationSource
16 identifier={props.sourceIdentifier}
17 accessible={Platform.OS === "ios"}
18 accessibilityRole="button"
19 accessibilityLabel={props.children.props.accessibilityLabel}
20 accessibilityHint={props.children.props.accessibilityHint}
21 accessibilityState={{ disabled: props.disabled ?? false }}
22 onAccessibilityTap={() => {
23 if (!props.disabled) props.onOpen();
24 }}
25 accessibilityActions={props.onShare ? [{ name: "share", label: "Save or share video" }] : []}
26 onAccessibilityAction={({ nativeEvent }) => {
27 if (nativeEvent.actionName === "share" && !props.disabled) props.onShare?.();
28 }}
29 >
30 {Platform.OS === "ios" && props.onShare ? (
31 <ControlPillMenu
32 shouldOpenOnLongPress
33 style={{ alignSelf: "flex-start" }}
34 actions={[
35 {
36 id: "share",
37 title: "Save or share video",
38 image: "square.and.arrow.up",
39 attributes: { disabled: props.disabled ?? false },
40 },
41 ]}
42 onPressAction={({ nativeEvent }) => {
43 if (nativeEvent.event === "share") props.onShare?.();
44 }}
45 >
46 {props.children}
47 </ControlPillMenu>
48 ) : (
49 props.children
50 )}
51 </PresentationSource>
52 );
53}
apps/mobile/src/components/VideoAttachmentTile.tsx+56-42
......@@ -2,65 +2,79 @@ import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react
22
33import { cn } from "../lib/cn";
44import type { DraftComposerFileAttachment } from "../lib/composerImages";
5import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions";
56import { SymbolView } from "./AppSymbol";
67import { AppText } from "./AppText";
7import { VideoAttachmentMenu } from "./VideoAttachmentMenu";
8import { MediaActionsMenu } from "./MediaActionsMenu";
9import { PresentationSource } from "./NativePresentation";
810import { VideoThumbnailImage } from "./VideoThumbnailImage";
911
1012export function VideoAttachmentTile(props: {
1113 readonly name: string;
1214 readonly sourceIdentifier: string;
1315 readonly thumbnailSource: string | DraftComposerFileAttachment | null;
16 readonly actionsSource?: MediaActionsSource;
1417 readonly compact?: boolean;
1518 readonly onPress: (sourceIdentifier: string) => void;
16 readonly onShare?: () => void;
1719 readonly disabled?: boolean;
1820 readonly className?: string;
1921 readonly style?: StyleProp<ViewStyle>;
2022}) {
23 const mediaActions = useMediaActions(props.disabled ? undefined : props.actionsSource);
24 const hasActions = mediaActions.actions.length > 0;
2125 return (
22 <VideoAttachmentMenu
23 sourceIdentifier={props.sourceIdentifier}
24 onOpen={() => props.onPress(props.sourceIdentifier)}
25 onShare={props.onShare}
26 disabled={props.disabled}
26 <PresentationSource
27 identifier={props.sourceIdentifier}
28 accessible={Platform.OS === "ios"}
29 accessibilityRole="button"
30 accessibilityLabel={`Play ${props.name}`}
31 accessibilityState={{ disabled: props.disabled ?? false }}
32 onAccessibilityTap={() => {
33 if (!props.disabled) props.onPress(props.sourceIdentifier);
34 }}
35 accessibilityActions={mediaActions.actions.map(({ id, title }) => ({
36 name: id,
37 label: title,
38 }))}
39 onAccessibilityAction={({ nativeEvent }) => {
40 if (props.disabled) return;
41 mediaActions.actions.find(({ id }) => id === nativeEvent.actionName)?.run();
42 }}
2743 >
28 <Pressable
29 accessibilityRole="button"
30 accessibilityLabel={`Play ${props.name}`}
31 accessibilityHint={
32 Platform.OS === "ios" && props.onShare
33 ? "Touch and hold for save and share options"
34 : undefined
35 }
36 accessibilityState={{ disabled: props.disabled ?? false }}
37 disabled={props.disabled}
38 onPress={() => props.onPress(props.sourceIdentifier)}
39 className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)}
40 style={props.style}
41 >
42 <VideoThumbnailImage cacheKey={props.sourceIdentifier} source={props.thumbnailSource} />
43 <View
44 className={cn(
45 "items-center justify-center rounded-full bg-black/45",
46 props.compact ? "size-6" : "size-12",
47 )}
44 <MediaActionsMenu media={mediaActions}>
45 <Pressable
46 accessibilityRole="button"
47 accessibilityLabel={`Play ${props.name}`}
48 accessibilityHint={hasActions ? "Touch and hold for media actions" : undefined}
49 accessibilityState={{ disabled: props.disabled ?? false }}
50 disabled={props.disabled}
51 onPress={() => props.onPress(props.sourceIdentifier)}
52 className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)}
53 style={props.style}
4854 >
49 <SymbolView
50 name="play"
51 size={props.compact ? 15 : 24}
52 tintColor="#ffffff"
53 type="monochrome"
54 />
55 </View>
56 {!props.compact ? (
57 <View className="absolute inset-x-0 bottom-0 bg-black/55 px-2 py-1.5">
58 <AppText className="text-center text-xs text-white" numberOfLines={1}>
59 {props.name}
60 </AppText>
55 <VideoThumbnailImage cacheKey={props.sourceIdentifier} source={props.thumbnailSource} />
56 <View
57 className={cn(
58 "items-center justify-center rounded-full bg-black/45",
59 props.compact ? "size-6" : "size-12",
60 )}
61 >
62 <SymbolView
63 name="play"
64 size={props.compact ? 15 : 24}
65 tintColor="#ffffff"
66 type="monochrome"
67 />
6168 </View>
62 ) : null}
63 </Pressable>
64 </VideoAttachmentMenu>
69 {!props.compact ? (
70 <View className="absolute inset-x-0 bottom-0 bg-black/55 px-2 py-1.5">
71 <AppText className="text-center text-xs text-white" numberOfLines={1}>
72 {props.name}
73 </AppText>
74 </View>
75 ) : null}
76 </Pressable>
77 </MediaActionsMenu>
78 </PresentationSource>
6579 );
6680}
apps/mobile/src/components/VideoPreviewModal.ios.tsx+28-30
......@@ -1,14 +1,12 @@
11import { useIsFocused } from "@react-navigation/native";
2import { videoMimeType } from "@t3tools/shared/video";
32import { requireNativeModule } from "expo";
43import { useEffect, useEffectEvent, useId, useState } from "react";
54import { Alert, Keyboard } from "react-native";
65
76import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import { useAssetUrlState } from "../state/assets";
7import { mediaVideoPreviewUri, type VideoPreviewSource } from "../lib/videoPreviewSource";
8import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets";
99import { usePreparedConnection } from "../state/session";
10import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource";
11import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal";
1210
1311export type { VideoPreviewSource } from "../lib/videoPreviewSource";
1412
......@@ -23,27 +21,28 @@ const NativeControls = requireNativeModule<{
2321}>("T3NativeControls");
2422
2523function NativeVideoPreview(props: {
26 readonly source: AttachmentVideoPreviewSource;
24 readonly source: VideoPreviewSource;
2725 readonly onRequestClose: () => void;
2826}) {
2927 const { source } = props;
30 const { attachment } = source;
28 const localAttachment = source.type === "local" ? source.attachment : null;
3129 const identifier = useId();
3230 const onRequestClose = useEffectEvent(props.onRequestClose);
33 const environmentId = source.type === "remote" ? source.environmentId : null;
31 const environmentId =
32 source.type === "media" && "environmentId" in source ? source.environmentId : null;
33 const resource = source.type === "media" && "resource" in source ? source.resource : null;
3434 const preparedConnection = usePreparedConnection(environmentId);
35 const mimeType = videoMimeType(attachment) ?? attachment.mimeType;
36 const assetUrl = useAssetUrlState(
37 environmentId,
38 source.type === "remote"
39 ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType }
40 : null,
41 );
42 const [playbackUrl, setPlaybackUrl] = useState<string | null>(() =>
43 assetUrl._tag === "Success" ? assetUrl.url : null,
44 );
35 const assetUrl = useAssetUrlState(environmentId, resource);
36 const refreshAssetUrl = useEffectEvent(useRefreshAssetUrl(environmentId, resource));
37 const name = source.type === "media" ? source.name : source.attachment.name;
38 // The first minted URL is kept so a background refresh does not restart playback.
39 const resolvedUrl =
40 source.type === "media"
41 ? mediaVideoPreviewUri(source, assetUrl._tag === "Success" ? assetUrl.url : null)
42 : null;
43 const [playbackUrl, setPlaybackUrl] = useState<string | null>(resolvedUrl);
4544 const loadError =
46 source.type === "remote" && playbackUrl === null
45 resource !== null && playbackUrl === null
4746 ? preparedConnection._tag === "None"
4847 ? "Reconnect to this environment and open the video again."
4948 : assetUrl._tag === "Failure"
......@@ -53,8 +52,8 @@ function NativeVideoPreview(props: {
5352
5453 useEffect(() => Keyboard.dismiss(), []);
5554 useEffect(() => {
56 if (playbackUrl === null && assetUrl._tag === "Success") setPlaybackUrl(assetUrl.url);
57 }, [playbackUrl, assetUrl]);
55 if (playbackUrl === null && resolvedUrl !== null) setPlaybackUrl(resolvedUrl);
56 }, [playbackUrl, resolvedUrl]);
5857 useEffect(() => {
5958 if (!loadError) return;
6059 Alert.alert("Could not open video", loadError);
......@@ -62,21 +61,21 @@ function NativeVideoPreview(props: {
6261 }, [loadError]);
6362
6463 useEffect(() => {
65 if (source.type === "remote" && playbackUrl === null) return;
64 if (localAttachment === null && playbackUrl === null) return;
6665 const controller = new AbortController();
6766 let ready = false;
6867 void (async () => {
6968 const file =
70 source.type === "local"
71 ? await loadLocalAttachmentPreview(source.attachment, controller.signal)
69 localAttachment !== null
70 ? await loadLocalAttachmentPreview(localAttachment, controller.signal)
7271 : null;
73 if (source.type === "local" && !file) return;
72 if (localAttachment !== null && !file) return;
7473 try {
7574 if (controller.signal.aborted) return;
7675 ready = true;
7776 await NativeControls.presentVideo(
7877 file?.uri ?? playbackUrl!,
79 attachment.name,
78 name,
8079 source.sourceIdentifier ?? "",
8180 identifier,
8281 );
......@@ -87,10 +86,12 @@ function NativeVideoPreview(props: {
8786 }
8887 })().catch((error: unknown) => {
8988 if (controller.signal.aborted) return;
89 // AVKit gives no retry, so re-mint now; the cached URL may simply have expired.
90 if (ready) void refreshAssetUrl();
9091 Alert.alert(
9192 "Could not open video",
9293 ready
93 ? "This video couldn't be loaded or played. Check the connection, or touch and hold the attachment to save or share the original."
94 ? "This video couldn't be loaded or played. Check the connection, or touch and hold the video to save or share the original."
9495 : error instanceof Error
9596 ? error.message
9697 : "Could not load this video.",
......@@ -101,7 +102,7 @@ function NativeVideoPreview(props: {
101102 controller.abort();
102103 void NativeControls.dismissVideo(identifier).catch(() => undefined);
103104 };
104 }, [source, attachment.name, playbackUrl, identifier]);
105 }, [localAttachment, name, source.sourceIdentifier, playbackUrl, identifier]);
105106
106107 return null;
107108}
......@@ -118,8 +119,5 @@ export function VideoPreviewModal(props: {
118119 }, [isFocused, hasSource]);
119120
120121 if (!props.source || !isFocused) return null;
121 if (props.source.type === "media") {
122 return <MediaVideoPreviewModal source={props.source} onRequestClose={props.onRequestClose} />;
123 }
124122 return <NativeVideoPreview source={props.source} onRequestClose={props.onRequestClose} />;
125123}
apps/mobile/src/components/VideoPreviewModal.tsx+153-180
......@@ -1,190 +1,139 @@
11import { useIsFocused } from "@react-navigation/native";
22import { videoMimeType } from "@t3tools/shared/video";
3import { useEvent } from "expo";
4import { useVideoPlayer, VideoView } from "expo-video";
53import { useEffect, useRef, useState } from "react";
6import {
7 ActivityIndicator,
8 AppState,
9 Keyboard,
10 Modal,
11 Pressable,
12 StyleSheet,
13 View,
14} from "react-native";
4import { ActivityIndicator, Keyboard, Modal, Pressable, View } from "react-native";
155import { useSafeAreaInsets } from "react-native-safe-area-context";
166
17import {
18 downloadAttachmentForPreview,
19 type AttachmentPreviewFile,
20} from "../lib/attachmentDownload";
217import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
22import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource";
23import { useAssetUrlState } from "../state/assets";
8import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions";
9import {
10 mediaVideoPreviewUri,
11 mediaVideoThumbnailKey,
12 type LocalVideoPreviewSource,
13 type MediaVideoPreviewSource,
14 type VideoPreviewSource,
15} from "../lib/videoPreviewSource";
16import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets";
2417import { usePreparedConnection } from "../state/session";
25import { SymbolView } from "./AppSymbol";
2618import { AppText } from "./AppText";
27import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal";
19import { SymbolView } from "./AppSymbol";
20import { MediaActionsMenu } from "./MediaActionsMenu";
21import { MediaSourceCaption } from "./MediaSourceCaption";
22import { MediaVideoPlayer } from "./MediaVideoPlayer";
2823
2924export type { VideoPreviewSource } from "../lib/videoPreviewSource";
3025
31function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) {
32 const player = useVideoPlayer(props.file.uri, (player) => {
33 player.staysActiveInBackground = false;
34 if (AppState.currentState === "active") player.play();
35 });
36 const { status } = useEvent(player, "statusChange", { status: player.status });
37 const shareControllerRef = useRef<AbortController | null>(null);
38 const [sharing, setSharing] = useState(false);
39 const [shareError, setShareError] = useState<string | null>(null);
40
41 useEffect(
42 () => () => {
43 shareControllerRef.current?.abort();
44 shareControllerRef.current = null;
45 },
46 [],
47 );
26interface PlaybackState {
27 readonly uri: string | null;
28 readonly resolvePlaybackUri?: () => Promise<string | null>;
29 readonly unavailable: boolean;
30 readonly error: string | null;
31 readonly actionsSource: MediaActionsSource | undefined;
32}
4833
49 const onShare = () => {
50 if (shareControllerRef.current) return;
51 player.pause();
52 const controller = new AbortController();
53 shareControllerRef.current = controller;
54 setSharing(true);
55 setShareError(null);
56 void props.file
57 .share(controller.signal)
58 .catch((error: unknown) => {
59 if (!controller.signal.aborted) {
60 setShareError(error instanceof Error ? error.message : "Could not share this video.");
61 }
62 })
63 .finally(() => {
64 if (shareControllerRef.current === controller) {
65 shareControllerRef.current = null;
66 setSharing(false);
67 }
68 });
34function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState {
35 const environmentId = "environmentId" in source ? source.environmentId : null;
36 const resource = "resource" in source ? source.resource : null;
37 const connection = usePreparedConnection(environmentId);
38 const asset = useAssetUrlState(environmentId, resource);
39 const refreshAssetUrl = useRefreshAssetUrl(environmentId, resource);
40 const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null);
41 return {
42 uri,
43 ...(resource !== null
44 ? { resolvePlaybackUri: async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) }
45 : {}),
46 unavailable:
47 uri === null &&
48 environmentId !== null &&
49 (connection._tag === "None" || asset._tag === "Failure"),
50 error: null,
51 actionsSource: source.actionsSource,
6952 };
70
71 return (
72 <>
73 <View className="flex-1 items-center justify-center">
74 {status === "error" ? (
75 <AppText className="px-6 text-center text-base text-white/80">
76 This video couldn't be played on this device. You can save or share the original file.
77 </AppText>
78 ) : (
79 <>
80 <VideoView
81 player={player}
82 style={StyleSheet.absoluteFill}
83 nativeControls
84 contentFit="contain"
85 fullscreenOptions={{ enable: true }}
86 allowsPictureInPicture={false}
87 />
88 {status === "loading" ? (
89 <ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" />
90 ) : null}
91 </>
92 )}
93 </View>
94 <Pressable
95 accessibilityRole="button"
96 accessibilityLabel="Save or share video"
97 accessibilityState={{ disabled: sharing, busy: sharing }}
98 disabled={sharing}
99 onPress={onShare}
100 className="mx-4 my-3 min-h-12 items-center justify-center rounded-xl bg-white/15 px-4"
101 >
102 <AppText className="font-t3-medium text-base text-white">
103 {sharing ? "Opening share sheet..." : "Save or share video"}
104 </AppText>
105 </Pressable>
106 {shareError ? (
107 <AppText accessibilityRole="alert" className="px-4 pb-3 text-sm text-white/80">
108 {shareError}
109 </AppText>
110 ) : null}
111 </>
112 );
11353}
11454
115function OpenVideoPreviewModal(props: {
116 readonly source: AttachmentVideoPreviewSource;
117 readonly onRequestClose: () => void;
118}) {
119 const { source } = props;
55function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState {
12056 const { attachment } = source;
121 const insets = useSafeAreaInsets();
122 const environmentId = source.type === "remote" ? source.environmentId : null;
123 const preparedConnection = usePreparedConnection(environmentId);
124 const fileUri = source.type === "local" ? source.attachment.fileUri : null;
125 const mimeType = videoMimeType(attachment) ?? attachment.mimeType;
126 const assetUrl = useAssetUrlState(
127 environmentId,
128 source.type === "remote"
129 ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType }
130 : null,
131 );
132 const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
133 const [file, setFile] = useState<AttachmentPreviewFile | null>(null);
134 const [failure, setFailure] = useState<string | null>(null);
135
136 useEffect(() => Keyboard.dismiss(), []);
137 useEffect(() => {
138 if (environmentId !== null && downloadUrl === null && assetUrl._tag === "Success") {
139 setDownloadUrl(assetUrl.url);
140 }
141 }, [environmentId, downloadUrl, assetUrl]);
142
57 const [uri, setUri] = useState<string | null>(null);
58 const [error, setError] = useState<string | null>(null);
59 // Only a different file needs a new lease; a metadata update on the same
60 // draft must not dispose the file Android is still playing.
61 const attachmentRef = useRef(attachment);
62 attachmentRef.current = attachment;
63 const { id: attachmentId, fileUri } = attachment;
14364 useEffect(() => {
144 if (source.type === "remote" && downloadUrl === null) return;
65 setUri(null);
66 setError(null);
14567 const controller = new AbortController();
146 let preview: AttachmentPreviewFile | null = null;
147 setFile(null);
148 setFailure(null);
149 const loading =
150 source.type === "local"
151 ? loadLocalAttachmentPreview(source.attachment, controller.signal)
152 : downloadAttachmentForPreview({
153 url: downloadUrl!,
154 attachment: { name: attachment.name, mimeType },
155 signal: controller.signal,
156 });
68 const loading = loadLocalAttachmentPreview(attachmentRef.current, controller.signal);
15769 void loading.then(
158 (loaded) => {
159 if (controller.signal.aborted) {
160 loaded?.dispose();
161 return;
162 }
163 preview = loaded;
164 setFile(loaded);
70 (file) => {
71 if (file === null) return;
72 if (controller.signal.aborted) file.dispose();
73 else setUri(file.uri);
16574 },
166 (error: unknown) => {
75 (cause: unknown) => {
16776 if (!controller.signal.aborted) {
168 setFailure(error instanceof Error ? error.message : "Could not load this video.");
77 setError(cause instanceof Error ? cause.message : "Could not load this video.");
16978 }
17079 },
17180 );
17281 return () => {
17382 controller.abort();
174 preview?.dispose();
83 void loading.then(
84 (file) => file?.dispose(),
85 () => undefined,
86 );
17587 };
176 }, [source.type, environmentId, attachment.id, attachment.name, mimeType, fileUri, downloadUrl]);
88 }, [attachmentId, fileUri]);
89 const mimeType = videoMimeType(attachment) ?? attachment.mimeType;
90 return {
91 uri,
92 unavailable: error !== null,
93 error,
94 actionsSource: uri === null ? undefined : { name: attachment.name, mimeType, uri },
95 };
96}
97
98function MediaPreviewModal(props: {
99 readonly source: MediaVideoPreviewSource;
100 readonly onRequestClose: () => void;
101}) {
102 return (
103 <OpenVideoPreviewModal
104 name={props.source.name}
105 thumbnailKey={mediaVideoThumbnailKey(props.source)}
106 playback={useMediaPlayback(props.source)}
107 onRequestClose={props.onRequestClose}
108 />
109 );
110}
111
112function LocalPreviewModal(props: {
113 readonly source: LocalVideoPreviewSource;
114 readonly onRequestClose: () => void;
115}) {
116 return (
117 <OpenVideoPreviewModal
118 name={props.source.attachment.name}
119 thumbnailKey={`local:${props.source.attachment.id}`}
120 playback={useLocalPlayback(props.source)}
121 onRequestClose={props.onRequestClose}
122 />
123 );
124}
177125
178 const loadError =
179 failure ??
180 (environmentId !== null && downloadUrl === null
181 ? preparedConnection._tag === "None"
182 ? "This environment is disconnected. Reconnect and open the video again."
183 : assetUrl._tag === "Failure"
184 ? "Could not load this video. Check the connection to this environment and try again."
185 : null
186 : null);
126function OpenVideoPreviewModal(props: {
127 readonly name: string;
128 readonly thumbnailKey: string;
129 readonly playback: PlaybackState;
130 readonly onRequestClose: () => void;
131}) {
132 const { playback } = props;
133 const insets = useSafeAreaInsets();
134 const mediaActions = useMediaActions(playback.actionsSource, props.onRequestClose);
187135
136 useEffect(() => Keyboard.dismiss(), []);
188137 return (
189138 <Modal
190139 visible
......@@ -200,8 +149,9 @@ function OpenVideoPreviewModal(props: {
200149 >
201150 <View className="min-h-14 flex-row items-center gap-3 pl-4 pr-2">
202151 <AppText className="flex-1 font-t3-medium text-base text-white" numberOfLines={2}>
203 {attachment.name}
152 {props.name}
204153 </AppText>
154 <MediaActionsMenu media={mediaActions} inModal />
205155 <Pressable
206156 accessibilityRole="button"
207157 accessibilityLabel="Close video"
......@@ -211,22 +161,40 @@ function OpenVideoPreviewModal(props: {
211161 <SymbolView name="xmark" size={20} tintColor="#ffffff" type="monochrome" />
212162 </Pressable>
213163 </View>
214 {file ? (
215 <VideoPlayback key={file.uri} file={file} />
216 ) : (
164 <MediaSourceCaption source={mediaActions.title} />
165 {playback.uri === null && !playback.unavailable ? (
217166 <View className="flex-1 items-center justify-center gap-3 px-6">
218 {loadError ? (
219 <AppText accessibilityRole="alert" className="text-center text-base text-white/80">
220 {loadError}
221 </AppText>
222 ) : (
223 <>
224 <ActivityIndicator color="#ffffff" />
225 <AppText className="text-sm text-white/80">Loading video...</AppText>
226 </>
227 )}
167 <ActivityIndicator color="#ffffff" />
168 <AppText className="text-sm text-white/80">Loading video...</AppText>
228169 </View>
170 ) : (
171 <MediaVideoPlayer
172 uri={playback.uri}
173 resolvePlaybackUri={playback.resolvePlaybackUri}
174 name={props.name}
175 thumbnailKey={props.thumbnailKey}
176 unavailable={playback.unavailable}
177 paused={mediaActions.sharing}
178 expanded
179 autoPlay
180 />
229181 )}
182 {playback.error ? (
183 <AppText accessibilityRole="alert" className="px-4 pb-3 text-sm text-white/80">
184 {playback.error}
185 </AppText>
186 ) : null}
187 <Pressable
188 accessibilityRole="button"
189 accessibilityLabel="Save or share video"
190 disabled={playback.uri === null || mediaActions.sharing}
191 onPress={mediaActions.share}
192 className="mx-4 my-3 min-h-12 items-center justify-center rounded-xl bg-white/15 px-4"
193 >
194 <AppText className="font-t3-medium text-base text-white">
195 {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"}
196 </AppText>
197 </Pressable>
230198 </View>
231199 </Modal>
232200 );
......@@ -243,12 +211,17 @@ export function VideoPreviewModal(props: {
243211 }, [isFocused, hasSource, props.onRequestClose]);
244212 const { source } = props;
245213 if (source === null || !isFocused) return null;
246 if (source.type === "media") {
247 return <MediaVideoPreviewModal source={source} onRequestClose={props.onRequestClose} />;
248 }
249 const key =
250 source.type === "local"
251 ? `local:${source.attachment.id}:${source.attachment.fileUri}`
252 : `remote:${source.environmentId}:${source.attachment.id}`;
253 return <OpenVideoPreviewModal key={key} source={source} onRequestClose={props.onRequestClose} />;
214 return source.type === "local" ? (
215 <LocalPreviewModal
216 key={`local:${source.attachment.id}:${source.attachment.fileUri}`}
217 source={source}
218 onRequestClose={props.onRequestClose}
219 />
220 ) : (
221 <MediaPreviewModal
222 key={mediaVideoThumbnailKey(source)}
223 source={source}
224 onRequestClose={props.onRequestClose}
225 />
226 );
254227}
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+1-1
......@@ -668,7 +668,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
668668 id: action.id,
669669 title: action.title,
670670 icon:
671 action.id === "share" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const),
671 action.id === "save" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const),
672672 inline: false,
673673 onPress: action.run,
674674 }))
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+38-75
......@@ -1,11 +1,8 @@
1import { useAtomValue } from "@effect/atom-react";
21import { useId, useMemo, useState } from "react";
32import { ActivityIndicator, Image, Pressable, View } from "react-native";
4import { AsyncResult } from "effect/unstable/reactivity";
53
64import { AppText as Text } from "../../components/AppText";
75import { EmptyState } from "../../components/EmptyState";
8import { workspaceFileImageAtom } from "./workspace-file-image-cache";
96import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
107import { PresentationSource } from "../../components/NativePresentation";
118import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
......@@ -27,86 +24,52 @@ function ResolvedWorkspaceFileImagePreview(props: {
2724
2825 return (
2926 <View className="relative flex-1 bg-subtle">
30 <Pressable
31 accessibilityRole="button"
32 accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`}
33 disabled={loadError !== null}
34 className="flex-1 p-4 active:bg-subtle-strong"
35 onPress={() =>
36 setPreview({
37 kind: "image",
38 uri: props.uri,
39 name: props.accessibilityLabel,
40 sourceIdentifier,
41 actionsSource: props.actionsSource,
42 })
43 }
44 >
45 <PresentationSource identifier={sourceIdentifier} style={{ flex: 1 }}>
46 <Image
47 accessible={false}
48 source={imageSource}
49 className="h-full w-full"
50 resizeMode="contain"
51 onLoadStart={() => setLoadError(null)}
52 onError={(event) => {
53 setLoadError(event.nativeEvent.error || "The image could not be rendered.");
54 }}
55 />
56 </PresentationSource>
57 </Pressable>
27 <MediaActionsMenu media={mediaActions} style={{ flex: 1 }}>
28 <Pressable
29 accessibilityRole="button"
30 accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`}
31 accessibilityHint={
32 mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined
33 }
34 disabled={loadError !== null}
35 className="flex-1 p-4 active:bg-subtle-strong"
36 onPress={() =>
37 setPreview({
38 kind: "image",
39 uri: props.uri,
40 name: props.accessibilityLabel,
41 sourceIdentifier,
42 actionsSource: props.actionsSource,
43 })
44 }
45 >
46 <PresentationSource identifier={sourceIdentifier} style={{ flex: 1 }}>
47 <Image
48 accessible={false}
49 source={imageSource}
50 className="h-full w-full"
51 resizeMode="contain"
52 onLoadStart={() => setLoadError(null)}
53 onError={(event) => {
54 setLoadError(event.nativeEvent.error || "The image could not be rendered.");
55 }}
56 />
57 </PresentationSource>
58 </Pressable>
59 </MediaActionsMenu>
5860 {loadError !== null ? (
59 <View className="absolute inset-0 items-center justify-center bg-card px-6">
61 <View
62 pointerEvents="none"
63 className="absolute inset-0 items-center justify-center bg-card px-6"
64 >
6065 <EmptyState title="Image unavailable" detail={loadError} />
6166 </View>
6267 ) : null}
63
64 <View className="absolute right-2 top-2">
65 <MediaActionsMenu media={mediaActions} />
66 </View>
67
6868 <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />
6969 </View>
7070 );
7171}
7272
73function CachedWorkspaceFileImagePreview(props: {
74 readonly accessibilityLabel: string;
75 readonly uri: string;
76 readonly actionsSource?: MediaActionsSource;
77}) {
78 const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]);
79 const imageResult = useAtomValue(imageAtom);
80
81 if (AsyncResult.isFailure(imageResult)) {
82 return (
83 <View className="flex-1 items-center justify-center bg-card px-6">
84 <EmptyState
85 title="Image unavailable"
86 detail="The image could not be loaded into the local cache."
87 />
88 </View>
89 );
90 }
91
92 if (!AsyncResult.isSuccess(imageResult)) {
93 return (
94 <View className="flex-1 items-center justify-center gap-3 bg-card px-6">
95 <ActivityIndicator />
96 <Text className="text-center text-sm text-foreground-muted">Loading image...</Text>
97 </View>
98 );
99 }
100
101 return (
102 <ResolvedWorkspaceFileImagePreview
103 accessibilityLabel={props.accessibilityLabel}
104 uri={imageResult.value}
105 actionsSource={props.actionsSource}
106 />
107 );
108}
109
11073export function WorkspaceFileImagePreview(props: {
11174 readonly accessibilityLabel: string;
11275 readonly uri: string | null;
......@@ -124,7 +87,7 @@ export function WorkspaceFileImagePreview(props: {
12487 }
12588
12689 return (
127 <CachedWorkspaceFileImagePreview
90 <ResolvedWorkspaceFileImagePreview
12891 accessibilityLabel={props.accessibilityLabel}
12992 uri={props.uri}
13093 actionsSource={props.actionsSource}
apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx-7
......@@ -1,9 +1,7 @@
1import { useState } from "react";
21import { View } from "react-native";
32
43import { EmptyState } from "../../components/EmptyState";
54import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";
6import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
75import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource";
86
97/** Uses the signed progressive URL directly; choosing a file never preloads its video bytes as text. */
......@@ -15,7 +13,6 @@ export function WorkspaceFileVideoPreview(props: {
1513 readonly resolvePlaybackUri: () => Promise<string | null>;
1614 readonly unavailable: boolean;
1715}) {
18 const [preview, setPreview] = useState<VideoPreviewSource | null>(null);
1916 const uri = props.uri;
2017
2118 if (props.unavailable) {
......@@ -37,11 +34,7 @@ export function WorkspaceFileVideoPreview(props: {
3734 name={props.name}
3835 thumbnailKey={props.thumbnailKey}
3936 actionsSource={props.source?.actionsSource}
40 onExpand={
41 uri === null || props.source === null ? undefined : () => setPreview(props.source)
42 }
4337 />
44 <VideoPreviewModal source={preview} onRequestClose={() => setPreview(null)} />
4538 </View>
4639 );
4740}
apps/mobile/src/features/files/workspace-file-image-cache.test.ts deleted-64
......@@ -1,64 +0,0 @@
1import { AtomRegistry } from "effect/unstable/reactivity";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3import { describe, expect, it, vi } from "vite-plus/test";
4
5import { createWorkspaceFileImageAtomFamily } from "./workspace-file-image-cache";
6
7describe("workspaceFileImageAtom", () => {
8 it("reuses a prefetched image across route remounts", async () => {
9 const prefetch = vi.fn(async () => true);
10 const imageAtom = createWorkspaceFileImageAtomFamily({ idleTtlMs: 1_000, prefetch });
11 const registry = AtomRegistry.make({ timeoutResolution: 1 });
12 const first = imageAtom("https://example.test/image.png");
13 const firstUnmount = registry.mount(first);
14
15 await vi.waitFor(() => {
16 expect(AsyncResult.isSuccess(registry.get(first))).toBe(true);
17 });
18 firstUnmount();
19
20 const remounted = imageAtom("https://example.test/image.png");
21 const secondUnmount = registry.mount(remounted);
22
23 expect(remounted).toBe(first);
24 expect(AsyncResult.isSuccess(registry.get(remounted))).toBe(true);
25 expect(prefetch).toHaveBeenCalledTimes(1);
26
27 secondUnmount();
28 registry.dispose();
29 });
30
31 it("prefetches different asset URLs independently", async () => {
32 const prefetch = vi.fn(async () => true);
33 const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch });
34 const registry = AtomRegistry.make();
35 const first = imageAtom("https://example.test/first.png");
36 const second = imageAtom("https://example.test/second.png");
37 const firstUnmount = registry.mount(first);
38 const secondUnmount = registry.mount(second);
39
40 await vi.waitFor(() => {
41 expect(AsyncResult.isSuccess(registry.get(first))).toBe(true);
42 expect(AsyncResult.isSuccess(registry.get(second))).toBe(true);
43 });
44 expect(prefetch).toHaveBeenCalledTimes(2);
45
46 firstUnmount();
47 secondUnmount();
48 registry.dispose();
49 });
50
51 it("exposes prefetch failures", async () => {
52 const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false });
53 const registry = AtomRegistry.make();
54 const atom = imageAtom("https://example.test/missing.png");
55 const unmount = registry.mount(atom);
56
57 await vi.waitFor(() => {
58 expect(AsyncResult.isFailure(registry.get(atom))).toBe(true);
59 });
60
61 unmount();
62 registry.dispose();
63 });
64});
apps/mobile/src/features/files/workspace-file-image-cache.ts deleted-48
......@@ -1,48 +0,0 @@
1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import { Atom } from "effect/unstable/reactivity";
4
5const WORKSPACE_IMAGE_IDLE_TTL_MS = 30 * 60_000;
6
7type ImagePrefetch = (uri: string) => Promise<boolean>;
8
9class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {}
10
11export class WorkspaceImagePrefetchError extends Data.TaggedError("WorkspaceImagePrefetchError")<{
12 readonly cause?: unknown;
13 readonly uri: string;
14}> {}
15
16async function prefetchWithNativeImage(uri: string): Promise<boolean> {
17 const { Image } = await import("react-native");
18 return Image.prefetch(uri);
19}
20
21export function createWorkspaceFileImageAtomFamily(options?: {
22 readonly idleTtlMs?: number;
23 readonly prefetch?: ImagePrefetch;
24}) {
25 const idleTtlMs = options?.idleTtlMs ?? WORKSPACE_IMAGE_IDLE_TTL_MS;
26 const prefetch = options?.prefetch ?? prefetchWithNativeImage;
27 const family = Atom.family((key: WorkspaceImageCacheKey) =>
28 Atom.make(
29 Effect.tryPromise({
30 try: async () => {
31 const cached = await prefetch(key.uri);
32 if (!cached) {
33 throw new WorkspaceImagePrefetchError({ uri: key.uri });
34 }
35 return key.uri;
36 },
37 catch: (cause) =>
38 cause instanceof WorkspaceImagePrefetchError
39 ? cause
40 : new WorkspaceImagePrefetchError({ uri: key.uri, cause }),
41 }),
42 ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`mobile:workspace-image:${key.uri}`)),
43 );
44
45 return (uri: string) => family(new WorkspaceImageCacheKey({ uri }));
46}
47
48export const workspaceFileImageAtom = createWorkspaceFileImageAtomFamily();
apps/mobile/src/features/threads/ThreadFeed.tsx+87-71
......@@ -105,6 +105,7 @@ import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia";
105105import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
106106import { MediaActionsMenu } from "../../components/MediaActionsMenu";
107107import {
108 attachmentVideoPreviewSource,
108109 mediaVideoPreviewUri,
109110 mediaVideoThumbnailKey,
110111 type MediaVideoPreviewSource,
......@@ -251,14 +252,21 @@ function MessageAttachmentImage(props: {
251252 readonly environmentId: EnvironmentId;
252253 readonly attachmentId: string;
253254 readonly name: string;
255 readonly mimeType: string;
254256 readonly className: string;
255257 readonly onPressPreview: (source: FilePreviewSource) => void;
256258}) {
257259 const sourceIdentifier = useId();
258 const uri = useAssetUrl(props.environmentId, {
259 _tag: "attachment",
260 attachmentId: props.attachmentId,
261 });
260 const resource = useMemo(
261 () => ({
262 _tag: "attachment" as const,
263 attachmentId: props.attachmentId,
264 fileName: props.name,
265 mimeType: props.mimeType,
266 }),
267 [props.attachmentId, props.name, props.mimeType],
268 );
269 const uri = useAssetUrl(props.environmentId, resource);
262270
263271 if (uri === null) {
264272 return (
......@@ -274,7 +282,20 @@ function MessageAttachmentImage(props: {
274282 accessibilityRole="imagebutton"
275283 accessibilityLabel={`Open ${props.name}`}
276284 onPress={() =>
277 props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier })
285 // The viewer mints its own URL from the resource so the image survives a refresh.
286 props.onPressPreview({
287 kind: "image",
288 environmentId: props.environmentId,
289 resource,
290 name: props.name,
291 sourceIdentifier,
292 actionsSource: {
293 name: props.name,
294 mimeType: props.mimeType,
295 environmentId: props.environmentId,
296 resource,
297 },
298 })
278299 }
279300 >
280301 <Image source={{ uri }} className={props.className} resizeMode="cover" />
......@@ -389,14 +410,18 @@ function MessageAttachmentFile(props: {
389410 };
390411
391412 if (videoType !== null) {
413 const sourceIdentifier = `attachment:${props.environmentId}:${attachment.id}`;
392414 return (
393415 <VideoAttachmentTile
394416 name={attachment.name}
395 sourceIdentifier={`attachment:${props.environmentId}:${attachment.id}`}
417 sourceIdentifier={sourceIdentifier}
396418 thumbnailSource={thumbnailUrl}
419 actionsSource={
420 attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier)
421 .actionsSource
422 }
397423 disabled={opening || httpBaseUrl === null}
398424 onPress={(sourceIdentifier) => props.onPressVideo(attachment, sourceIdentifier)}
399 onShare={() => shareFile(`attachment:${props.environmentId}:${attachment.id}`)}
400425 className="my-1 rounded-2xl"
401426 style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }}
402427 />
......@@ -520,62 +545,60 @@ function ThreadMarkdownImageView(props: {
520545 style={{ alignSelf: "stretch", gap: 6 }}
521546 >
522547 {props.uri === null || failed ? (
523 <View
524 className="items-center justify-center rounded-[10px] bg-md-code-bg"
525 style={{
526 ...frameStyle,
527 }}
528 >
529 {failed ? (
530 <Text className="text-xs text-foreground-muted">Image unavailable</Text>
531 ) : (
532 <ActivityIndicator />
533 )}
534 {props.actionsSource ? (
535 <View className="absolute right-1 top-1">
536 <MediaActionsMenu media={mediaActions} />
537 </View>
538 ) : null}
539 </View>
548 <MediaActionsMenu media={mediaActions}>
549 <Pressable
550 accessibilityRole="imagebutton"
551 accessibilityLabel={props.alt ?? "Markdown image"}
552 accessibilityHint={
553 mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined
554 }
555 className="items-center justify-center rounded-[10px] bg-md-code-bg"
556 style={frameStyle}
557 >
558 {failed ? (
559 <Text className="text-xs text-foreground-muted">Image unavailable</Text>
560 ) : (
561 <ActivityIndicator />
562 )}
563 </Pressable>
564 </MediaActionsMenu>
540565 ) : (
541566 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>
542 <View>
543 <MediaActionsMenu media={mediaActions}>
544 <Pressable
545 accessibilityRole="imagebutton"
546 accessibilityLabel={props.alt ?? "Markdown image"}
547 onPress={() =>
548 props.onPressPreview({
549 kind: "image",
550 uri: props.uri!,
551 name: props.alt ?? "Image",
552 sourceIdentifier,
553 actionsSource: props.actionsSource,
554 })
555 }
556 style={{ alignSelf: "flex-start" }}
567 <MediaActionsMenu media={mediaActions}>
568 <Pressable
569 accessibilityRole="imagebutton"
570 accessibilityLabel={props.alt ?? "Markdown image"}
571 accessibilityHint={
572 mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined
573 }
574 onPress={() =>
575 // Quick Look picks the viewer from the name's extension, so it needs the
576 // file name rather than the alt text.
577 props.onPressPreview({
578 kind: "image",
579 uri: props.uri!,
580 name: props.actionsSource?.name ?? props.alt ?? "Image",
581 sourceIdentifier,
582 actionsSource: props.actionsSource,
583 })
584 }
585 style={{ alignSelf: "flex-start" }}
586 >
587 <View
588 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
589 style={{
590 ...frameStyle,
591 }}
557592 >
558 <View
559 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
560 style={{
561 ...frameStyle,
562 }}
563 >
564 <ThreadMarkdownImageRequest
565 key={props.uri}
566 uri={props.uri}
567 onLoad={setSourceSize}
568 onError={() => setFailedUri(props.uri)}
569 />
570 </View>
571 </Pressable>
572 </MediaActionsMenu>
573 {props.actionsSource ? (
574 <View className="absolute right-1 top-1">
575 <MediaActionsMenu media={mediaActions} />
593 <ThreadMarkdownImageRequest
594 key={props.uri}
595 uri={props.uri}
596 onLoad={setSourceSize}
597 onError={() => setFailedUri(props.uri)}
598 />
576599 </View>
577 ) : null}
578 </View>
600 </Pressable>
601 </MediaActionsMenu>
579602 </PresentationSource>
580603 )}
581604 {props.alt ? (
......@@ -658,10 +681,7 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) {
658681 return <ThreadMediaVisibleContext value={visible}>{props.children}</ThreadMediaVisibleContext>;
659682}
660683
661function ThreadMarkdownVideo(props: {
662 readonly source: MediaVideoPreviewSource;
663 readonly onExpand: (source: MediaVideoPreviewSource) => void;
664}) {
684function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }) {
665685 const { source } = props;
666686 const visible = useContext(ThreadMediaVisibleContext);
667687 const thumbnailKey = mediaVideoThumbnailKey(source);
......@@ -688,7 +708,6 @@ function ThreadMarkdownVideo(props: {
688708 thumbnailVisible={visible}
689709 unavailable={"resource" in source && asset._tag === "Failure"}
690710 actionsSource={source.actionsSource}
691 onExpand={() => props.onExpand(source)}
692711 />
693712 );
694713}
......@@ -1593,6 +1612,7 @@ function renderFeedEntry(
15931612 environmentId={props.environmentId}
15941613 attachmentId={attachment.id}
15951614 name={attachment.name}
1615 mimeType={attachment.mimeType}
15961616 className="aspect-[1.3] w-full rounded-[14px] bg-white/15"
15971617 onPressPreview={props.onPressPreview}
15981618 />
......@@ -1656,6 +1676,7 @@ function renderFeedEntry(
16561676 environmentId={props.environmentId}
16571677 attachmentId={attachment.id}
16581678 name={attachment.name}
1679 mimeType={attachment.mimeType}
16591680 className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800"
16601681 onPressPreview={props.onPressPreview}
16611682 />
......@@ -2218,7 +2239,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22182239 <ThreadMarkdownVideo
22192240 key={image.href}
22202241 source={{ ...media.source, name: image.alt ?? media.source.name }}
2221 onExpand={(source) => setExpandedVideo((current) => current ?? source)}
22222242 />
22232243 );
22242244 }
......@@ -2667,12 +2687,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26672687 (attachment: ChatFileAttachment, sourceIdentifier: string) => {
26682688 setExpandedVideo(
26692689 (current) =>
2670 current ?? {
2671 type: "remote",
2672 environmentId: props.environmentId,
2673 attachment,
2674 sourceIdentifier,
2675 },
2690 current ??
2691 attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier),
26762692 );
26772693 },
26782694 [props.environmentId],
apps/mobile/src/lib/markdownMedia.test.ts+11
......@@ -61,6 +61,17 @@ describe("resolveMarkdownMediaPreview", () => {
6161 });
6262 });
6363
64 it("serves a linked T3 attachment file in place like any other host path", () => {
65 const path = "/home/demo/.t3/userdata/attachments/11111111-1111-4111-8111-111111111111-mp4.mp4";
66 expect(resolveMarkdownMediaPreview(path, input)).toMatchObject({
67 kind: "video",
68 source: {
69 resource: { _tag: "media-file", threadId: input.threadId, path },
70 actionsSource: { resource: { _tag: "media-file", path } },
71 },
72 });
73 });
74
6475 it("resolves protocol-relative media for native APIs without rewriting its signed query", () => {
6576 expect(
6677 resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input),
apps/mobile/src/lib/markdownMedia.ts+13-53
......@@ -1,15 +1,6 @@
1import {
2 classifyMarkdownImageSource,
3 markdownImageSourceFragment,
4} from "@t3tools/client-runtime/markdown-images";
1import { resolveMediaSource } from "@t3tools/client-runtime/media-source";
52import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
63import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links";
7import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
8import {
9 mediaFileReference,
10 mediaReferenceFileName,
11 mediaUrlReference,
12} from "@t3tools/client-runtime/media-reference";
134
145import type { FilePreviewSource } from "../components/FilePreviewModal";
156import type { MediaVideoPreviewSource } from "./videoPreviewSource";
......@@ -29,61 +20,30 @@ export function resolveMarkdownMediaPreview(
2920 | { readonly kind: "image"; readonly source: FilePreviewSource }
3021 | { readonly kind: "video"; readonly source: MediaVideoPreviewSource }
3122 | null {
32 const classified = classifyMarkdownImageSource(href, input.workspaceRoot);
33 if (classified._tag === "Blocked") return null;
34 const path =
35 classified._tag === "WorkspaceFile"
36 ? classified.path.replace(/:\d+(?::\d+)?$/, "")
37 : classified.uri.split(/[?#]/, 1)[0]!;
38 const basename = path.split(/[\\/]/).at(-1) ?? "";
39 const extensionIndex = basename.lastIndexOf(".");
40 // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again.
41 const detectedMimeType =
42 classified._tag === "Direct"
43 ? mediaMimeType(classified.uri)
44 : extensionIndex < 0
45 ? null
46 : mediaMimeTypeFromExtension(basename.slice(extensionIndex));
47 const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null);
48 if (mimeType === null) return null;
49 const kind = mimeType.startsWith("video/") ? "video" : "image";
50 const reference =
51 classified._tag === "Direct"
52 ? mediaUrlReference(classified.uri)
53 : mediaFileReference(path, input.workspaceRoot);
54 const name =
55 (reference && mediaReferenceFileName(reference)) || (kind === "video" ? "Video" : "Image");
56 const srcFragment = markdownImageSourceFragment(href);
23 const media = resolveMediaSource(href, input);
24 if (media === null || media.access === "unavailable") return null;
25 const { kind, name, mimeType, reference, srcFragment } = media;
26
5727 const target =
58 classified._tag === "Direct"
59 ? { uri: normalizeNativeMarkdownUrl(classified.uri) }
28 media.access === "direct"
29 ? { uri: normalizeNativeMarkdownUrl(media.uri) }
6030 : {
6131 environmentId: input.environmentId,
62 resource: {
63 _tag: "media-file" as const,
64 threadId: input.threadId,
65 path,
66 },
32 resource: media.resource,
6733 ...(srcFragment ? { srcFragment } : {}),
6834 };
6935 const actionsSource: MediaActionsSource =
70 classified._tag === "Direct"
71 ? { reference, uri: classified.uri, name, mimeType }
36 media.access === "direct"
37 ? { reference, uri: media.uri, name, mimeType }
7238 : {
7339 reference,
7440 environmentId: input.environmentId,
7541 threadId: input.threadId,
76 resource: { _tag: "media-file", threadId: input.threadId, path },
42 resource: media.resource,
7743 name,
7844 mimeType,
7945 };
8046 return kind === "video"
81 ? {
82 kind,
83 source: { type: "media", name, mimeType, ...target, actionsSource },
84 }
85 : {
86 kind,
87 source: { kind, name, ...target, actionsSource },
88 };
47 ? { kind, source: { type: "media", name, mimeType, ...target, actionsSource } }
48 : { kind, source: { kind, name, ...target, actionsSource } };
8949}
apps/mobile/src/lib/mediaActions.ts+73-46
......@@ -1,4 +1,5 @@
11import { useNavigation } from "@react-navigation/native";
2import type { MediaActionId } from "@t3tools/client-runtime/media-actions";
23import type { MediaReference } from "@t3tools/client-runtime/media-reference";
34import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts";
45import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links";
......@@ -7,18 +8,23 @@ import { Alert } from "react-native";
78
89import { useRefreshAssetUrl } from "../state/assets";
910import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload";
11import type { DraftComposerFileAttachment } from "./composerImages";
1012import { copyTextWithHaptic } from "./copyTextWithHaptic";
13import { loadLocalAttachmentPreview } from "./localAttachmentPreview";
1114
1215/** Authored source metadata is kept separate from temporary preview/download URLs. */
1316export type MediaActionsSource = {
1417 readonly reference?: MediaReference;
1518 readonly name: string;
1619 readonly mimeType: string;
20 /** Anchors the iOS share sheet to the view that opened the menu. */
21 readonly sourceIdentifier?: string;
1722} & (
1823 | { readonly uri: string }
24 | { readonly attachment: DraftComposerFileAttachment }
1925 | {
2026 readonly environmentId: EnvironmentId;
21 readonly threadId: ThreadId;
27 readonly threadId?: ThreadId;
2228 readonly resource: AssetResource;
2329 }
2430);
......@@ -39,12 +45,23 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi
3945 controller.current = request;
4046 setSharing(true);
4147 void (async () => {
48 if ("attachment" in source) {
49 const preview = await loadLocalAttachmentPreview(source.attachment, request.signal);
50 if (!preview) return;
51 try {
52 await preview.share(request.signal, source.sourceIdentifier);
53 } finally {
54 preview.dispose();
55 }
56 return;
57 }
4258 const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh();
4359 if (request.signal.aborted) return;
4460 if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again.");
4561 const input = {
4662 attachment: { name: source.name, mimeType: source.mimeType },
4763 signal: request.signal,
64 sourceIdentifier: source.sourceIdentifier,
4865 };
4966 if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri });
5067 else await downloadAndShareAttachment({ ...input, url: uri });
......@@ -66,52 +83,62 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi
6683 };
6784
6885 const reference = source?.reference;
69 const actions: { id: string; title: string; run: () => void; disabled?: boolean }[] = source
70 ? [
71 ...(reference?.kind === "file"
72 ? [
73 {
74 id: "copy-path",
75 title: "Copy full path",
76 run: () => copyTextWithHaptic(reference.path),
77 },
78 ...(reference.relativePath
79 ? [
80 {
81 id: "copy-relative-path",
82 title: "Copy relative path",
83 run: () => copyTextWithHaptic(reference.relativePath!),
84 },
85 ]
86 : []),
87 ...(reference.relativePath && source && "environmentId" in source
88 ? [
89 {
90 id: "open-file",
91 title: "Open in file viewer",
92 run: () => {
93 onOpenFile?.();
94 navigation.navigate("ThreadFile", {
95 environmentId: String(source.environmentId),
96 threadId: String(source.threadId),
97 path: reference.relativePath!.split("/"),
98 });
99 },
100 },
101 ]
102 : []),
103 ]
104 : reference
105 ? [{ id: "copy-url", title: "Copy URL", run: () => copyTextWithHaptic(reference.url) }]
86 const relativePath = reference?.kind === "file" ? reference.relativePath : undefined;
87 const threadId = source && "threadId" in source ? source.threadId : undefined;
88 const actions: { id: MediaActionId; title: string; run: () => void; disabled?: boolean }[] =
89 source
90 ? [
91 ...(reference?.kind === "file"
92 ? [
93 {
94 id: "copy-full-path" as const,
95 title: "Copy full path",
96 run: () => copyTextWithHaptic(reference.path),
97 },
98 ]
99 : []),
100 ...(relativePath
101 ? [
102 {
103 id: "copy-relative-path" as const,
104 title: "Copy relative path",
105 run: () => copyTextWithHaptic(relativePath),
106 },
107 ]
108 : []),
109 ...(reference?.kind === "url"
110 ? [
111 {
112 id: "copy-url" as const,
113 title: "Copy URL",
114 run: () => copyTextWithHaptic(reference.url),
115 },
116 ]
117 : []),
118 ...(relativePath && "environmentId" in source && threadId !== undefined
119 ? [
120 {
121 id: "open-file" as const,
122 title: "Open in file viewer",
123 run: () => {
124 onOpenFile?.();
125 navigation.navigate("ThreadFile", {
126 environmentId: String(source.environmentId),
127 threadId: String(threadId),
128 path: relativePath.split("/"),
129 });
130 },
131 },
132 ]
106133 : []),
107 {
108 id: "share",
109 title: sharing ? "Opening share sheet…" : "Save or share",
110 run: share,
111 disabled: sharing,
112 },
113 ]
114 : [];
134 {
135 id: "save" as const,
136 title: sharing ? "Opening share sheet…" : "Save or share",
137 run: share,
138 disabled: sharing,
139 },
140 ]
141 : [];
115142 return {
116143 title: reference?.kind === "file" ? reference.path : reference?.url,
117144 actions,
apps/mobile/src/lib/videoPreviewSource.ts+46-17
......@@ -1,4 +1,5 @@
11import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts";
2import { videoMimeType } from "@t3tools/shared/video";
23
34import type { DraftComposerFileAttachment } from "./composerImages";
45import type { MediaActionsSource } from "./mediaActions";
......@@ -14,7 +15,7 @@ export type MediaVideoPreviewSource = {
1415 | { readonly uri: string }
1516 | {
1617 readonly environmentId: EnvironmentId;
17 readonly resource: Extract<AssetResource, { readonly _tag: "media-file" }>;
18 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>;
1819 }
1920);
2021
......@@ -32,23 +33,51 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string
3233 return JSON.stringify(
3334 "uri" in source
3435 ? ["media-video", source.uri]
35 : [
36 "media-video",
37 source.environmentId,
38 source.resource.threadId,
39 source.resource.path,
40 source.srcFragment ?? "",
41 ],
36 : source.resource._tag === "attachment"
37 ? ["media-video", source.environmentId, "attachment", source.resource.attachmentId]
38 : [
39 "media-video",
40 source.environmentId,
41 source.resource.threadId,
42 source.resource.path,
43 source.srcFragment ?? "",
44 ],
4245 );
4346}
4447
45export type AttachmentVideoPreviewSource = (
46 | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment }
47 | {
48 readonly type: "remote";
49 readonly environmentId: EnvironmentId;
50 readonly attachment: ChatFileAttachment;
51 }
52) & { readonly sourceIdentifier?: string };
48export interface LocalVideoPreviewSource {
49 readonly type: "local";
50 readonly attachment: DraftComposerFileAttachment;
51 readonly sourceIdentifier?: string;
52}
53
54export type VideoPreviewSource = LocalVideoPreviewSource | MediaVideoPreviewSource;
5355
54export type VideoPreviewSource = AttachmentVideoPreviewSource | MediaVideoPreviewSource;
56export function attachmentVideoPreviewSource(
57 environmentId: EnvironmentId,
58 attachment: ChatFileAttachment,
59 sourceIdentifier?: string,
60): MediaVideoPreviewSource {
61 const mimeType = videoMimeType(attachment) ?? attachment.mimeType;
62 const resource = {
63 _tag: "attachment" as const,
64 attachmentId: attachment.id,
65 fileName: attachment.name,
66 mimeType,
67 };
68 return {
69 type: "media",
70 name: attachment.name,
71 mimeType,
72 ...(sourceIdentifier ? { sourceIdentifier } : {}),
73 environmentId,
74 resource,
75 actionsSource: {
76 name: attachment.name,
77 mimeType,
78 ...(sourceIdentifier ? { sourceIdentifier } : {}),
79 environmentId,
80 resource,
81 },
82 };
83}
apps/mobile/src/state/assets.ts+17-23
......@@ -1,23 +1,20 @@
11import { useAtomValue } from "@effect/atom-react";
2import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
2import {
3 type AssetUrlState,
4 assetUrlStateFromResult,
5 createAssetEnvironmentAtoms,
6 EMPTY_ASSET_URL_ATOM,
7} from "@t3tools/client-runtime/state/assets";
38import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
4import { AsyncResult, Atom } from "effect/unstable/reactivity";
59import { useCallback } from "react";
610
711import { connectionAtomRuntime } from "../connection/runtime";
812import { usePreparedConnection } from "./session";
913import { useAtomQueryRunner } from "./use-atom-query-runner";
1014
11export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
12
13const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
14 Atom.withLabel("mobile-asset-url:empty"),
15);
15export type { AssetUrlState } from "@t3tools/client-runtime/state/assets";
1616
17export type AssetUrlState =
18 | { readonly _tag: "Loading" }
19 | { readonly _tag: "Failure" }
20 | { readonly _tag: "Success"; readonly url: string };
17export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
2118
2219export function useAssetUrlState(
2320 environmentId: EnvironmentId | null,
......@@ -29,14 +26,10 @@ export function useAssetUrlState(
2926 ? EMPTY_ASSET_URL_ATOM
3027 : assetEnvironment.createUrl({ environmentId, input: { resource } }),
3128 );
32 if (result._tag === "Failure") {
33 return { _tag: "Failure" };
34 }
35 if (preparedConnection._tag === "None" || result._tag !== "Success") {
36 return { _tag: "Loading" };
37 }
38 const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl);
39 return url === null ? { _tag: "Failure" } : { _tag: "Success", url };
29 return assetUrlStateFromResult(
30 result,
31 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
32 );
4033}
4134
4235export function useAssetUrl(
......@@ -60,9 +53,10 @@ export function useRefreshAssetUrl(
6053 });
6154 return useCallback(async () => {
6255 if (environmentId === null || resource === null || httpBaseUrl === null) return null;
63 const result = await createUrl({ environmentId, input: { resource } });
64 return result._tag === "Success"
65 ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl)
66 : null;
56 const state = assetUrlStateFromResult(
57 await createUrl({ environmentId, input: { resource } }),
58 httpBaseUrl,
59 );
60 return state._tag === "Success" ? state.url : null;
6761 }, [createUrl, environmentId, httpBaseUrl, resource]);
6862}
apps/web/src/assets/assetUrls.ts+24-35
......@@ -1,5 +1,10 @@
11import { useAtomValue } from "@effect/atom-react";
2import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
2import {
3 type AssetUrlState,
4 assetUrlStateFromResult,
5 EMPTY_ASSET_URL_ATOM,
6 resolveAssetUrl,
7} from "@t3tools/client-runtime/state/assets";
38import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
49import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
510import { AsyncResult } from "effect/unstable/reactivity";
......@@ -9,58 +14,42 @@ import { assetEnvironment } from "~/state/assets";
914import { usePreparedConnection } from "~/state/session";
1015import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
1116
12export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
13
14export type AssetUrlState =
15 | { readonly _tag: "Loading" }
16 | { readonly _tag: "Failure" }
17 | { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string };
17export { resolveAssetUrl, type AssetUrlState } from "@t3tools/client-runtime/state/assets";
1818
1919export function useAssetUrlState(
20 environmentId: EnvironmentId,
21 resource: AssetResource,
20 environmentId: EnvironmentId | null,
21 resource: AssetResource | null,
2222): AssetUrlState {
2323 const preparedConnection = usePreparedConnection(environmentId);
2424 const result = useAtomValue(
25 assetEnvironment.createUrl({
26 environmentId,
27 input: { resource },
28 }),
25 environmentId === null || resource === null
26 ? EMPTY_ASSET_URL_ATOM
27 : assetEnvironment.createUrl({ environmentId, input: { resource } }),
28 );
29 return assetUrlStateFromResult(
30 result,
31 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
2932 );
30 if (result._tag === "Failure") {
31 return { _tag: "Failure" };
32 }
33 if (preparedConnection._tag === "None" || result._tag !== "Success") {
34 return { _tag: "Loading" };
35 }
36 const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl);
37 return url === null
38 ? { _tag: "Failure" }
39 : {
40 _tag: "Success",
41 url,
42 ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}),
43 };
4433}
4534
46export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null {
35export function useAssetUrl(
36 environmentId: EnvironmentId | null,
37 resource: AssetResource | null,
38): string | null {
4739 const result = useAssetUrlState(environmentId, resource);
48 if (result._tag !== "Success") {
49 return null;
50 }
51 return result.url;
40 return result._tag === "Success" ? result.url : null;
5241}
5342
54/** Re-mints an exact-file capability after a file change or an explicit retry. */
5543export function useAssetUrlRefresh(
56 environmentId: EnvironmentId,
57 resource: AssetResource,
44 environmentId: EnvironmentId | null,
45 resource: AssetResource | null,
5846): () => Promise<void> {
5947 const refresh = useAtomQueryRunner(assetEnvironment.createUrl, {
6048 reportFailure: false,
6149 refresh: true,
6250 });
6351 return useCallback(async () => {
52 if (environmentId === null || resource === null) return;
6453 const result = await refresh({ environmentId, input: { resource } });
6554 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
6655 }, [environmentId, resource, refresh]);
apps/web/src/components/ChatMarkdown.tsx-24
......@@ -1274,7 +1274,6 @@ function ChatMarkdownVideo(props: {
12741274 readonly mediaIdentity?: string | undefined;
12751275 readonly actionsSource?: MediaActionSource | undefined;
12761276 readonly onRetry?: (() => Promise<void>) | undefined;
1277 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
12781277}) {
12791278 return (
12801279 <MediaVideoPlayer
......@@ -1296,27 +1295,6 @@ function ChatMarkdownVideo(props: {
12961295 )}
12971296 onRetry={props.onRetry}
12981297 actionsSource={props.actionsSource}
1299 onExpand={
1300 props.onImageExpand
1301 ? (src) => {
1302 props.onImageExpand?.({
1303 images: [
1304 {
1305 src,
1306 name: props.alt || "video",
1307 type: "video",
1308 autoPlay: false,
1309 ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}),
1310 ...(props.actionsSource
1311 ? { actionsSource: { ...props.actionsSource, src } }
1312 : {}),
1313 },
1314 ],
1315 index: 0,
1316 });
1317 }
1318 : undefined
1319 }
13201298 />
13211299 );
13221300}
......@@ -1378,7 +1356,6 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
13781356 style={props.style}
13791357 mediaIdentity={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}
13801358 onRetry={refreshAssetUrl}
1381 onImageExpand={props.onImageExpand}
13821359 actionsSource={actionsSource}
13831360 />
13841361 );
......@@ -2642,7 +2619,6 @@ function ChatMarkdown({
26422619 copyMarkdown={copyMarkdown}
26432620 originalUrl={originalUrl}
26442621 style={authoredSizeStyle}
2645 onImageExpand={imageExpand}
26462622 actionsSource={actionsSource}
26472623 />
26482624 );
apps/web/src/components/ChatView.logic.test.ts-9
......@@ -24,7 +24,6 @@ import {
2424 dismissBranchMismatchForSession,
2525 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
2626 getStartedThreadModelChangeBlockReason,
27 isVideoPreviewRequestCurrent,
2827 hasEnvironmentReconnectWarningGraceElapsed,
2928 hasServerAcknowledgedLocalDispatch,
3029 isBranchMismatchDismissedForSession,
......@@ -130,14 +129,6 @@ describe("proactive panels", () => {
130129 });
131130});
132131
133describe("isVideoPreviewRequestCurrent", () => {
134 it("rejects changed threads and replaced previews", () => {
135 expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false);
136 expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 1, 2)).toBe(false);
137 expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 2, 2)).toBe(true);
138 });
139});
140
141132describe("toolGroupConsumesUpwardNavigation", () => {
142133 class ScrollElement extends EventTarget {
143134 scrollTop = 0;
apps/web/src/components/ChatView.logic.ts-9
......@@ -399,15 +399,6 @@ export async function resolveFileAttachmentUrl(input: {
399399 return url;
400400}
401401
402export function isVideoPreviewRequestCurrent(
403 requestThreadKey: string,
404 currentThreadKey: string,
405 requestId: number,
406 currentRequestId: number,
407): boolean {
408 return requestThreadKey === currentThreadKey && requestId === currentRequestId;
409}
410
411402export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
412403 if (message.role !== "user" || !message.attachments) {
413404 return;
apps/web/src/components/ChatView.tsx+12-53
......@@ -135,7 +135,6 @@ import {
135135 type ChatMessage,
136136 isBrowserPreviewAttachment,
137137 isImageAttachment,
138 videoMimeType,
139138 type SessionPhase,
140139 type Thread,
141140 type TurnDiffSummary,
......@@ -300,7 +299,7 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";
300299import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
301300import { ChatHeader } from "./chat/ChatHeader";
302301import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
303import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
302import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
304303import { NoActiveThreadState } from "./NoActiveThreadState";
305304import { WorkspacePageHeader } from "./WorkspacePageHeader";
306305import {
......@@ -375,7 +374,6 @@ import {
375374 deriveLockedProvider,
376375 readFileAsDataUrl,
377376 resolveFileAttachmentUrl,
378 isVideoPreviewRequestCurrent,
379377 reconcileMountedTerminalThreadIds,
380378 resolveBackgroundDraftWorkspaceOptions,
381379 resolveDraftHeroState,
......@@ -1509,19 +1507,13 @@ function ChatViewContent(props: ChatViewProps) {
15091507 [composerRef],
15101508 );
15111509 const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);
1512 const routeThreadKeyRef = useRef(routeThreadKey);
1513 routeThreadKeyRef.current = routeThreadKey;
1514 const videoPreviewRequestIdRef = useRef(0);
1515 const cancelVideoPreviewRequest = useCallback(() => {
1516 videoPreviewRequestIdRef.current += 1;
1517 }, []);
1518 const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState<string | null>(null);
15191510 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
15201511 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
15211512 useEffect(() => {
15221513 const item = expandedImage?.images[expandedImage.index];
1523 if (item?.type !== "video" || !item.src.startsWith("blob:")) return;
1524 return () => revokeBlobPreviewUrl(item.src);
1514 if (item?.type !== "video" || item.src === null || !item.src.startsWith("blob:")) return;
1515 const src = item.src;
1516 return () => revokeBlobPreviewUrl(src);
15251517 }, [expandedImage]);
15261518 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
15271519 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
......@@ -2575,12 +2567,11 @@ function ChatViewContent(props: ChatViewProps) {
25752567 useEffect(() => {
25762568 return () => {
25772569 clearAttachmentPreviewHandoffs();
2578 cancelVideoPreviewRequest();
25792570 for (const message of optimisticUserMessagesRef.current) {
25802571 revokeUserMessagePreviewUrls(message);
25812572 }
25822573 };
2583 }, [cancelVideoPreviewRequest, clearAttachmentPreviewHandoffs]);
2574 }, [clearAttachmentPreviewHandoffs]);
25842575 const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
25852576 if (previewUrls.length === 0) return;
25862577
......@@ -2608,18 +2599,6 @@ function ChatViewContent(props: ChatViewProps) {
26082599 toastManager.add({ type: "error", title: "The environment is not connected." });
26092600 return;
26102601 }
2611 const isVideo = videoMimeType(attachment) !== null;
2612 const action = isVideo ? "play" : "download";
2613 const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0;
2614 const isCurrentRequest = () =>
2615 !isVideo ||
2616 isVideoPreviewRequestCurrent(
2617 routeThreadKey,
2618 routeThreadKeyRef.current,
2619 videoPreviewRequestId,
2620 videoPreviewRequestIdRef.current,
2621 );
2622 if (isVideo) setOpeningVideoAttachmentId(attachment.id);
26232602
26242603 try {
26252604 const url = await resolveFileAttachmentUrl({
......@@ -2628,30 +2607,19 @@ function ChatViewContent(props: ChatViewProps) {
26282607 httpBaseUrl: connection.httpBaseUrl,
26292608 createAssetUrl: createAttachmentAssetUrl,
26302609 });
2631 if (!isCurrentRequest()) return;
2632 if (isVideo) {
2633 setExpandedImage({
2634 images: [{ src: url, name: attachment.name, type: "video" }],
2635 index: 0,
2636 });
2637 return;
2638 }
26392610 const anchor = document.createElement("a");
26402611 anchor.href = url;
26412612 anchor.download = attachment.name;
26422613 anchor.click();
26432614 } catch (error) {
2644 if (!isCurrentRequest()) return;
26452615 toastManager.add({
26462616 type: "error",
2647 title: "Could not " + action + " " + attachment.name,
2617 title: "Could not download " + attachment.name,
26482618 description: error instanceof Error ? error.message : "The attachment is unavailable.",
26492619 });
2650 } finally {
2651 if (isVideo && isCurrentRequest()) setOpeningVideoAttachmentId(null);
26522620 }
26532621 },
2654 [createAttachmentAssetUrl, environmentId, routeThreadKey],
2622 [createAttachmentAssetUrl, environmentId],
26552623 );
26562624 const openFileAttachment = useCallback(
26572625 (attachment: ChatFileAttachment) => {
......@@ -4690,10 +4658,8 @@ function ChatViewContent(props: ChatViewProps) {
46904658 return [];
46914659 });
46924660 resetLocalDispatch();
4693 cancelVideoPreviewRequest();
4694 setOpeningVideoAttachmentId(null);
46954661 setExpandedImage(null);
4696 }, [cancelVideoPreviewRequest, draftId, resetLocalDispatch, threadId]);
4662 }, [draftId, resetLocalDispatch, threadId]);
46974663
46984664 const closeExpandedImage = useCallback(() => {
46994665 setExpandedImage(null);
......@@ -7188,14 +7154,9 @@ function ChatViewContent(props: ChatViewProps) {
71887154 }
71897155 };
71907156
7191 const onExpandTimelineImage = useCallback(
7192 (preview: ExpandedImagePreview) => {
7193 cancelVideoPreviewRequest();
7194 setOpeningVideoAttachmentId(null);
7195 setExpandedImage(preview);
7196 },
7197 [cancelVideoPreviewRequest],
7198 );
7157 const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
7158 setExpandedImage(preview);
7159 }, []);
71997160 const onOpenTurnDiff = useCallback(
72007161 (turnId: TurnId, filePath?: string) => {
72017162 if (!isServerThread || !activeThreadRef) return;
......@@ -7525,7 +7486,6 @@ function ChatViewContent(props: ChatViewProps) {
75257486 onImageExpand={onExpandTimelineImage}
75267487 onFileOpen={openFileAttachment}
75277488 onFileDownload={downloadFileAttachment}
7528 openingVideoAttachmentId={openingVideoAttachmentId}
75297489 markdownCwd={gitCwd ?? undefined}
75307490 resolvedTheme={resolvedTheme}
75317491 timestampFormat={timestampFormat}
......@@ -7701,7 +7661,6 @@ function ChatViewContent(props: ChatViewProps) {
77017661 setThreadError={setThreadError}
77027662 onExpandImage={onExpandTimelineImage}
77037663 onFileOpen={openFileAttachment}
7704 openingVideoAttachmentId={openingVideoAttachmentId}
77057664 />
77067665 </div>
77077666 </ComposerSurface.Host>
......@@ -7922,7 +7881,7 @@ function ChatViewContent(props: ChatViewProps) {
79227881
79237882 {expandedImage && (
79247883 <ExpandedImageDialog
7925 key={`${expandedImage.images[expandedImage.index]?.src ?? "image"}:${expandedImage.index}`}
7884 key={expandedImageKey(expandedImage)}
79267885 preview={expandedImage}
79277886 onClose={closeExpandedImage}
79287887 />
apps/web/src/components/chat/ChatComposer.tsx+3-13
......@@ -741,7 +741,6 @@ export interface ChatComposerProps {
741741 setThreadError: (threadId: ThreadId | null, error: string | null) => void;
742742 onExpandImage: (preview: ExpandedImagePreview) => void;
743743 onFileOpen: (attachment: ChatFileAttachment) => void;
744 openingVideoAttachmentId: string | null;
745744}
746745
747746// --------------------------------------------------------------------------
......@@ -820,7 +819,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
820819 setThreadError,
821820 onExpandImage,
822821 onFileOpen,
823 openingVideoAttachmentId,
824822 } = props;
825823 const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null;
826824 const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null;
......@@ -4020,7 +4018,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
40204018 maxFileAttachmentBytes !== null &&
40214019 file.sizeBytes <= maxFileAttachmentBytes;
40224020 const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined;
4023 const isOpening = file.uploadedAttachmentId === openingVideoAttachmentId;
40244021 return (
40254022 <div
40264023 key={file.id}
......@@ -4028,12 +4025,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
40284025 >
40294026 <button
40304027 type="button"
4031 className="flex h-full w-full cursor-zoom-in flex-col items-center justify-center gap-1 px-1 text-white aria-disabled:cursor-default aria-disabled:opacity-50"
4032 aria-busy={isOpening || undefined}
4033 aria-disabled={isOpening || undefined}
4034 aria-label={`${isOpening ? "Loading" : "Play"} ${file.name}`}
4028 className="flex h-full w-full cursor-zoom-in flex-col items-center justify-center gap-1 px-1 text-white"
4029 aria-label={`Play ${file.name}`}
40354030 onClick={() => {
4036 if (isOpening) return;
40374031 if (file.file !== null) {
40384032 const preview = buildExpandedImagePreview([file], file.id);
40394033 if (preview) onExpandImage(preview);
......@@ -4049,11 +4043,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
40494043 <span className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-black/10" />
40504044 </>
40514045 )}
4052 {isOpening ? (
4053 <span className="relative z-10 text-[10px]">Loading…</span>
4054 ) : (
4055 <PlayIcon className="relative z-10 size-4 fill-current drop-shadow-md" />
4056 )}
4046 <PlayIcon className="relative z-10 size-4 fill-current drop-shadow-md" />
40574047 </button>
40584048 {upload?.status === "uploading" && (
40594049 <span className="pointer-events-none absolute inset-x-0 bottom-0 bg-background/85 px-1 text-center text-[10px] text-foreground">
apps/web/src/components/chat/ExpandedImageDialog.tsx+35-25
......@@ -2,11 +2,12 @@ import { memo, useCallback, useEffect, useState, type ReactNode } from "react";
22import { createPortal } from "react-dom";
33import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react";
44import { Button } from "../ui/button";
5import type { ExpandedImagePreview } from "./ExpandedImagePreview";
6import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
5import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview";
76import { resolveExternalWebLinkHost } from "./externalLinkContextMenu";
7import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls";
88import { OpenMediaLink } from "../media/OpenMediaLink";
99import { MediaActions, type MediaActionSource } from "../media/MediaActions";
10import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
1011import { isContextMenuOpen } from "../../contextMenuFallback";
1112
1213interface ExpandedImageDialogProps {
......@@ -14,23 +15,47 @@ interface ExpandedImageDialogProps {
1415 onClose: () => void;
1516}
1617
18const EXPANDED_MEDIA_STATE_CLASS_NAME =
19 "flex aspect-auto h-48 min-h-0 w-[min(92vw,32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black p-6 text-center text-sm text-white shadow-2xl";
20
1721function ExpandedMediaFailure({ children }: { children: ReactNode }) {
1822 return (
19 <div
20 role="alert"
21 className="flex h-48 w-[min(92vw,32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black px-6 text-center text-sm text-white shadow-2xl"
22 >
23 <div role="alert" className={EXPANDED_MEDIA_STATE_CLASS_NAME}>
2324 {children}
2425 </div>
2526 );
2627}
2728
29function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) {
30 const asset = item.actionsSource?.asset;
31 const assetUrl = useAssetUrlState(asset?.environmentId ?? null, asset?.resource ?? null);
32 const refreshAssetUrl = useAssetUrlRefresh(asset?.environmentId ?? null, asset?.resource ?? null);
33 const src = asset
34 ? assetUrl._tag === "Success"
35 ? assetUrl.url + (item.srcFragment ?? "")
36 : null
37 : item.src;
38 return (
39 <MediaVideoPlayer
40 src={src}
41 label={item.name}
42 sourceFailed={assetUrl._tag === "Failure"}
43 originalUrl={item.originalUrl}
44 preload="metadata"
45 autoPlay={item.autoPlay ?? true}
46 className="block max-h-[86vh] max-w-[92vw] text-center"
47 videoClassName="aspect-auto max-h-[86vh] w-auto max-w-[92vw] rounded-lg border border-border/70 shadow-2xl"
48 stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME}
49 onRetry={asset ? refreshAssetUrl : undefined}
50 />
51 );
52}
53
2854export const ExpandedImageDialog = memo(function ExpandedImageDialog({
2955 preview,
3056 onClose,
3157}: ExpandedImageDialogProps) {
3258 const [imageOffset, setImageOffset] = useState(0);
33 const [failedVideoSrc, setFailedVideoSrc] = useState<string | null>(null);
3459 const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null);
3560 const index = (preview.index + imageOffset + preview.images.length) % preview.images.length;
3661 const item = preview.images[index];
......@@ -125,24 +150,9 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
125150 >
126151 <XIcon />
127152 </Button>
128 {item.type === "video" && failedVideoSrc === item.src ? (
129 <ExpandedMediaFailure>
130 <p>This video could not be loaded or played.</p>
131 <OpenMediaLink originalUrl={item.originalUrl} src={item.src} fileName={item.name} />
132 </ExpandedMediaFailure>
133 ) : item.type === "video" ? (
134 <video
135 src={item.src}
136 aria-label={item.name}
137 autoPlay={item.autoPlay ?? true}
138 preload="metadata"
139 controls
140 playsInline
141 onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)}
142 onError={() => setFailedVideoSrc(item.src)}
143 className="max-h-[86vh] max-w-[92vw] rounded-lg border border-border/70 bg-black object-contain shadow-2xl"
144 />
145 ) : failedImageSrc === item.src ? (
153 {item.type === "video" ? (
154 <ExpandedVideo key={index} item={item} />
155 ) : item.src === null || failedImageSrc === item.src ? (
146156 <ExpandedMediaFailure>
147157 <p>
148158 {openOriginalLink
apps/web/src/components/chat/ExpandedImagePreview.tsx+45-45
......@@ -3,31 +3,30 @@ import { type ChatImageAttachment, isVideoAttachment } from "../../types";
33import type {
44 AssetCreateUrlResult,
55 AssetResource,
6 ChatFileAttachment,
67 EnvironmentId,
78 ScopedThreadRef,
89} from "@t3tools/contracts";
9import {
10 classifyMarkdownImageSource,
11 markdownImageSourceFragment,
12} from "@t3tools/client-runtime/markdown-images";
13import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
10import { videoMimeType } from "@t3tools/shared/video";
11import { resolveMediaSource } from "@t3tools/client-runtime/media-source";
1412import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
1513import {
1614 squashAtomCommandFailure,
1715 type AtomCommandResult,
1816} from "@t3tools/client-runtime/state/runtime";
19import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
2017import { resolveExternalWebLinkHost } from "./externalLinkContextMenu";
2118import type { MediaActionSource } from "../media/MediaActions";
2219import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent";
2320
2421export interface ExpandedImageItem {
25 src: string;
22 /** A loadable URL, or null when the dialog must mint one from `asset` first. */
23 src: string | null;
2624 name: string;
2725 type?: "video";
2826 autoPlay?: boolean;
2927 /** Authored remote destination to open when embedding fails, never a generated asset URL. */
3028 originalUrl?: string;
29 srcFragment?: string;
3130 actionsSource?: MediaActionSource;
3231}
3332
......@@ -49,45 +48,24 @@ export async function resolveMarkdownMediaPreview(input: {
4948 input: { resource: AssetResource };
5049 }) => Promise<AtomCommandResult<AssetCreateUrlResult, unknown>>;
5150}): Promise<ExpandedImagePreview | null> {
52 const source =
53 input.resolvedFilePath === undefined
54 ? classifyMarkdownImageSource(input.source, input.cwd)
55 : { _tag: "WorkspaceFile" as const, path: input.resolvedFilePath };
56 if (source._tag === "Blocked") return null;
57
58 const path =
59 source._tag === "Direct"
60 ? source.uri.split(/[?#]/, 1)[0]!
61 : source.path.replace(/:\d+(?::\d+)?$/, "");
62 const name = path.split(/[\\/]/).at(-1) ?? "";
63 const extensionIndex = name.lastIndexOf(".");
64 const fileMimeType =
65 extensionIndex < 0 ? null : mediaMimeTypeFromExtension(name.slice(extensionIndex));
66 const kind =
67 source._tag === "Direct"
68 ? mediaKindFromPath(source.uri)
69 : fileMimeType === null
70 ? null
71 : fileMimeType.startsWith("video/")
72 ? "video"
73 : "image";
74 if (kind === null) return null;
75
76 const reference =
77 source._tag === "Direct" ? mediaUrlReference(source.uri) : mediaFileReference(path, input.cwd);
51 const media = resolveMediaSource(input.source, {
52 threadId: input.threadRef?.threadId,
53 workspaceRoot: input.cwd,
54 resolvedFilePath: input.resolvedFilePath,
55 });
56 if (media === null) return null;
57 const { kind, name, reference } = media;
7858 const relativePath = reference?.kind === "file" ? reference.relativePath : undefined;
59
7960 let src: string;
8061 let asset: MediaActionSource["asset"];
81 if (source._tag === "Direct") {
82 src = resolveProtocolRelativeMediaUrl(source.uri);
62 if (media.access === "direct") {
63 src = resolveProtocolRelativeMediaUrl(media.uri);
8364 } else {
84 if (!input.threadRef || !input.httpBaseUrl) {
65 if (media.access === "unavailable" || !input.threadRef || !input.httpBaseUrl) {
8566 throw new Error("Reconnect to this environment and open the media again.");
8667 }
87 asset = {
88 environmentId: input.threadRef.environmentId,
89 resource: { _tag: "media-file", threadId: input.threadRef.threadId, path },
90 };
68 asset = { environmentId: input.threadRef.environmentId, resource: media.resource };
9169 const result = await input.createAssetUrl({
9270 environmentId: asset.environmentId,
9371 input: { resource: asset.resource },
......@@ -95,20 +73,21 @@ export async function resolveMarkdownMediaPreview(input: {
9573 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
9674 const assetUrl = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl);
9775 if (assetUrl === null) throw new Error("The environment returned an invalid media URL.");
98 src = assetUrl + markdownImageSourceFragment(input.source);
76 src = assetUrl + media.srcFragment;
9977 }
10078 return {
10179 images: [
10280 {
10381 src,
104 name: name || kind,
82 name,
10583 ...(kind === "video" ? { type: "video", autoPlay: false } : {}),
106 ...(source._tag === "Direct" && resolveExternalWebLinkHost(source.uri) !== null
107 ? { originalUrl: source.uri }
84 ...(media.access === "direct" && resolveExternalWebLinkHost(media.uri) !== null
85 ? { originalUrl: media.uri }
10886 : {}),
87 ...(media.srcFragment ? { srcFragment: media.srcFragment } : {}),
10988 actionsSource: {
11089 kind,
111 name: name || kind,
90 name,
11291 src,
11392 ...(reference ? { reference } : {}),
11493 ...(asset ? { asset } : {}),
......@@ -122,6 +101,27 @@ export async function resolveMarkdownMediaPreview(input: {
122101 };
123102}
124103
104export function buildAttachmentVideoAsset(
105 environmentId: EnvironmentId,
106 attachment: ChatFileAttachment,
107): NonNullable<MediaActionSource["asset"]> {
108 return {
109 environmentId,
110 resource: {
111 _tag: "attachment" as const,
112 attachmentId: attachment.id,
113 fileName: attachment.name,
114 mimeType: videoMimeType(attachment) ?? attachment.mimeType,
115 },
116 };
117}
118
119export function expandedImageKey(preview: ExpandedImagePreview): string {
120 const item = preview.images[preview.index];
121 const asset = item?.actionsSource?.asset;
122 return `${item?.src ?? (asset ? JSON.stringify([asset.environmentId, asset.resource]) : "image")}:${preview.index}`;
123}
124
125125export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void {
126126 const url = URL.createObjectURL(file);
127127 video.src = url;
apps/web/src/components/chat/MessagesTimeline.test.tsx+31-16
......@@ -147,7 +147,6 @@ function buildProps() {
147147 revertTurnCountByUserMessageId: new Map(),
148148 onRevertUserMessage: () => {},
149149 isRevertingCheckpoint: false,
150 openingVideoAttachmentId: null,
151150 onImageExpand: () => {},
152151 activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID,
153152 markdownCwd: undefined,
......@@ -465,7 +464,7 @@ describe("MessagesTimeline", () => {
465464 expect(markup).not.toContain('alt="report.pdf"');
466465 });
467466
468 it("renders video attachments as play buttons", () => {
467 it("renders video attachments with the shared video player", () => {
469468 const entry = {
470469 ...buildUserTimelineEntry("Watch the demo."),
471470 message: {
......@@ -477,6 +476,7 @@ describe("MessagesTimeline", () => {
477476 name: "demo.mp4",
478477 mimeType: "video/mp4",
479478 sizeBytes: 42,
479 previewUrl: "https://environment.test/api/assets/demo.mp4",
480480 },
481481 ],
482482 },
......@@ -485,22 +485,37 @@ describe("MessagesTimeline", () => {
485485 const markup = renderToStaticMarkup(
486486 <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
487487 );
488 const busyMarkup = renderToStaticMarkup(
489 <MessagesTimeline
490 {...buildProps()}
491 timelineEntries={[entry]}
492 openingVideoAttachmentId="attachment-demo-mp4"
493 />,
488
489 expect(markup).toContain("<video");
490 expect(markup).toContain('aria-label="demo.mp4"');
491 expect(markup).toContain('controls=""');
492 expect(markup).not.toContain("Expand demo.mp4");
493 });
494
495 it("shows the filename while an optimistic video is unavailable", () => {
496 const entry = {
497 ...buildUserTimelineEntry("Uploading the demo."),
498 message: {
499 ...buildUserTimelineEntry("Uploading the demo.").message,
500 attachments: [
501 {
502 type: "file" as const,
503 id: "optimistic-demo-mp4",
504 name: "pending-demo.mp4",
505 mimeType: "video/mp4",
506 sizeBytes: 42,
507 downloadable: false,
508 },
509 ],
510 },
511 };
512
513 const markup = renderToStaticMarkup(
514 <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
494515 );
495516
496 expect(markup).toContain('aria-label="Play demo.mp4"');
497 expect(markup).toContain("min-h-[72px]");
498 expect(markup).toContain(">demo.mp4</span>");
499 expect(markup).not.toContain('aria-label="Download demo.mp4"');
500 expect(busyMarkup).toContain('aria-busy="true"');
501 expect(busyMarkup).toContain('aria-disabled="true"');
502 expect(busyMarkup).not.toContain('disabled=""');
503 expect(busyMarkup).toContain(">Loading…</span>");
517 expect(markup).not.toContain("<video");
518 expect(markup).toContain(">pending-demo.mp4</div>");
504519 });
505520 it("renders an ordinary file download button without creating its URL in advance", () => {
506521 const entry = {
apps/web/src/components/chat/MessagesTimeline.tsx+53-40
......@@ -1,6 +1,5 @@
11import {
22 type AssistantCitation,
3 type ChatFileAttachment,
43 type EnvironmentId,
54 type MessageId,
65 type ScopedThreadRef,
......@@ -50,6 +49,7 @@ import {
5049 workLogEntryIsToolLike,
5150} from "../../session-logic";
5251import {
52 type ChatFileAttachment,
5353 type ChatImageAttachment,
5454 isBrowserPreviewAttachment,
5555 isFileAttachment,
......@@ -79,7 +79,6 @@ import {
7979 MessageCircleIcon,
8080 MousePointerClickIcon,
8181 PaintbrushIcon,
82 PlayIcon,
8382 SearchIcon,
8483 SquarePenIcon,
8584 TerminalIcon,
......@@ -89,8 +88,14 @@ import {
8988 ZapIcon,
9089} from "lucide-react";
9190import { Button } from "../ui/button";
91import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls";
92import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
9293import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
93import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
94import {
95 buildAttachmentVideoAsset,
96 buildExpandedImagePreview,
97 ExpandedImagePreview,
98} from "./ExpandedImagePreview";
9499import { ProposedPlanCard } from "./ProposedPlanCard";
95100import { ChangedFilesCard } from "./ChangedFilesTree";
96101import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
......@@ -188,7 +193,6 @@ interface TimelineRowSharedState {
188193 onImageExpand: (preview: ExpandedImagePreview) => void;
189194 onFileOpen: (attachment: ChatFileAttachment) => void;
190195 onFileDownload: (attachment: ChatFileAttachment) => void;
191 openingVideoAttachmentId: string | null;
192196 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
193197 onToggleTurnFold: (turnId: TurnId) => void;
194198 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
......@@ -289,7 +293,6 @@ interface MessagesTimelineProps {
289293 onImageExpand: (preview: ExpandedImagePreview) => void;
290294 onFileOpen?: (attachment: ChatFileAttachment) => void;
291295 onFileDownload?: (attachment: ChatFileAttachment) => void;
292 openingVideoAttachmentId: string | null;
293296 activeThreadEnvironmentId: EnvironmentId;
294297 markdownCwd: string | undefined;
295298 resolvedTheme: "light" | "dark";
......@@ -341,7 +344,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
341344 onImageExpand,
342345 onFileOpen = NOOP_OPEN_ATTACHMENT,
343346 onFileDownload = NOOP_OPEN_ATTACHMENT,
344 openingVideoAttachmentId,
345347 activeThreadEnvironmentId,
346348 markdownCwd,
347349 resolvedTheme,
......@@ -638,7 +640,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
638640 onImageExpand,
639641 onFileOpen,
640642 onFileDownload,
641 openingVideoAttachmentId,
642643 onOpenTurnDiff,
643644 onToggleTurnFold,
644645 onToggleWorkGroup,
......@@ -663,7 +664,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
663664 onImageExpand,
664665 onFileOpen,
665666 onFileDownload,
666 openingVideoAttachmentId,
667667 onOpenTurnDiff,
668668 onToggleTurnFold,
669669 onToggleWorkGroup,
......@@ -1127,6 +1127,45 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
11271127 );
11281128});
11291129
1130function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) {
1131 const ctx = use(TimelineRowCtx);
1132 const asset = useMemo(
1133 () =>
1134 file.downloadable === false
1135 ? null
1136 : buildAttachmentVideoAsset(ctx.activeThreadEnvironmentId, file),
1137 [ctx.activeThreadEnvironmentId, file.downloadable, file.id, file.mimeType, file.name],
1138 );
1139 const resource = asset?.resource ?? null;
1140 const assetUrl = useAssetUrlState(ctx.activeThreadEnvironmentId, resource);
1141 const refreshAssetUrl = useAssetUrlRefresh(ctx.activeThreadEnvironmentId, resource);
1142 const src = assetUrl._tag === "Success" ? assetUrl.url : (file.previewUrl ?? null);
1143
1144 if (asset === null && src === null) {
1145 return (
1146 <div className="flex aspect-[4/3] w-full items-center justify-center rounded-lg border border-border/80 bg-black px-2 py-3 text-center text-[11px] text-white/70">
1147 {file.name}
1148 </div>
1149 );
1150 }
1151
1152 return (
1153 <MediaVideoPlayer
1154 src={src}
1155 sourceFailed={
1156 file.previewUrl === undefined && resource !== null && assetUrl._tag === "Failure"
1157 }
1158 label={file.name}
1159 preload="visible"
1160 className="block aspect-[4/3] w-full"
1161 videoClassName="aspect-auto size-full rounded-lg border border-border/80"
1162 stateClassName="aspect-auto min-h-full rounded-lg border border-border/80 bg-black text-white"
1163 onRetry={asset ? refreshAssetUrl : undefined}
1164 actionsSource={asset ? { kind: "video", name: file.name, src, asset } : undefined}
1165 />
1166 );
1167}
1168
11301169function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
11311170 const ctx = use(TimelineRowCtx);
11321171 // The attachment union has an open member, so guards (not literal type
......@@ -1165,12 +1204,12 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
11651204 {regularImages.map((image) => (
11661205 <div
11671206 key={image.id}
1168 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
1207 className="aspect-[4/3] overflow-hidden rounded-lg border border-border/80 bg-background/70"
11691208 >
11701209 {image.previewUrl ? (
11711210 <button
11721211 type="button"
1173 className="h-full w-full cursor-zoom-in"
1212 className="block h-full w-full cursor-zoom-in"
11741213 aria-label={`Preview ${image.name}`}
11751214 onClick={() => {
11761215 const preview = buildExpandedImagePreview(regularImages, image.id);
......@@ -1181,7 +1220,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
11811220 <img
11821221 src={image.previewUrl}
11831222 alt={image.name}
1184 className="block h-auto max-h-[220px] w-full object-cover"
1223 className="block size-full object-cover"
11851224 />
11861225 </button>
11871226 ) : (
......@@ -1191,35 +1230,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
11911230 )}
11921231 </div>
11931232 ))}
1194 {userVideos.map((file) => {
1195 const isOpening = ctx.openingVideoAttachmentId === file.id;
1196 return (
1197 <div
1198 key={file.id}
1199 className="overflow-hidden rounded-lg border border-border/80 bg-black"
1200 >
1201 <button
1202 type="button"
1203 disabled={file.downloadable === false}
1204 className="flex min-h-[72px] w-full cursor-zoom-in flex-col items-center justify-center gap-1 px-2 py-2 text-white disabled:cursor-default disabled:opacity-50 aria-disabled:cursor-default aria-disabled:opacity-50"
1205 aria-busy={isOpening || undefined}
1206 aria-disabled={isOpening || undefined}
1207 aria-label={`${isOpening ? "Loading" : "Play"} ${file.name}`}
1208 onClick={() => {
1209 if (isOpening) return;
1210 ctx.onFileOpen(file);
1211 }}
1212 >
1213 {isOpening ? (
1214 <span className="text-[11px]">Loading…</span>
1215 ) : (
1216 <PlayIcon className="size-8 fill-current" />
1217 )}
1218 <span className="max-w-full truncate text-[11px]">{file.name}</span>
1219 </button>
1220 </div>
1221 );
1222 })}
1233 {userVideos.map((file) => (
1234 <UserVideoAttachment key={file.id} file={file} />
1235 ))}
12231236 </div>
12241237 )}
12251238 {previewAnnotations.map((annotation, index) => (
apps/web/src/components/media/MediaActions.tsx+21-16
......@@ -1,3 +1,4 @@
1import type { MediaActionId } from "@t3tools/client-runtime/media-actions";
12import {
23 mediaReferenceFileName,
34 type MediaReference,
......@@ -68,8 +69,6 @@ export function useMediaActions(source: MediaActionSource) {
6869 return { save, copyImage };
6970}
7071
71type MediaAction = "copy-full" | "copy-relative" | "copy-url" | "save" | "copy-image" | "open-file";
72
7372/** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */
7473export function MediaActions({
7574 source,
......@@ -82,8 +81,6 @@ export function MediaActions({
8281 const [tooltipOpen, setTooltipOpen] = useState(false);
8382 const menuOpen = useRef(false);
8483 const reference = source.reference;
85 const hasActions =
86 source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined;
8784 const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name);
8885
8986 const showMenu = async (position: { x: number; y: number }) => {
......@@ -94,28 +91,37 @@ export function MediaActions({
9491 let failureTitle = "Could not open media menu";
9592 let progressToast: ReturnType<typeof toastManager.add> | undefined;
9693 try {
97 const items: ContextMenuItem<MediaAction>[] = [];
94 const noun = source.kind === "image" ? "image" : "video";
95 const unavailable = source.src === null && source.asset === undefined;
96 const canCopyImage =
97 typeof navigator !== "undefined" &&
98 Boolean(navigator.clipboard?.write) &&
99 typeof ClipboardItem !== "undefined";
100 const items: ContextMenuItem<MediaActionId>[] = [];
98101 if (reference?.kind === "file") {
99 items.push({ id: "copy-full", label: "Copy full path" });
102 items.push({ id: "copy-full-path", label: "Copy full path" });
100103 if (reference.relativePath)
101 items.push({ id: "copy-relative", label: "Copy relative path" });
104 items.push({ id: "copy-relative-path", label: "Copy relative path" });
102105 } else if (reference?.kind === "url") {
103106 items.push({ id: "copy-url", label: "Copy URL" });
104107 }
108 if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" });
109 items.push({ id: "save", label: `Save ${noun}`, disabled: unavailable });
105110 if (source.kind === "image") {
106 const unavailable = source.src === null && source.asset === undefined;
107 items.push({ id: "save", label: "Save image", disabled: unavailable });
108 items.push({ id: "copy-image", label: "Copy image", disabled: unavailable });
111 items.push({
112 id: "copy-image",
113 label: "Copy image",
114 disabled: unavailable || !canCopyImage,
115 });
109116 }
110 if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" });
111117
112118 const action = await api.contextMenu.show(items, position);
113119 if (!action) return;
114120 failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`;
115121 const text =
116 action === "copy-full" && reference?.kind === "file"
122 action === "copy-full-path" && reference?.kind === "file"
117123 ? reference.path
118 : action === "copy-relative" && reference?.kind === "file"
124 : action === "copy-relative-path" && reference?.kind === "file"
119125 ? reference.relativePath
120126 : action === "copy-url" && reference?.kind === "url"
121127 ? reference.url
......@@ -131,7 +137,7 @@ export function MediaActions({
131137 } else if (action === "save" || action === "copy-image") {
132138 progressToast = toastManager.add({
133139 type: "loading",
134 title: action === "save" ? "Preparing image download…" : "Copying image…",
140 title: action === "save" ? `Preparing ${noun} download…` : "Copying image…",
135141 });
136142 await (action === "save" ? save() : copyImage());
137143 toastManager.update(progressToast, {
......@@ -158,7 +164,7 @@ export function MediaActions({
158164 render={children}
159165 tabIndex={0}
160166 onContextMenu={(event) => {
161 if (!hasActions || event.defaultPrevented) return;
167 if (event.defaultPrevented) return;
162168 event.preventDefault();
163169 event.stopPropagation();
164170 const bounds = event.currentTarget.getBoundingClientRect();
......@@ -170,7 +176,6 @@ export function MediaActions({
170176 }}
171177 onKeyDown={(event) => {
172178 if (
173 !hasActions ||
174179 event.defaultPrevented ||
175180 !(event.key === "ContextMenu" || (event.shiftKey && event.key === "F10"))
176181 )
apps/web/src/components/media/MediaVideoPlayer.tsx+12-24
......@@ -1,4 +1,4 @@
1import { Maximize2Icon, RotateCwIcon, TriangleAlertIcon } from "lucide-react";
1import { RotateCwIcon, TriangleAlertIcon } from "lucide-react";
22import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
33
44import { cn } from "../../lib/utils";
......@@ -14,11 +14,13 @@ interface MediaVideoPlayerProps {
1414 readonly originalUrl?: string | undefined;
1515 readonly revision?: string | null | undefined;
1616 readonly preload?: "visible" | "metadata" | undefined;
17 readonly autoPlay?: boolean | undefined;
1718 readonly className?: string | undefined;
1819 readonly videoClassName?: string | undefined;
20 /** Styles the loading and failure panels, which otherwise assume an inline light surface. */
21 readonly stateClassName?: string | undefined;
1922 readonly style?: CSSProperties | undefined;
2023 readonly copyMarkdown?: string | undefined;
21 readonly onExpand?: ((src: string) => void) | undefined;
2224 readonly onRetry?: (() => Promise<void>) | undefined;
2325 readonly actionsSource?: MediaActionSource | undefined;
2426}
......@@ -31,11 +33,12 @@ export function MediaVideoPlayer({
3133 originalUrl,
3234 revision = null,
3335 preload = "visible",
36 autoPlay = false,
3437 className,
3538 videoClassName,
39 stateClassName,
3640 style,
3741 copyMarkdown,
38 onExpand,
3942 onRetry,
4043 actionsSource,
4144}: MediaVideoPlayerProps) {
......@@ -114,23 +117,6 @@ export function MediaVideoPlayer({
114117 }
115118 };
116119
117 const expandButton =
118 onExpand && src !== null ? (
119 <Button
120 type="button"
121 variant="secondary"
122 size="icon-xs"
123 className={failed ? undefined : "absolute right-2 top-2"}
124 aria-label={`Expand ${label || "video"}`}
125 onClick={() => {
126 videoRef.current?.pause();
127 onExpand(latestSrc ?? src);
128 }}
129 >
130 <Maximize2Icon />
131 </Button>
132 ) : null;
133
134120 const player = (
135121 <span
136122 className={cn("relative inline-block align-middle", className)}
......@@ -140,7 +126,10 @@ export function MediaVideoPlayer({
140126 {failed ? (
141127 <span
142128 role="alert"
143 className="flex min-h-28 flex-col items-center justify-center gap-3 rounded-lg border border-border/40 bg-muted/40 p-4 text-center text-sm text-muted-foreground"
129 className={cn(
130 "flex min-h-28 flex-col items-center justify-center gap-3 rounded-lg border border-border/40 bg-muted/40 p-4 text-center text-sm text-muted-foreground",
131 stateClassName,
132 )}
144133 >
145134 <span className="inline-flex items-center gap-1.5">
146135 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
......@@ -159,7 +148,6 @@ export function MediaVideoPlayer({
159148 </Button>
160149 ) : null}
161150 <OpenMediaLink originalUrl={originalUrl} src={latestSrc ?? src} fileName={label} />
162 {expandButton}
163151 </span>
164152 </span>
165153 ) : src !== null ? (
......@@ -168,6 +156,7 @@ export function MediaVideoPlayer({
168156 ref={videoRef}
169157 src={src}
170158 aria-label={label || "Video preview"}
159 autoPlay={autoPlay}
171160 controls
172161 playsInline
173162 preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"}
......@@ -186,11 +175,10 @@ export function MediaVideoPlayer({
186175 <span
187176 role="status"
188177 aria-label="Loading video"
189 className="block aspect-video w-full rounded-lg bg-muted/60"
178 className={cn("block aspect-video w-full rounded-lg bg-muted/60", stateClassName)}
190179 style={style}
191180 />
192181 )}
193 {!failed && expandButton}
194182 </span>
195183 );
196184 return actionsSource ? <MediaActions source={actionsSource}>{player}</MediaActions> : player;
apps/web/src/filePathDisplay.ts+11-16
......@@ -1,22 +1,18 @@
1import { splitPathAndPosition } from "./terminal-links";
1import {
2 fileBasename,
3 formatFilePathPosition,
4 splitFilePathPosition,
5 stripSlashPrefixedWindowsDrive,
6} from "@t3tools/client-runtime/markdown-links";
27
38function normalizePathSeparators(path: string): string {
49 return path.replaceAll("\\", "/");
510}
611
7function canonicalizeWindowsDrivePath(path: string): string {
8 return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
9}
10
1112function trimTrailingPathSeparators(path: string): string {
1213 return path.replace(/[\\/]+$/, "");
1314}
1415
15function basenameOfPath(path: string): string {
16 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
17 return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
18}
19
2016function stripRelativePrefixes(path: string): string {
2117 return path.replace(/^\.\/+/, "").replace(/^\/+/, "");
2218}
......@@ -25,15 +21,15 @@ export function formatWorkspaceRelativePath(
2521 pathWithPosition: string,
2622 workspaceRoot: string | undefined,
2723): string {
28 const { path, line, column } = splitPathAndPosition(pathWithPosition);
29 const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path));
24 const position = splitFilePathPosition(pathWithPosition);
25 const normalizedPath = stripSlashPrefixedWindowsDrive(normalizePathSeparators(position.path));
3026
3127 let displayPath = normalizedPath;
3228 if (workspaceRoot) {
33 const normalizedWorkspaceRoot = canonicalizeWindowsDrivePath(
29 const normalizedWorkspaceRoot = stripSlashPrefixedWindowsDrive(
3430 normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)),
3531 );
36 const workspaceLabel = basenameOfPath(normalizedWorkspaceRoot);
32 const workspaceLabel = fileBasename(normalizedWorkspaceRoot);
3733 const pathForCompare = normalizedPath.toLowerCase();
3834 const workspaceForCompare = normalizedWorkspaceRoot.toLowerCase();
3935 const workspaceWithSeparator = `${workspaceForCompare}/`;
......@@ -52,6 +48,5 @@ export function formatWorkspaceRelativePath(
5248 }
5349 }
5450
55 if (!line) return displayPath;
56 return `${displayPath}:${line}${column ? `:${column}` : ""}`;
51 return formatFilePathPosition({ ...position, path: displayPath });
5752}
apps/web/src/markdown-links.test.ts+8-1
......@@ -270,8 +270,15 @@ describe("resolveMarkdownFileLinkTarget", () => {
270270 ).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1");
271271 });
272272
273 it("does not treat app routes as file links", () => {
273 it("does not treat app routes as file links, even with a line anchor", () => {
274274 expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull();
275 expect(resolveMarkdownFileLinkTarget("/chat/settings#L3", "/repo")).toBeNull();
276 });
277
278 it("decodes an encoded drive colon in a file uri before dropping its slash", () => {
279 expect(resolveMarkdownFileLinkTarget("file:///c%3A/Users/x/shot.png")).toBe(
280 "c:/Users/x/shot.png",
281 );
275282 });
276283});
277284
apps/web/src/markdown-links.ts+25-214
......@@ -1,53 +1,21 @@
11import {
2 fileBasename,
3 formatFilePathPosition,
24 inlineCodeFilePathCandidate,
3 isConventionalFilePosition,
5 isRelativeFilePath,
6 normalizeMarkdownLinkDestination,
7 parseFileUrlHref,
8 parseMarkdownFileLink,
9 safeDecodeURIComponent,
10 splitFilePathPosition,
11 workspaceRelativeFilePath,
412} from "@t3tools/client-runtime/markdown-links";
513
614import { formatWorkspaceRelativePath } from "./filePathDisplay";
7import {
8 isTerminalLinkActivation,
9 resolvePathLinkTarget,
10 splitPathAndPosition,
11} from "./terminal-links";
15import { isTerminalLinkActivation, resolvePathLinkTarget } from "./terminal-links";
16
17export { normalizeMarkdownLinkDestination };
1218
13const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
14const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
15const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;
16const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
17const RELATIVE_FILE_PATH_PATTERN =
18 /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
19const RELATIVE_FILE_NAME_PATTERN =
20 /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
21const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
22const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
23// Standard OS and dev-container roots; deliberately excludes app-route-ish
24// prefixes like /app/ or /chat/ so SPA routes never read as files.
25const POSIX_FILE_ROOT_PREFIXES = [
26 "/Users/",
27 "/home/",
28 "/tmp/",
29 "/var/",
30 "/etc/",
31 "/opt/",
32 "/mnt/",
33 "/Volumes/",
34 "/private/",
35 "/root/",
36 "/usr/",
37 "/bin/",
38 "/sbin/",
39 "/lib/",
40 "/lib64/",
41 "/srv/",
42 "/dev/",
43 "/proc/",
44 "/sys/",
45 "/run/",
46 "/boot/",
47 "/media/",
48 "/workspace/",
49 "/workspaces/",
50] as const;
5119const MARKDOWN_LINK_HREF_PATTERN =
5220 /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g;
5321
......@@ -81,112 +49,14 @@ export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): bool
8149 return /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? "");
8250}
8351
84function safeDecode(value: string): string {
85 try {
86 return decodeURIComponent(value);
87 } catch {
88 return value;
89 }
90}
91
9252export function isWindowsDrivePathHref(href: string): boolean {
93 return WINDOWS_DRIVE_PATH_PATTERN.test(safeDecode(href));
94}
95
96function unwrapMarkdownLinkDestination(value: string): string {
97 return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value;
98}
99
100export function normalizeMarkdownLinkDestination(value: string): string {
101 return unwrapMarkdownLinkDestination(value.trim());
102}
103
104function stripSearchAndHash(value: string): { path: string; hash: string } {
105 const hashIndex = value.indexOf("#");
106 const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
107 const rawHash = hashIndex >= 0 ? value.slice(hashIndex) : "";
108 const queryIndex = pathWithSearch.indexOf("?");
109 const path = queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch;
110 return { path, hash: rawHash };
111}
112
113function normalizeWindowsDrivePath(path: string): string {
114 return /^\/[A-Za-z]:[\\/]/.test(path) ? path.slice(1) : path;
115}
116
117function parseFileUrlHref(
118 href: string,
119 options?: { readonly decodePath?: boolean },
120): { path: string; hash: string } | null {
121 try {
122 const parsed = new URL(href);
123 if (parsed.protocol.toLowerCase() !== "file:") return null;
124
125 const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname;
126 const rawPath = uncHostname
127 ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}`
128 : parsed.pathname;
129 if (rawPath.length === 0) return null;
130
131 // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs.
132 const normalizedPath = normalizeWindowsDrivePath(rawPath);
133
134 return {
135 path: options?.decodePath === false ? normalizedPath : safeDecode(normalizedPath),
136 hash: parsed.hash,
137 };
138 } catch {
139 return null;
140 }
53 return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href));
14154}
14255
14356export function rewriteMarkdownFileUriHref(href: string | undefined): string | null {
14457 if (!href) return null;
145 const normalizedHref = normalizeMarkdownLinkDestination(href);
146 const target = parseFileUrlHref(normalizedHref, { decodePath: false });
147 if (!target) return null;
148 return `${target.path}${target.hash}`;
149}
150
151function looksLikePosixFilesystemPath(path: string): boolean {
152 if (!path.startsWith("/")) return false;
153 if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;
154 if (POSITION_SUFFIX_PATTERN.test(path)) return true;
155 const basename = path.slice(path.lastIndexOf("/") + 1);
156 return /\.[A-Za-z0-9_-]+$/.test(basename);
157}
158
159function appendLineColumnFromHash(path: string, hash: string): string {
160 if (!hash || POSITION_SUFFIX_PATTERN.test(path)) return path;
161 const match = hash.match(/^#L(\d+)(?:C(\d+))?$/i);
162 if (!match?.[1]) return path;
163 const line = match[1];
164 const column = match[2];
165 return `${path}:${line}${column ? `:${column}` : ""}`;
166}
167
168function isLikelyPathCandidate(path: string): boolean {
169 if (WINDOWS_DRIVE_PATH_PATTERN.test(path) || WINDOWS_UNC_PATH_PATTERN.test(path)) return true;
170 if (RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true;
171 if (path.startsWith("/")) return looksLikePosixFilesystemPath(path);
172 return RELATIVE_FILE_PATH_PATTERN.test(path) || RELATIVE_FILE_NAME_PATTERN.test(path);
173}
174
175function isRelativePath(path: string): boolean {
176 return (
177 RELATIVE_PATH_PREFIX_PATTERN.test(path) ||
178 (!path.startsWith("/") &&
179 !WINDOWS_DRIVE_PATH_PATTERN.test(path) &&
180 !WINDOWS_UNC_PATH_PATTERN.test(path))
181 );
182}
183
184function hasExternalScheme(path: string): boolean {
185 const match = path.match(EXTERNAL_SCHEME_PATTERN);
186 if (!match) return false;
187 const rest = match[2] ?? "";
188 if (rest.startsWith("//")) return true;
189 return !POSITION_ONLY_PATTERN.test(rest);
58 const target = parseFileUrlHref(normalizeMarkdownLinkDestination(href));
59 return target ? `${target.path}${target.hash}` : null;
19060}
19161
19262/**
......@@ -200,34 +70,11 @@ export function resolveMarkdownFileLinkTarget(
20070 baseDir: string | undefined = cwd,
20171): string | null {
20272 if (!href) return null;
203 const rawHref = normalizeMarkdownLinkDestination(href);
204 if (rawHref.length === 0 || rawHref.startsWith("#") || rawHref.startsWith("//")) return null;
205
206 const fileUrlTarget = rawHref.toLowerCase().startsWith("file:")
207 ? parseFileUrlHref(rawHref)
208 : null;
209 const source = fileUrlTarget ?? stripSearchAndHash(rawHref);
210 const decodedPath = normalizeWindowsDrivePath(
211 fileUrlTarget ? source.path.trim() : safeDecode(source.path.trim()),
212 );
213 const decodedHash = safeDecode(source.hash.trim());
214
215 if (decodedPath.length === 0) return null;
216 if (
217 !WINDOWS_DRIVE_PATH_PATTERN.test(decodedPath) &&
218 !WINDOWS_UNC_PATH_PATTERN.test(decodedPath) &&
219 hasExternalScheme(decodedPath)
220 ) {
221 return null;
222 }
223
224 if (!isLikelyPathCandidate(decodedPath)) return null;
225
226 const pathWithPosition = appendLineColumnFromHash(decodedPath, decodedHash);
227 if (!isRelativePath(pathWithPosition)) {
228 return pathWithPosition;
229 }
73 const target = parseMarkdownFileLink(href);
74 if (!target) return null;
23075
76 const pathWithPosition = formatFilePathPosition(target);
77 if (!isRelativeFilePath(pathWithPosition)) return pathWithPosition;
23178 if (!baseDir) return null;
23279 return resolvePathLinkTarget(pathWithPosition, baseDir);
23380}
......@@ -246,38 +93,7 @@ export function resolveInlineCodeFileLinkMeta(
24693 const candidate = inlineCodeFilePathCandidate(codeText);
24794 if (candidate === null) return null;
24895
249 const resolved = resolveMarkdownFileLinkMeta(candidate, cwd, baseDir);
250 if (resolved) return resolved;
251
252 // `Makefile:12` — conventional extensionless names fail the generic
253 // markdown-link candidate patterns, but here the :line suffix already
254 // marked the span as a file reference.
255 if (baseDir && isConventionalFilePosition(candidate)) {
256 return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, baseDir), cwd);
257 }
258 return null;
259}
260
261function basenameOfPath(path: string): string {
262 // A trailing separator is a valid way to write a directory, so trim it before
263 // taking the final segment. Without this the segment reads as empty and the
264 // chip renders with no label at all.
265 const trimmed = path.replace(/[/\\]+$/, "") || path;
266 const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
267 return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed;
268}
269
270function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null {
271 if (!workspaceRoot) return null;
272 const normalizedPath = normalizeWindowsDrivePath(path.replaceAll("\\", "/"));
273 const normalizedRoot = normalizeWindowsDrivePath(workspaceRoot.replaceAll("\\", "/")).replace(
274 /\/+$/,
275 "",
276 );
277 const pathForCompare = normalizedPath.toLowerCase();
278 const rootForCompare = normalizedRoot.toLowerCase();
279 if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null;
280 return normalizedPath.slice(normalizedRoot.length + 1);
96 return resolveMarkdownFileLinkMeta(candidate, cwd, baseDir);
28197}
28298
28399export function resolveMarkdownFileLinkMeta(
......@@ -291,19 +107,14 @@ export function resolveMarkdownFileLinkMeta(
291107}
292108
293109function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta {
294 const { path, line, column } = splitPathAndPosition(targetPath);
295 const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN;
296 const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN;
297 const lineNumber = Number.isFinite(parsedLine) ? parsedLine : undefined;
298 const columnNumber = Number.isFinite(parsedColumn) ? parsedColumn : undefined;
299
110 const { path, line, column } = splitFilePathPosition(targetPath);
300111 return {
301112 filePath: path,
302113 targetPath,
303114 displayPath: formatWorkspaceRelativePath(targetPath, cwd),
304 workspaceRelativePath: workspaceRelativePath(path, cwd),
305 basename: basenameOfPath(path),
306 ...(lineNumber !== undefined ? { line: lineNumber } : {}),
307 ...(columnNumber !== undefined ? { column: columnNumber } : {}),
115 workspaceRelativePath: workspaceRelativeFilePath(path, cwd),
116 basename: fileBasename(path),
117 ...(line !== undefined ? { line } : {}),
118 ...(column !== undefined ? { column } : {}),
308119 };
309120}
apps/web/src/terminal-links.ts+8-32
......@@ -1,3 +1,8 @@
1import {
2 formatFilePathPosition,
3 splitFilePathPosition,
4} from "@t3tools/client-runtime/markdown-links";
5
16import { isMacPlatform } from "./lib/utils";
27
38export type TerminalLinkKind = "url" | "path";
......@@ -137,35 +142,6 @@ function inferHomeFromCwd(cwd: string): string | undefined {
137142 return undefined;
138143}
139144
140export function splitPathAndPosition(value: string): {
141 path: string;
142 line: string | undefined;
143 column: string | undefined;
144} {
145 let path = value;
146 let column: string | undefined;
147 let line: string | undefined;
148
149 const columnMatch = path.match(/:(\d+)$/);
150 if (!columnMatch?.[1]) {
151 return { path, line: undefined, column: undefined };
152 }
153
154 column = columnMatch[1];
155 path = path.slice(0, -columnMatch[0].length);
156
157 const lineMatch = path.match(/:(\d+)$/);
158 if (lineMatch?.[1]) {
159 line = lineMatch[1];
160 path = path.slice(0, -lineMatch[0].length);
161 } else {
162 line = column;
163 column = undefined;
164 }
165
166 return { path, line, column };
167}
168
169145export function extractTerminalLinks(line: string): TerminalLinkMatch[] {
170146 const urlMatches = collectMatches(line, "url", URL_PATTERN, []);
171147 const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches);
......@@ -271,7 +247,8 @@ export function isTerminalLinkActivation(
271247}
272248
273249export function resolvePathLinkTarget(rawPath: string, cwd: string): string {
274 const { path, line, column } = splitPathAndPosition(rawPath);
250 const position = splitFilePathPosition(rawPath);
251 const { path } = position;
275252
276253 let resolvedPath = path;
277254 if (path.startsWith("~/")) {
......@@ -285,6 +262,5 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string {
285262 resolvedPath = joinPath(cwd, path, separator);
286263 }
287264
288 if (!line) return resolvedPath;
289 return `${resolvedPath}:${line}${column ? `:${column}` : ""}`;
265 return formatFilePathPosition({ ...position, path: resolvedPath });
290266}
docs/internals/mobile-navigation.md+13-6
......@@ -86,10 +86,15 @@ composer thumbnails, and workspace image previews. The workspace PDF web preview
8686Open PDF action for the native viewer. Android retains its image viewer and uses the
8787system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission.
8888
89Received videos open directly from their signed asset URL. AVKit handles buffering;
90the client does not download the entire file or show a separate opening overlay before
91presentation. The URL is captured once per preview so credential refresh does not
92restart playback. Saving or sharing still downloads the original file.
89Every full-screen video preview on iOS is AVKit, playing directly from the signed asset
90URL: sent attachments, composer drafts, and file links that point at a video. Inline
91markdown embeds and the workspace file screen play in place with the Expo Video player.
92AVKit handles buffering; the client does not download the entire file or show a separate
93opening overlay before presentation.
94The URL is captured once per preview so credential refresh does not restart playback.
95Media actions (copy path, open in file viewer, save or share) belong to the surface that
96opened the video, a long-press on the thumbnail, so the player carries no
97menu. AVKit has no retry; a failure alerts and closes, re-mints the URL in the background, and the next open plays from the fresh one.
9398
9499The native presentation promise completes after dismissal. Local draft previews
95100hold their file lease until that promise settles. The iOS preview component requests
......@@ -97,8 +102,10 @@ native dismissal when its source screen unmounts. Playback pauses in the backgro
97102AVPlayer activates audio as playback starts. The presenter pauses and releases
98103its own player on close, then restores the previous audio-session configuration
99104if no other component changed it during playback. It does not deactivate the
100shared session, which may still serve another player or recorder. Android retains
101its React Native modal and Expo Video player.
105shared session, which may still serve another player or recorder. Android plays every
106video, attachments included, in its React Native modal with the Expo Video player.
107Composer drafts play and share from their local file; other videos stream from the
108signed URL and download only for save or share.
102109
103110`shareFileFromSource` uses the same source registration to anchor UIKit's activity
104111controller. Its promise completes when the native share flow finishes, keeping
docs/user/composer.md+11-11
......@@ -37,12 +37,12 @@ download button beside it to save a copy. Other attached files download when sel
3737Select a video attachment before or after sending to play it. Web and desktop use the browser's
3838built-in controls. On mobile, videos open in a full-screen player with native playback controls.
3939Supported videos show a thumbnail in the conversation and composer.
40On web, desktop, and iOS, received videos stream from their environment as they play. Supported formats and codecs
41depend on the browser or device; you can save an unsupported video to open it in another app.
40Received videos stream from their environment as they play on every platform. Supported formats and
41codecs depend on the browser or device; you can save an unsupported video to open it in another app.
4242
4343On iOS, the system player zooms from the attachment. Swipe down or tap Close to return to the
44conversation or draft. Touch and hold the attachment, then choose **Save or share video** to open
45the system share options. On Android, use **Save or share video** inside the preview.
44conversation or draft. Touch and hold a video thumbnail, then choose **Save or share** to open
45the system share options. On Android, the same menu is also available inside the preview.
4646
4747On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name
4848and shows **Attach again** next to it. Attach the file again or remove it, then send.
......@@ -97,20 +97,20 @@ open a media preview.
9797Videos opened from the file explorer or a file-viewer tab also play inside T3 Code. They
9898stream from the environment as needed, rather than downloading the entire video before playback.
9999Paths in inline code, such as `/tmp/recording.mp4`, work the same way. Image embeds stay inline;
100video embeds show a player with controls and an option to expand. Visible video previews load
100video embeds show a player with the browser's controls, full screen included. Visible video previews load
101101an initial frame when supported, but stay paused until you press Play. Video file references use
102102a filmstrip icon.
103103
104104On web and desktop, hover over a preview to see its full file path or original URL. Right-click
105to copy that reference, save an image, or copy an image to the clipboard. Use the video player's
106built-in controls to download videos. If the player cannot decode a video, its error message
105to copy that reference, save the image or video, or copy an image to the clipboard. The video
106player's built-in controls can download a video too. If the player cannot decode a video, its error message
107107offers a link to open the source in the browser. Workspace media also offers **Copy relative
108108path** and **Open in file viewer**. These actions are available in expanded previews too.
109109
110On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its
111source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file
112viewer from the same menu. Saving downloads a copy only when you request it; it does not change
113how the video buffers during playback. On iOS, touch and hold a file reference in a message to
110On mobile, touch and hold an inline image or a video thumbnail to see its source,
111copy the path or URL, or choose **Save or share**. Workspace files can open in the file viewer
112from the same menu. Saving downloads a copy only when you request it; it does not change how
113the video buffers during playback. On iOS, touch and hold a file reference in a message to
114114copy its full or relative path or open it in the file viewer.
115115
116116Use Markdown image syntax to embed either kind of media:
packages/client-runtime/package.json+8
......@@ -27,6 +27,14 @@
2727 "types": "./src/mediaReference.ts",
2828 "default": "./src/mediaReference.ts"
2929 },
30 "./media-source": {
31 "types": "./src/mediaSource.ts",
32 "default": "./src/mediaSource.ts"
33 },
34 "./media-actions": {
35 "types": "./src/mediaActions.ts",
36 "default": "./src/mediaActions.ts"
37 },
3038 "./codex-file-citations": {
3139 "types": "./src/codexFileCitations.ts",
3240 "default": "./src/codexFileCitations.ts"
packages/client-runtime/src/markdownImages.ts+24-56
......@@ -1,66 +1,27 @@
1import { isWindowsAbsolutePath } from "@t3tools/shared/path";
2
3import {
4 normalizeMarkdownLinkDestination,
5 parseFileUrlHref,
6 safeDecodeURIComponent,
7 splitMarkdownLinkSearchAndHash,
8 stripSlashPrefixedWindowsDrive,
9} from "./markdownLinks.ts";
10
111const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i;
212const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/;
3const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
413
514export type MarkdownImageSource =
615 | { readonly _tag: "Direct"; readonly uri: string }
716 | { readonly _tag: "WorkspaceFile"; readonly path: string }
817 | { readonly _tag: "Blocked" };
918
10function safeDecode(value: string): string {
11 try {
12 return decodeURIComponent(value);
13 } catch {
14 return value;
15 }
16}
17
18function normalizeSource(value: string): string {
19 const trimmed = value.trim();
20 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
21}
22
2319export function markdownImageSourceFragment(source: string): string {
24 const normalizedSource = normalizeSource(source);
25 const hashIndex = normalizedSource.indexOf("#");
26 return hashIndex >= 0 ? normalizedSource.slice(hashIndex) : "";
27}
28
29function normalizeWindowsDrivePath(value: string): string {
30 return /^\/[A-Za-z]:[\\/]/.test(value) ? value.slice(1) : value;
31}
32
33function parseFileUrl(value: string): string | null {
34 try {
35 const parsed = new URL(value);
36 if (parsed.protocol.toLowerCase() !== "file:") return null;
37
38 if (parsed.hostname.length > 0 && parsed.hostname.toLowerCase() !== "localhost") {
39 const pathname = safeDecode(parsed.pathname).replaceAll("/", "\\");
40 return `\\\\${safeDecode(parsed.hostname)}${pathname}`;
41 }
42
43 const pathname = safeDecode(parsed.pathname);
44 if (pathname.length === 0) return null;
45 return normalizeWindowsDrivePath(pathname);
46 } catch {
47 return null;
48 }
49}
50
51function stripSearchAndHash(value: string): string {
52 const searchIndex = value.indexOf("?");
53 const hashIndex = value.indexOf("#");
54 const end = [searchIndex, hashIndex]
55 .filter((index) => index >= 0)
56 .reduce((lowest, index) => Math.min(lowest, index), value.length);
57 return value.slice(0, end);
20 return splitMarkdownLinkSearchAndHash(normalizeMarkdownLinkDestination(source)).hash;
5821}
5922
6023function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
61 const windows =
62 WINDOWS_DRIVE_PATH_PATTERN.test(workspaceRoot) || workspaceRoot.startsWith("\\\\");
63 const separator = windows ? "\\" : "/";
24 const separator = isWindowsAbsolutePath(workspaceRoot) ? "\\" : "/";
6425 const root = workspaceRoot.replace(/[\\/]+$/, "");
6526 const path = relativePath.replace(/[\\/]/g, separator).replace(/^[\\/]+/, "");
6627 return `${root}${separator}${path}`;
......@@ -77,7 +38,7 @@ export function classifyMarkdownImageSource(
7738): MarkdownImageSource {
7839 if (value === null || value === undefined) return { _tag: "Blocked" };
7940
80 const source = normalizeSource(value);
41 const source = normalizeMarkdownLinkDestination(value);
8142 if (source.length === 0 || source.startsWith("#") || source.startsWith("?")) {
8243 return { _tag: "Blocked" };
8344 }
......@@ -86,13 +47,20 @@ export function classifyMarkdownImageSource(
8647 }
8748
8849 if (/^file:/i.test(source)) {
89 const path = parseFileUrl(source);
90 return path === null ? { _tag: "Blocked" } : { _tag: "WorkspaceFile", path };
50 const target = parseFileUrlHref(source);
51 return target === null
52 ? { _tag: "Blocked" }
53 : {
54 _tag: "WorkspaceFile",
55 path: stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(target.path)),
56 };
9157 }
9258
93 const path = normalizeWindowsDrivePath(safeDecode(stripSearchAndHash(source)));
59 const path = stripSlashPrefixedWindowsDrive(
60 safeDecodeURIComponent(splitMarkdownLinkSearchAndHash(source).path),
61 );
9462 if (path.length === 0) return { _tag: "Blocked" };
95 if (path.startsWith("/") || WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith("\\\\")) {
63 if (path.startsWith("/") || isWindowsAbsolutePath(path)) {
9664 return { _tag: "WorkspaceFile", path };
9765 }
9866 if (URI_SCHEME_PATTERN.test(path) || path.startsWith("~/") || path.startsWith("~\\")) {
packages/client-runtime/src/markdownLinks.test.ts+141-1
......@@ -1,6 +1,14 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { inlineCodeFilePathCandidate, isConventionalFilePosition } from "./markdownLinks.js";
3import {
4 fileBasename,
5 inlineCodeFilePathCandidate,
6 isConventionalFilePosition,
7 parseFileUrlHref,
8 parseMarkdownFileLink,
9 splitFilePathPosition,
10 workspaceRelativeFilePath,
11} from "./markdownLinks.ts";
412
513describe("inlineCodeFilePathCandidate", () => {
614 it.each([
......@@ -28,3 +36,135 @@ describe("isConventionalFilePosition", () => {
2836 expect(isConventionalFilePosition("port:3000")).toBe(false);
2937 });
3038});
39
40describe("parseFileUrlHref", () => {
41 it.each([
42 ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"],
43 [
44 "file:///D:/Programme/t3code/OpenInPicker.tsx#L69",
45 "D:/Programme/t3code/OpenInPicker.tsx",
46 "#L69",
47 ],
48 ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg", ""],
49 ["file://localhost/home/me/notes.md", "/home/me/notes.md", ""],
50 ])("parses %s", (href, path, hash) => {
51 expect(parseFileUrlHref(href)).toEqual({ path, hash });
52 });
53
54 it("keeps percent-encoding so the caller decodes once", () => {
55 expect(parseFileUrlHref("file:///Users/julius/project/file%2520name.md")?.path).toBe(
56 "/Users/julius/project/file%2520name.md",
57 );
58 expect(parseFileUrlHref("file:///c%3A/Users/x/shot.png")?.path).toBe("/c%3A/Users/x/shot.png");
59 });
60
61 it.each(["https://example.com/a.ts", "file://%", "/Users/julius/a.ts"])("rejects %s", (href) => {
62 expect(parseFileUrlHref(href)).toBeNull();
63 });
64});
65
66describe("splitFilePathPosition", () => {
67 it.each([
68 ["src/main.ts", "", { path: "src/main.ts" }],
69 ["src/main.ts:12", "", { path: "src/main.ts", line: 12 }],
70 ["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }],
71 ["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }],
72 ["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }],
73 ["src/main.ts:0", "", { path: "src/main.ts" }],
74 ["src/main.ts", "#section", { path: "src/main.ts" }],
75 ])("splits %s%s", (path, hash, expected) => {
76 expect(splitFilePathPosition(path, hash)).toEqual(expected);
77 });
78});
79
80describe("parseMarkdownFileLink", () => {
81 // Both clients consume this table, so a path the web app recognizes is one
82 // the mobile app recognizes too.
83 it.each([
84 ["/Users/julius/project/AGENTS.md", "/Users/julius/project/AGENTS.md"],
85 ["/home/me/notes.md", "/home/me/notes.md"],
86 ["/usr/local/bin/tool", "/usr/local/bin/tool"],
87 ["/workspace/Makefile", "/workspace/Makefile"],
88 ["/tmp/favicons/", "/tmp/favicons/"],
89 ["C:\\Users\\mike\\project\\src\\main.ts", "C:\\Users\\mike\\project\\src\\main.ts"],
90 ["C:%5Crepo%5Cimage.png", "C:\\repo\\image.png"],
91 ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"],
92 ["/D:/Programme/t3code/OpenInPicker.tsx", "D:/Programme/t3code/OpenInPicker.tsx"],
93 ["</D:/Programme/t3code/ChatMarkdown.tsx:1>", "D:/Programme/t3code/ChatMarkdown.tsx"],
94 ["file:///Users/julius/project/file%2520name.md", "/Users/julius/project/file%20name.md"],
95 ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg"],
96 ["file://localhost/home/me/notes.md", "/home/me/notes.md"],
97 ["apps/mobile/src/index.ts:10", "apps/mobile/src/index.ts"],
98 ["docs/My%20Folder/checklist.xml", "docs/My Folder/checklist.xml"],
99 ["Updated%20cutover%20checklist.md", "Updated cutover checklist.md"],
100 ["./scripts/deploy", "./scripts/deploy"],
101 ["~/notes/today.md", "~/notes/today.md"],
102 ["AGENTS.md", "AGENTS.md"],
103 ["script.ts:10", "script.ts"],
104 ["/tmp/clip%23one.mp4#t=2", "/tmp/clip#one.mp4"],
105 ])("recognizes %s as a file", (href, path) => {
106 expect(parseMarkdownFileLink(href)?.path).toBe(path);
107 });
108
109 it.each([
110 "",
111 "#anchor",
112 "//cdn.example.com/clip.mp4",
113 "https://example.com/docs",
114 "mailto:someone@example.com",
115 "javascript:alert(1)",
116 "/chat/settings",
117 "/chat/settings#L3",
118 "/app#L1",
119 "readme",
120 "TODO:12",
121 ])("does not treat %s as a file", (href) => {
122 expect(parseMarkdownFileLink(href)).toBeNull();
123 });
124
125 it("accepts conventional extensionless names with or without a position", () => {
126 expect(parseMarkdownFileLink("Makefile")).toEqual({ path: "Makefile" });
127 expect(parseMarkdownFileLink("Dockerfile:8")).toEqual({ path: "Dockerfile", line: 8 });
128 expect(parseMarkdownFileLink("/srv/app/Makefile")).toEqual({ path: "/srv/app/Makefile" });
129 });
130
131 it("reads positions from suffixes and line anchors", () => {
132 expect(parseMarkdownFileLink("/Users/julius/project/src/main.ts#L42C7")).toEqual({
133 path: "/Users/julius/project/src/main.ts",
134 line: 42,
135 column: 7,
136 });
137 expect(parseMarkdownFileLink("file://server/share/src/main.ts#L42C7")).toMatchObject({
138 path: "\\\\server\\share\\src\\main.ts",
139 line: 42,
140 column: 7,
141 });
142 });
143});
144
145describe("fileBasename", () => {
146 it.each([
147 ["/tmp/favicons/", "favicons"],
148 ["C:\\Users\\kelchm\\.claude\\", ".claude"],
149 ["/tmp/", "tmp"],
150 ["AGENTS.md", "AGENTS.md"],
151 ["/", "/"],
152 ])("labels %s as %s", (path, basename) => {
153 expect(fileBasename(path)).toBe(basename);
154 });
155});
156
157describe("workspaceRelativeFilePath", () => {
158 it.each([
159 ["/repo/project/src/main.ts", "/repo/project", "src/main.ts"],
160 ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"],
161 ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"],
162 ["/C:/Users/mike/t3code/apps/web/a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"],
163 ["/Repo/Project/src/main.ts", "/repo/project", "src/main.ts"],
164 ["/tmp/report.ts", "/repo/project", null],
165 ["/repo/project-two/a.ts", "/repo/project", null],
166 ["/repo/project/a.ts", undefined, null],
167 ])("relates %s to %s", (path, workspaceRoot, relativePath) => {
168 expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath);
169 });
170});
packages/client-runtime/src/markdownLinks.ts+197-8
......@@ -1,12 +1,49 @@
1const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
2const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
1import { isWindowsAbsolutePath } from "@t3tools/shared/path";
2
3const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/;
34const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
5const RELATIVE_FILE_PATH_PATTERN =
6 /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
7const RELATIVE_FILE_NAME_PATTERN =
8 /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
9const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;
410const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
11const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/;
12const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i;
13const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
514const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/;
615const PATH_SEPARATOR_PATTERN = /[\\/]/;
716const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/;
817const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/;
918const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/;
19// Standard OS and dev-container roots; deliberately excludes app-route-ish
20// prefixes like /app/ or /chat/ so SPA routes never read as files.
21const POSIX_FILE_ROOT_PREFIXES = [
22 "/Users/",
23 "/home/",
24 "/tmp/",
25 "/var/",
26 "/etc/",
27 "/opt/",
28 "/mnt/",
29 "/Volumes/",
30 "/private/",
31 "/root/",
32 "/usr/",
33 "/bin/",
34 "/sbin/",
35 "/lib/",
36 "/lib64/",
37 "/srv/",
38 "/dev/",
39 "/proc/",
40 "/sys/",
41 "/run/",
42 "/boot/",
43 "/media/",
44 "/workspace/",
45 "/workspaces/",
46] as const;
1047// `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`.
1148const EXTENSIONLESS_FILE_NAMES = new Set([
1249 "Makefile",
......@@ -131,18 +168,14 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null {
131168 const trimmed = codeText.trim();
132169 if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null;
133170
134 const candidate =
135 WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed)
136 ? trimmed
137 : trimmed.replaceAll("\\", "/");
171 const candidate = isWindowsAbsolutePath(trimmed) ? trimmed : trimmed.replaceAll("\\", "/");
138172 const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate);
139173 if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null;
140174
141175 const hasExplicitPathShape =
142176 RELATIVE_PATH_PREFIX_PATTERN.test(candidate) ||
143177 candidate.startsWith("/") ||
144 WINDOWS_DRIVE_PATH_PATTERN.test(candidate) ||
145 WINDOWS_UNC_PATH_PATTERN.test(candidate);
178 isWindowsAbsolutePath(candidate);
146179 if (!hasExplicitPathShape) {
147180 const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, "");
148181 const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition;
......@@ -156,3 +189,159 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null {
156189 }
157190 return candidate;
158191}
192
193export function safeDecodeURIComponent(value: string): string {
194 try {
195 return decodeURIComponent(value);
196 } catch {
197 return value;
198 }
199}
200
201export function normalizeMarkdownLinkDestination(value: string): string {
202 const trimmed = value.trim();
203 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
204}
205
206/** Browser URL parsers write `C:/foo` as `/C:/foo` for file URLs. */
207export function stripSlashPrefixedWindowsDrive(path: string): string {
208 return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path;
209}
210
211export function splitMarkdownLinkSearchAndHash(value: string): {
212 readonly path: string;
213 readonly hash: string;
214} {
215 const hashIndex = value.indexOf("#");
216 const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
217 const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";
218 const queryIndex = pathWithSearch.indexOf("?");
219 return {
220 path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch,
221 hash,
222 };
223}
224
225/**
226 * Turns a `file:` URL into a host path, still percent-encoded so callers that
227 * decode every destination in one place do not decode file URLs twice. A
228 * non-localhost authority becomes a UNC share.
229 */
230export function parseFileUrlHref(
231 href: string,
232): { readonly path: string; readonly hash: string } | null {
233 try {
234 const parsed = new URL(href);
235 if (parsed.protocol.toLowerCase() !== "file:") return null;
236
237 const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname;
238 const path = uncHostname
239 ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}`
240 : parsed.pathname;
241 if (path.length === 0) return null;
242 return { path: stripSlashPrefixedWindowsDrive(path), hash: parsed.hash };
243 } catch {
244 return null;
245 }
246}
247
248export interface FilePathPosition {
249 readonly path: string;
250 readonly line?: number;
251 readonly column?: number;
252}
253
254export function splitFilePathPosition(path: string, hash = ""): FilePathPosition {
255 const suffixMatch = path.match(POSITION_SUFFIX_CAPTURE_PATTERN);
256 const match = suffixMatch ?? hash.match(POSITION_HASH_PATTERN);
257 if (!match?.[1]) return { path };
258
259 const line = Number.parseInt(match[1], 10);
260 const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10);
261 return {
262 path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path,
263 ...(line > 0 ? { line } : {}),
264 ...(column !== undefined && column > 0 ? { column } : {}),
265 };
266}
267
268export function formatFilePathPosition(position: FilePathPosition): string {
269 if (!position.line) return position.path;
270 return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`;
271}
272
273export function isRelativeFilePath(path: string): boolean {
274 return (
275 RELATIVE_PATH_PREFIX_PATTERN.test(path) ||
276 (!path.startsWith("/") && !isWindowsAbsolutePath(path))
277 );
278}
279
280function looksLikePosixFilesystemPath(path: string): boolean {
281 if (!path.startsWith("/")) return false;
282 if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;
283 if (POSITION_SUFFIX_PATTERN.test(path)) return true;
284 const basename = path.slice(path.lastIndexOf("/") + 1);
285 return EXTENSIONLESS_FILE_NAMES.has(basename) || FILE_EXTENSION_PATTERN.test(basename);
286}
287
288/**
289 * Decides whether a decoded link destination is a file path rather than a route
290 * or prose. Only a `:line` suffix the author wrote counts as evidence; a `#L`
291 * anchor never turns `/chat/settings` into a file.
292 */
293function looksLikeFilePath(path: string, authoredPath: string): boolean {
294 if (isWindowsAbsolutePath(path) || RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true;
295 if (path.startsWith("/")) return looksLikePosixFilesystemPath(authoredPath);
296 if (EXTENSIONLESS_FILE_NAMES.has(path)) return true;
297 return RELATIVE_FILE_PATH_PATTERN.test(authoredPath) || RELATIVE_FILE_NAME_PATTERN.test(path);
298}
299
300function hasExternalScheme(path: string): boolean {
301 if (isWindowsAbsolutePath(path)) return false;
302 const match = path.match(EXTERNAL_SCHEME_PATTERN);
303 if (!match) return false;
304 const rest = match[2] ?? "";
305 if (rest.startsWith("//")) return true;
306 return !POSITION_ONLY_PATTERN.test(rest);
307}
308
309export function parseMarkdownFileLink(href: string): FilePathPosition | null {
310 const normalized = normalizeMarkdownLinkDestination(href);
311 if (normalized.length === 0 || normalized.startsWith("#") || normalized.startsWith("//")) {
312 return null;
313 }
314
315 const source =
316 (normalized.toLowerCase().startsWith("file:") ? parseFileUrlHref(normalized) : null) ??
317 splitMarkdownLinkSearchAndHash(normalized);
318 // A percent-encoded drive colon (`/c%3A/`) only becomes strippable once decoded.
319 const path = stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(source.path.trim()));
320 const hash = safeDecodeURIComponent(source.hash.trim());
321 if (path.length === 0 || hasExternalScheme(path)) return null;
322
323 const position = splitFilePathPosition(path, hash);
324 return looksLikeFilePath(position.path, path) ? position : null;
325}
326
327export function fileBasename(path: string): string {
328 // A trailing separator is a valid way to write a directory. Trim it before
329 // taking the final segment so the label is never empty.
330 const trimmed = path.replace(/[/\\]+$/, "");
331 if (trimmed.length === 0) return path;
332 const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
333 return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed;
334}
335
336export function workspaceRelativeFilePath(
337 path: string,
338 workspaceRoot: string | null | undefined,
339): string | null {
340 if (!workspaceRoot) return null;
341 const normalizedPath = stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/"));
342 const normalizedRoot = stripSlashPrefixedWindowsDrive(
343 workspaceRoot.replaceAll("\\", "/"),
344 ).replace(/\/+$/, "");
345 if (!normalizedPath.toLowerCase().startsWith(`${normalizedRoot.toLowerCase()}/`)) return null;
346 return normalizedPath.slice(normalizedRoot.length + 1);
347}
packages/client-runtime/src/mediaActions.ts created+8
......@@ -0,0 +1,8 @@
1/** Menu action ids shared by every client so labels and handlers line up across surfaces. */
2export type MediaActionId =
3 | "copy-full-path"
4 | "copy-relative-path"
5 | "copy-url"
6 | "open-file"
7 | "save"
8 | "copy-image";
packages/client-runtime/src/mediaReference.ts+3-6
......@@ -1,5 +1,7 @@
11import { isWindowsAbsolutePath } from "@t3tools/shared/path";
22
3import { safeDecodeURIComponent } from "./markdownLinks.ts";
4
35/** The authored media location, never the temporary URL used to load its bytes. */
46export type MediaReference =
57 | {
......@@ -86,10 +88,5 @@ export function mediaReferenceFileName(reference: MediaReference): string | unde
8688 } catch {
8789 return undefined;
8890 }
89 if (!basename) return undefined;
90 try {
91 return decodeURIComponent(basename);
92 } catch {
93 return basename;
94 }
91 return basename ? safeDecodeURIComponent(basename) : undefined;
9592}
packages/client-runtime/src/mediaSource.test.ts created+141
......@@ -0,0 +1,141 @@
1import { ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { resolveMediaSource } from "./mediaSource.ts";
5
6const threadId = ThreadId.make("thread-1");
7const attachmentId =
8 "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222-mp4";
9
10describe("resolveMediaSource", () => {
11 describe("direct URLs", () => {
12 it("keeps the authored URL and decodes the display name once", () => {
13 const href = "https://cdn.example.com/clip%20one%2520%2Emp4?signature=a%2fb#t=2";
14 expect(resolveMediaSource(href, { threadId })).toEqual({
15 kind: "video",
16 mimeType: "video/mp4",
17 name: "clip one%20.mp4",
18 reference: { kind: "url", url: href },
19 srcFragment: "#t=2",
20 access: "direct",
21 uri: href,
22 });
23 });
24
25 it("keeps protocol-relative URLs as authored; clients add the scheme", () => {
26 const href = "//cdn.example.com/clip.mp4?sig=1#t=2";
27 expect(resolveMediaSource(href, { threadId })).toMatchObject({
28 access: "direct",
29 uri: href,
30 reference: { kind: "url", url: href },
31 });
32 });
33
34 it("accepts extensionless image embeds only when asked", () => {
35 const href = "https://cdn.example.com/render?id=42";
36 expect(resolveMediaSource(href, { threadId })).toBeNull();
37 expect(resolveMediaSource(href, { threadId, imageEmbed: true })).toMatchObject({
38 kind: "image",
39 mimeType: "image/*",
40 name: "render",
41 });
42 });
43
44 it.each(["data:image/png;base64,AAAA", "blob:https://app.t3.codes/id"])(
45 "loads %s directly",
46 (href) => {
47 expect(resolveMediaSource(href, { threadId, imageEmbed: true })).toMatchObject({
48 access: "direct",
49 uri: href,
50 });
51 },
52 );
53 });
54
55 describe("host paths", () => {
56 // POSIX, Windows, UNC, and file URLs must all reach the same media-file resource.
57 it.each([
58 ["/tmp/frame%23one.png:12", "/tmp/frame#one.png"],
59 ["/tmp/frame%3Fone.png:12:3", "/tmp/frame?one.png"],
60 ["/tmp/frame%2523one.png:12", "/tmp/frame%23one.png"],
61 ["C:\\Users\\demo\\frame.png", "C:\\Users\\demo\\frame.png"],
62 ["/C:/Users/demo/frame.png", "C:/Users/demo/frame.png"],
63 ["file:///C:/Users/demo/frame.png", "C:/Users/demo/frame.png"],
64 ["file://server/share/frame.png", "\\\\server\\share\\frame.png"],
65 ["\\\\server\\share\\frame.png", "\\\\server\\share\\frame.png"],
66 ])("resolves %s through the environment", (href, path) => {
67 expect(resolveMediaSource(href, { threadId, workspaceRoot: "/repo" })).toEqual({
68 kind: "image",
69 mimeType: "image/png",
70 name: path.split(/[\\/]/).at(-1),
71 reference: { kind: "file", path },
72 srcFragment: "",
73 access: "environment",
74 resource: { _tag: "media-file", threadId, path },
75 });
76 });
77
78 it("joins relative paths to the workspace and records the relative reference", () => {
79 expect(
80 resolveMediaSource("screens/logo.svg?v=2#mark", { threadId, workspaceRoot: "/repo" }),
81 ).toEqual({
82 kind: "image",
83 mimeType: "image/svg+xml",
84 name: "logo.svg",
85 reference: {
86 kind: "file",
87 path: "/repo/screens/logo.svg",
88 relativePath: "screens/logo.svg",
89 },
90 srcFragment: "#mark",
91 access: "environment",
92 resource: { _tag: "media-file", threadId, path: "/repo/screens/logo.svg" },
93 });
94 });
95
96 it("prefers a caller-resolved path over classifying the source", () => {
97 expect(
98 resolveMediaSource("logo.svg", {
99 threadId,
100 workspaceRoot: "/repo",
101 resolvedFilePath: "/repo/docs/logo.svg",
102 }),
103 ).toMatchObject({
104 access: "environment",
105 resource: { _tag: "media-file", path: "/repo/docs/logo.svg" },
106 reference: { relativePath: "docs/logo.svg" },
107 });
108 });
109
110 it("separates a playback fragment from literal filename characters", () => {
111 expect(resolveMediaSource("/tmp/clip%23one.mp4#t=2", { threadId })).toMatchObject({
112 kind: "video",
113 srcFragment: "#t=2",
114 resource: { path: "/tmp/clip#one.mp4" },
115 });
116 });
117
118 it("cannot be loaded without a thread to mint through", () => {
119 expect(resolveMediaSource("/tmp/clip.mp4", { threadId: undefined })).toMatchObject({
120 kind: "video",
121 access: "unavailable",
122 });
123 });
124
125 it.each(["notes.txt", "/repo/README", "/repo/archive.zip"])(
126 "returns null for the non-media path %s",
127 (href) => {
128 expect(resolveMediaSource(href, { threadId, workspaceRoot: "/repo" })).toBeNull();
129 },
130 );
131 });
132
133 it("serves T3 attachment files in place like any other host path", () => {
134 const path = `/home/demo/.t3/userdata/attachments/${attachmentId}.mp4`;
135 expect(resolveMediaSource(path, { threadId, workspaceRoot: "/repo" })).toMatchObject({
136 kind: "video",
137 access: "environment",
138 resource: { _tag: "media-file", threadId, path },
139 });
140 });
141});
packages/client-runtime/src/mediaSource.ts created+104
......@@ -0,0 +1,104 @@
1import type { AssetResource, ThreadId } from "@t3tools/contracts";
2import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
3
4import {
5 classifyMarkdownImageSource,
6 markdownImageSourceFragment,
7 type MarkdownImageSource,
8} from "./markdownImages.ts";
9import {
10 fileBasename,
11 splitFilePathPosition,
12 splitMarkdownLinkSearchAndHash,
13} from "./markdownLinks.ts";
14import {
15 mediaFileReference,
16 mediaReferenceFileName,
17 mediaUrlReference,
18 type MediaReference,
19} from "./mediaReference.ts";
20
21export type MediaSourceResource = Extract<AssetResource, { readonly _tag: "media-file" }>;
22
23/** What a piece of authored media is and how its bytes can be reached. */
24export type ResolvedMediaSource = {
25 readonly kind: "image" | "video";
26 readonly mimeType: string;
27 readonly name: string;
28 readonly reference?: MediaReference;
29 readonly srcFragment: string;
30} & (
31 | {
32 readonly access: "direct";
33 readonly uri: string;
34 }
35 | {
36 readonly access: "environment";
37 readonly resource: MediaSourceResource;
38 }
39 | {
40 readonly access: "unavailable";
41 }
42);
43
44export interface ResolveMediaSourceInput {
45 readonly threadId: ThreadId | undefined;
46 readonly workspaceRoot?: string | null | undefined;
47 readonly resolvedFilePath?: string | undefined;
48 /** Image syntax can target an endpoint without a recognizable extension. */
49 readonly imageEmbed?: boolean | undefined;
50}
51
52function classify(source: string, input: ResolveMediaSourceInput): MarkdownImageSource {
53 return input.resolvedFilePath === undefined
54 ? classifyMarkdownImageSource(source, input.workspaceRoot)
55 : { _tag: "WorkspaceFile", path: input.resolvedFilePath };
56}
57
58export function resolveMediaSource(
59 source: string,
60 input: ResolveMediaSourceInput,
61): ResolvedMediaSource | null {
62 const classified = classify(source, input);
63 if (classified._tag === "Blocked") return null;
64
65 const path =
66 classified._tag === "Direct"
67 ? splitMarkdownLinkSearchAndHash(classified.uri).path
68 : splitFilePathPosition(classified.path).path;
69 // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again.
70 const basename = fileBasename(path);
71 const extensionIndex = basename.lastIndexOf(".");
72 const detectedMimeType =
73 classified._tag === "Direct"
74 ? mediaMimeType(classified.uri)
75 : extensionIndex < 0
76 ? null
77 : mediaMimeTypeFromExtension(basename.slice(extensionIndex));
78 const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null);
79 if (mimeType === null) return null;
80 const kind = mimeType.startsWith("video/") ? "video" : "image";
81
82 const reference =
83 classified._tag === "Direct"
84 ? mediaUrlReference(classified.uri)
85 : mediaFileReference(path, input.workspaceRoot);
86 const name = (reference && mediaReferenceFileName(reference)) || basename || kind;
87 const common = {
88 kind,
89 mimeType,
90 name,
91 ...(reference ? { reference } : {}),
92 srcFragment: markdownImageSourceFragment(source),
93 } as const;
94
95 if (classified._tag === "Direct") {
96 return { ...common, access: "direct", uri: classified.uri };
97 }
98 if (input.threadId === undefined) return { ...common, access: "unavailable" };
99 return {
100 ...common,
101 access: "environment",
102 resource: { _tag: "media-file", threadId: input.threadId, path },
103 };
104}
packages/client-runtime/src/state/assets.ts+36-2
......@@ -1,6 +1,11 @@
1import { AssetResource, EnvironmentId, WS_METHODS } from "@t3tools/contracts";
1import {
2 type AssetCreateUrlResult,
3 AssetResource,
4 EnvironmentId,
5 WS_METHODS,
6} from "@t3tools/contracts";
27import * as Schema from "effect/Schema";
3import { Atom } from "effect/unstable/reactivity";
8import { AsyncResult, Atom } from "effect/unstable/reactivity";
49
510import type { EnvironmentRegistry } from "../connection/registry.ts";
611import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
......@@ -43,6 +48,35 @@ export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): strin
4348 }
4449}
4550
51export const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
52 Atom.withLabel("asset-url:empty"),
53);
54
55export type AssetUrlState =
56 | { readonly _tag: "Loading" }
57 | { readonly _tag: "Failure" }
58 | {
59 readonly _tag: "Success";
60 readonly url: string;
61 /** The host path the server chose to serve, when it differs from what was asked for. */
62 readonly sourcePath?: string;
63 };
64
65export function assetUrlStateFromResult(
66 result: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>,
67 httpBaseUrl: string | null,
68): AssetUrlState {
69 if (result._tag === "Failure") return { _tag: "Failure" };
70 if (httpBaseUrl === null || result._tag !== "Success") return { _tag: "Loading" };
71 const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl);
72 if (url === null) return { _tag: "Failure" };
73 return {
74 _tag: "Success",
75 url,
76 ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}),
77 };
78}
79
4680export function createAssetEnvironmentAtoms<R, E>(
4781 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
4882) {
packages/client-runtime/src/work-log/presentation.test.ts+5-11
......@@ -353,17 +353,11 @@ describe("toolGroupAction", () => {
353353describe("resolveViewedImageAsset", () => {
354354 const threadId = ThreadId.make("thread-1");
355355
356 it("loads t3 attachment paths as attachments", () => {
357 const attachmentId =
358 "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222";
359 expect(
360 resolveViewedImageAsset(`/Users/demo/.t3/dev/attachments/${attachmentId}.png`, {
361 threadId,
362 workspaceRoot: "/workspace",
363 }),
364 ).toEqual({
365 resource: { _tag: "attachment", attachmentId },
366 alt: `${attachmentId}.png`,
356 it("serves t3 attachment paths in place like any other host path", () => {
357 const path = "/Users/demo/.t3/dev/attachments/11111111-1111-4111-8111-111111111111.png";
358 expect(resolveViewedImageAsset(path, { threadId, workspaceRoot: "/workspace" })).toEqual({
359 resource: { _tag: "media-file", threadId, path },
360 alt: "11111111-1111-4111-8111-111111111111.png",
367361 srcFragment: "",
368362 });
369363 });
packages/client-runtime/src/work-log/presentation.ts+13-21
......@@ -4,10 +4,8 @@ import {
44 type ThreadId,
55 type ToolLifecycleItemType,
66} from "@t3tools/contracts";
7import {
8 classifyMarkdownImageSource,
9 markdownImageSourceFragment,
10} from "@t3tools/client-runtime/markdown-images";
7import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
8import { resolveMediaSource } from "@t3tools/client-runtime/media-source";
119import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
1210
1311export function isWorktreeSetupActivity(kind: string): boolean {
......@@ -350,15 +348,11 @@ export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): strin
350348}
351349
352350export interface ViewedImageAsset {
353 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>;
351 readonly resource: Extract<AssetResource, { readonly _tag: "media-file" }>;
354352 readonly alt: string;
355353 readonly srcFragment: string;
356354}
357355
358const ABSOLUTE_IMAGE_SOURCE_PATTERN = /^(?:file:|[\\/]|[a-z]:[\\/])/i;
359const T3_ATTACHMENT_IMAGE_PATH_PATTERN =
360 /(?:^|[\\/])(?:dev|userdata)[\\/]attachments[\\/]([a-z0-9_-]{1,128})\.[a-z0-9]{1,10}$/i;
361
362356export function resolveViewedImageAsset(
363357 source: string,
364358 input: {
......@@ -366,24 +360,22 @@ export function resolveViewedImageAsset(
366360 readonly workspaceRoot?: string | null | undefined;
367361 },
368362): ViewedImageAsset | null {
363 // A relative path with no known workspace still names a media-file relative
364 // to the thread's workspace, so classify against "." and drop the prefix.
369365 const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? ".");
370366 if (imageSource._tag !== "WorkspaceFile") return null;
371
372 const path =
367 const resolvedFilePath =
373368 input.workspaceRoot == null && imageSource.path.startsWith("./")
374369 ? imageSource.path.slice(2)
375370 : imageSource.path;
376 const attachmentId = ABSOLUTE_IMAGE_SOURCE_PATTERN.test(source)
377 ? (T3_ATTACHMENT_IMAGE_PATH_PATTERN.exec(path)?.[1] ?? null)
378 : null;
379371
380 return {
381 resource: attachmentId
382 ? { _tag: "attachment", attachmentId }
383 : { _tag: "media-file", threadId: input.threadId, path },
384 alt: path.split(/[\\/]/).at(-1) ?? "image",
385 srcFragment: markdownImageSourceFragment(source),
386 };
372 const media = resolveMediaSource(source, {
373 threadId: input.threadId,
374 workspaceRoot: input.workspaceRoot,
375 resolvedFilePath,
376 });
377 if (media === null || media.access !== "environment") return null;
378 return { resource: media.resource, alt: media.name, srcFragment: media.srcFragment };
387379}
388380
389381function toolGroupActionCount(