diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index 3b0f0a47f6d4..d1c32a79473b 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -39,6 +39,7 @@ function PreviewItemResolver(props: { const refreshAssetUrl = useRefreshAssetUrl( environmentId, "resource" in source ? source.resource : null, + "resource" in source ? source.scope : undefined, ); // Resolve once per presentation; background URL refreshes must not reopen the native viewer. const [uri, setUri] = useState("uri" in source ? source.uri : null); diff --git a/apps/mobile/src/components/FilePreviewModal.types.ts b/apps/mobile/src/components/FilePreviewModal.types.ts index cc0643828aa3..1a26448821a7 100644 --- a/apps/mobile/src/components/FilePreviewModal.types.ts +++ b/apps/mobile/src/components/FilePreviewModal.types.ts @@ -16,7 +16,11 @@ export type FilePreviewItem = FilePreviewMetadata & ( | { readonly uri: string } | { readonly attachment: FileBackedComposerAttachment } - | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } + | { + readonly environmentId: EnvironmentId; + readonly resource: AssetResource; + readonly scope?: string | undefined; + } ); export type FilePreviewSource = FilePreviewItem & { diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx index 2c95bdca396d..893d69f980f9 100644 --- a/apps/mobile/src/components/VideoPreviewModal.ios.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -35,9 +35,10 @@ function NativeVideoPreview(props: { const environmentId = source.type === "media" && "environmentId" in source ? source.environmentId : null; const resource = source.type === "media" && "resource" in source ? source.resource : null; + const scope = "resource" in source ? source.scope : undefined; const preparedConnection = usePreparedConnection(environmentId); - const assetUrl = useAssetUrlState(environmentId, resource); - const refreshAssetUrl = useEffectEvent(useRefreshAssetUrl(environmentId, resource)); + const assetUrl = useAssetUrlState(environmentId, resource, scope); + const refreshAssetUrl = useEffectEvent(useRefreshAssetUrl(environmentId, resource, scope)); const name = source.type === "media" ? source.name : source.attachment.name; // The first minted URL is kept so a background refresh does not restart playback. const resolvedUrl = diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index 053bf104517e..d4cba59cb038 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -37,8 +37,9 @@ function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState { const environmentId = "environmentId" in source ? source.environmentId : null; const resource = "resource" in source ? source.resource : null; const connection = usePreparedConnection(environmentId); - const asset = useAssetUrlState(environmentId, resource); - const refreshAssetUrl = useRefreshAssetUrl(environmentId, resource); + const scope = "resource" in source ? source.scope : undefined; + const asset = useAssetUrlState(environmentId, resource, scope); + const refreshAssetUrl = useRefreshAssetUrl(environmentId, resource, scope); const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); return { uri, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 9ced36004731..cc57a6c1b61a 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -250,6 +250,7 @@ import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge"; import { fileChipClipboardPath, fileChipMenu, resolveFileChipTarget } from "./fileChipMenu"; import { useFileChipShare } from "./useFileChipShare"; import { + MarkdownImageAssetScopeContext, MarkdownImageAvailableWidthContext, ThreadMarkdownImage, ThreadMarkdownImageUnavailable, @@ -740,10 +741,10 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { } interface MarkdownLinkHandlers { - readonly onLinkPress: (href: string) => void; + readonly onLinkPress: (href: string, scope?: string) => void; readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; - readonly onFileContextMenuAction: (href: string, actionId: string) => void; - readonly onImagePress?: (href: string) => void; + readonly onFileContextMenuAction: (href: string, actionId: string, scope?: string) => void; + readonly onImagePress?: (href: string, scope?: string) => void; readonly resolveImageSource?: MarkdownImageSourceResolver; readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined; } @@ -824,6 +825,12 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; }) { + const scope = useContext(MarkdownImageAssetScopeContext); + const resolveImageSource = props.linkHandlers.resolveImageSource; + const scopedImageSource = useCallback( + (image) => resolveImageSource?.(image, scope) ?? null, + [resolveImageSource, scope], + ); const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]); @@ -847,6 +854,12 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} {...props.linkHandlers} + onLinkPress={(href) => props.linkHandlers.onLinkPress(href, scope)} + onImagePress={(href) => props.linkHandlers.onImagePress?.(href, scope)} + onFileContextMenuAction={(href, actionId) => + props.linkHandlers.onFileContextMenuAction(href, actionId, scope) + } + resolveImageSource={scopedImageSource} renderImage={props.renderImage} /> ); @@ -1391,15 +1404,17 @@ function renderFeedEntry( > {renderedText.trim().length > 0 ? ( - + + + ) : null} {attachments.map((attachment) => { @@ -1830,7 +1845,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { source ); }; - const onMarkdownLinkPress = (href: string, imageEmbed = false) => { + const onMarkdownLinkPress = (href: string, scope?: string, imageEmbed = false) => { markdownLinkRequest.current?.abort(); const request = new AbortController(); markdownLinkRequest.current = request; @@ -1853,6 +1868,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { threadId: props.threadId, workspaceRoot: props.workspaceRoot, imageEmbed, + scope, }); if (media) { void Haptics.selectionAsync(); @@ -1977,7 +1993,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); const markdownLinkHandlers: MarkdownLinkHandlers = { onLinkPress: onMarkdownLinkPress, - onImagePress: (href) => onMarkdownLinkPress(href, true), + onImagePress: (href, scope) => onMarkdownLinkPress(href, scope, true), resolveImageSource, fileContextMenu: (href) => { const skill = enrichedSkillReference(href, props.skills); @@ -2008,7 +2024,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const target = resolveFileChipTarget(href, props.workspaceRoot); return target ? fileChipMenu(target) : undefined; }, - onFileContextMenuAction: (href, actionId) => { + onFileContextMenuAction: (href, actionId, scope) => { const skill = enrichedSkillReference(href, props.skills); if (skill) { const instructionsPath = enrichedSkillInstructionsPath(skill); @@ -2031,7 +2047,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return; } case "open-file": - onMarkdownLinkPress(href); + onMarkdownLinkPress(href, scope); return; case "save": shareFileChip(target); @@ -2798,7 +2814,12 @@ function MessageImageGalleryScope(props: { const images = entry.type === "message" ? [ - ...messageImageGallery(entry.message, { environmentId, threadId, workspaceRoot }), + ...messageImageGallery(entry.message, { + environmentId, + threadId, + workspaceRoot, + scope: entry.message.role === "assistant" ? entry.message.id : undefined, + }), ...composerImageGallery(entry.pendingMessage?.attachments ?? []), ] : []; diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index c9f240d34243..d6c65cb62805 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -30,6 +30,9 @@ import { */ export const MarkdownImageAvailableWidthContext = createContext(0); +/** Later messages must re-sign workspace files that may have changed. */ +export const MarkdownImageAssetScopeContext = createContext(undefined); + export function ThreadMarkdownImageView(props: { readonly uri: string | null; readonly sourceKey: string; @@ -248,7 +251,12 @@ export function ThreadMarkdownImage(props: { readonly onPressPreview: (source: FilePreviewSource) => void; }) { const withGallery = useFilePreviewGallery(); - const assetUrl = useAssetUrlState(props.environmentId, props.resource); + const scope = useContext(MarkdownImageAssetScopeContext); + const assetUrl = useAssetUrlState(props.environmentId, props.resource, scope); + const actionsSource = + props.actionsSource && "resource" in props.actionsSource + ? { ...props.actionsSource, scope } + : props.actionsSource; return ( { const { uri: _uri, ...rest } = source as FilePreviewSource & { uri?: string }; props.onPressPreview( @@ -283,6 +291,8 @@ export function ThreadMarkdownImage(props: { gallery: undefined, environmentId: props.environmentId, resource: props.resource, + scope, + actionsSource, }), ); }} diff --git a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx index e01f6ae3b2aa..92949c354f33 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx @@ -1,4 +1,5 @@ import { createContext, useContext } from "react"; +import { MarkdownImageAssetScopeContext } from "./ThreadMarkdownImage"; import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; import { @@ -13,17 +14,23 @@ export function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource; readonly thumbnailVisible?: boolean; }) { - const { source } = props; + const scope = useContext(MarkdownImageAssetScopeContext); + const source = + "resource" in props.source + ? { ...props.source, scope: props.source.scope ?? scope } + : props.source; const feedVisible = useContext(ThreadMediaVisibleContext); const visible = props.thumbnailVisible ?? feedVisible; const thumbnailKey = mediaVideoThumbnailKey(source); const asset = useAssetUrlState( "environmentId" in source ? source.environmentId : null, "resource" in source ? source.resource : null, + "resource" in source ? source.scope : undefined, ); const refreshAssetUrl = useRefreshAssetUrl( "environmentId" in source ? source.environmentId : null, "resource" in source ? source.resource : null, + "resource" in source ? source.scope : undefined, ); const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); return ( @@ -39,7 +46,11 @@ export function ThreadMarkdownVideo(props: { thumbnailKey={thumbnailKey} thumbnailVisible={visible} unavailable={"resource" in source && asset._tag === "Failure"} - actionsSource={source.actionsSource} + actionsSource={ + source.actionsSource && "resource" in source.actionsSource + ? { ...source.actionsSource, scope: "resource" in source ? source.scope : undefined } + : source.actionsSource + } /> ); } diff --git a/apps/mobile/src/features/threads/messageImageGallery.ts b/apps/mobile/src/features/threads/messageImageGallery.ts index c02269976a27..3aa378af9ec8 100644 --- a/apps/mobile/src/features/threads/messageImageGallery.ts +++ b/apps/mobile/src/features/threads/messageImageGallery.ts @@ -10,6 +10,7 @@ export function messageImageGallery( environmentId: EnvironmentId; threadId: ThreadId; workspaceRoot: string | null | undefined; + scope?: string | undefined; }, ): FilePreviewItem[] { const images: FilePreviewItem[] = []; diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index b056bab4b64e..142f4cbd0e39 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -93,7 +93,7 @@ import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread- import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; import type { FilePreviewSource } from "../../components/FilePreviewModal"; import { FilePreviewGallery } from "../../components/FilePreviewGallery"; -import { ThreadMarkdownImage } from "./ThreadMarkdownImage"; +import { MarkdownImageAssetScopeContext, ThreadMarkdownImage } from "./ThreadMarkdownImage"; import Animated, { FadeIn, FadeOut, @@ -546,7 +546,11 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { return ( {props.activities.map((row) => ( - {props.renderReasoning(row.workEntry.detail ?? "")} + + + {props.renderReasoning(row.workEntry.detail ?? "")} + + ))} ); @@ -1190,7 +1194,9 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( ) : null} {viewedImagePath ? ( - {props.renderImage({ href: viewedImagePath, alt: null, title: null })} + + {props.renderImage({ href: viewedImagePath, alt: null, title: null })} + ) : null} {reasoning ? ( - props.renderReasoning(reasoning.text) + + {props.renderReasoning(reasoning.text)} + ) : call ? ( [ call.command, diff --git a/apps/mobile/src/lib/filePreviewGallery.ts b/apps/mobile/src/lib/filePreviewGallery.ts index 415005456244..8e6f5d3c11b7 100644 --- a/apps/mobile/src/lib/filePreviewGallery.ts +++ b/apps/mobile/src/lib/filePreviewGallery.ts @@ -7,6 +7,7 @@ export function filePreviewKey(source: FilePreviewItem): string { Object.entries(source.resource) .filter(([key]) => key !== "mimeType" && key !== "fileName") .sort(([a], [b]) => a.localeCompare(b)), + source.scope ?? null, ]); if ("attachment" in source) return `attachment:${source.attachment.id}`; return source.uri; diff --git a/apps/mobile/src/lib/markdownMedia.ts b/apps/mobile/src/lib/markdownMedia.ts index cb67a30dd2f0..0b9ad827b0e4 100644 --- a/apps/mobile/src/lib/markdownMedia.ts +++ b/apps/mobile/src/lib/markdownMedia.ts @@ -24,6 +24,7 @@ export function resolveMarkdownMediaPreview( readonly workspaceRoot: string | null | undefined; readonly captured?: boolean; readonly imageEmbed?: boolean; + readonly scope?: string | undefined; }, ): | { readonly kind: "image"; readonly source: FilePreviewSource } @@ -94,6 +95,7 @@ export function resolveMarkdownMediaPreview( : { environmentId: input.environmentId, resource: media.resource, + scope: input.scope, ...(srcFragment ? { srcFragment } : {}), }; const actionsSource: MediaActionsSource = @@ -104,6 +106,7 @@ export function resolveMarkdownMediaPreview( environmentId: input.environmentId, threadId: input.threadId, resource: media.resource, + scope: input.scope, name, mimeType, }; diff --git a/apps/mobile/src/lib/mediaActionsSource.ts b/apps/mobile/src/lib/mediaActionsSource.ts index d3e499a7c3df..8337eec85397 100644 --- a/apps/mobile/src/lib/mediaActionsSource.ts +++ b/apps/mobile/src/lib/mediaActionsSource.ts @@ -14,5 +14,6 @@ export type MediaActionsSource = { readonly environmentId: EnvironmentId; readonly threadId?: ThreadId; readonly resource: AssetResource; + readonly scope?: string | undefined; } ); diff --git a/apps/mobile/src/lib/videoPreviewSource.ts b/apps/mobile/src/lib/videoPreviewSource.ts index dabc896e2ded..d939c66aa96a 100644 --- a/apps/mobile/src/lib/videoPreviewSource.ts +++ b/apps/mobile/src/lib/videoPreviewSource.ts @@ -15,6 +15,7 @@ export type MediaVideoPreviewSource = { | { readonly uri: string } | { readonly environmentId: EnvironmentId; + readonly scope?: string | undefined; readonly resource: Extract< AssetResource, { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" | "github-media" } @@ -31,7 +32,7 @@ export function mediaVideoPreviewUri( } export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string { - return JSON.stringify( + const key = "uri" in source ? ["media-video", source.uri] : source.resource._tag === "attachment" @@ -60,8 +61,8 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string source.resource.cwd, source.resource.path, source.srcFragment ?? "", - ], - ); + ]; + return JSON.stringify("uri" in source ? key : [...key, source.scope ?? null]); } export interface LocalVideoPreviewSource { diff --git a/apps/mobile/src/native/SelectableMarkdownText.types.ts b/apps/mobile/src/native/SelectableMarkdownText.types.ts index a0af8585bc63..6836187aeddc 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.types.ts +++ b/apps/mobile/src/native/SelectableMarkdownText.types.ts @@ -42,6 +42,7 @@ export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("rea export type MarkdownImageSourceResolver = ( image: MarkdownImageRequest, + scope?: string, ) => | { uri: string; headers?: Record } | null diff --git a/apps/mobile/src/native/useMarkdownImageSource.ts b/apps/mobile/src/native/useMarkdownImageSource.ts index 4c00705cf712..b378e57a69e3 100644 --- a/apps/mobile/src/native/useMarkdownImageSource.ts +++ b/apps/mobile/src/native/useMarkdownImageSource.ts @@ -33,7 +33,7 @@ export function useMarkdownImageSource(props: { const httpBaseUrl = Option.isSome(connection) ? connection.value.httpBaseUrl : null; const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false }); return useCallback( - async (image) => { + async (image, scope) => { const media = resolveMobileMarkdownMediaSource(image.href, { threadId: threadId ?? undefined, workspaceRoot, @@ -52,7 +52,7 @@ export function useMarkdownImageSource(props: { connectionPhase === "reconnecting" ) throw new Error("Markdown image is unavailable"); - const result = await createUrl({ environmentId, input: { resource: media.resource } }); + const result = await createUrl({ environmentId, input: { resource: media.resource, scope } }); if (result._tag !== "Success") throw new Error("Could not authorize Markdown image"); const uri = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); if (uri === null) throw new Error("Markdown image URL is invalid"); diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 37ae24ecc058..386f4d045654 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -33,6 +33,7 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, + scope?: string, ): AssetUrlState { const fileAccessSession = useEnvironmentQuery( environmentId === null ? null : environmentSession.sessionStateAtom(environmentId), @@ -50,7 +51,7 @@ export function useAssetUrlState( const result = useAtomValue( blocked !== null || environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM - : assetEnvironment.createUrl({ environmentId, input: { resource } }), + : assetEnvironment.createUrl({ environmentId, input: { resource, scope } }), ); const shared = blocked ?? @@ -76,6 +77,7 @@ export function useAssetUrl( export function useRefreshAssetUrl( environmentId: EnvironmentId | null, resource: AssetResource | null, + scope?: string, ): () => Promise { const connection = usePreparedConnection(environmentId); const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null; @@ -86,9 +88,9 @@ export function useRefreshAssetUrl( return useCallback(async () => { if (environmentId === null || resource === null || httpBaseUrl === null) return null; const state = assetUrlStateFromResult( - await createUrl({ environmentId, input: { resource } }), + await createUrl({ environmentId, input: { resource, scope } }), httpBaseUrl, ); return state._tag === "Success" ? state.url : null; - }, [createUrl, environmentId, httpBaseUrl, resource]); + }, [createUrl, environmentId, httpBaseUrl, resource, scope]); } diff --git a/apps/mobile/src/state/mediaActions.ts b/apps/mobile/src/state/mediaActions.ts index dde7e2fe6b5b..1a5b504f0cc0 100644 --- a/apps/mobile/src/state/mediaActions.ts +++ b/apps/mobile/src/state/mediaActions.ts @@ -52,6 +52,7 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi const refresh = useRefreshAssetUrl( source && "environmentId" in source ? source.environmentId : null, source && "resource" in source ? source.resource : null, + source && "resource" in source ? source.scope : undefined, ); const controller = useRef(null); const [sharing, setSharing] = useState(false); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 38a282279f8f..f92a649fa7fc 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -22,13 +22,14 @@ export type AssetUrlState = RuntimeAssetUrlState & { readonly waiting?: boolean export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, + scope?: string, ): AssetUrlState { const blocked = blockedAssetUrlState(resource, useFilesystemReadAccess(environmentId)); const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( blocked !== null || environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM - : assetEnvironment.createUrl({ environmentId, input: { resource } }), + : assetEnvironment.createUrl({ environmentId, input: { resource, scope } }), ); if (blocked !== null) return blocked; return { @@ -43,6 +44,7 @@ export function useAssetUrlState( export function useAssetUrlRefresh( environmentId: EnvironmentId | null, resource: AssetResource | null, + scope?: string, ): () => Promise { const connection = usePreparedConnection(environmentId); const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null; @@ -52,7 +54,7 @@ export function useAssetUrlRefresh( }); return async () => { if (environmentId === null || resource === null || httpBaseUrl === null) return null; - const result = await refresh({ environmentId, input: { resource } }); + const result = await refresh({ environmentId, input: { resource, scope } }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); return resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); }; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 8710dcc4df07..27b8b6cb5fd2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -267,6 +267,8 @@ interface ChatMarkdownProps { uploads need; without it those images and videos load unauthenticated and 404. */ githubMedia?: boolean | undefined; headingLevelOffset?: number | undefined; + /** Keeps rewritten workspace files from reusing an earlier message's cached URL. */ + assetScope?: string | undefined; } export interface ChatMarkdownContextReference { @@ -1604,9 +1606,10 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props readonly workspaceRoot?: string | undefined; readonly workspaceMutationId?: string | null | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + readonly assetScope?: string | undefined; }) { - const assetUrl = useAssetUrlState(props.environmentId, props.resource); - const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); + const assetUrl = useAssetUrlState(props.environmentId, props.resource, props.assetScope); + const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource, props.assetScope); const connectionReady = usePreparedConnection(props.environmentId)._tag === "Some"; const resource = props.resource; const refreshToken = JSON.stringify([props.environmentId, resource, props.workspaceMutationId]); @@ -1676,7 +1679,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props name: props.alt || (props.kind ?? "image"), src, ...(fallbackSrc === undefined - ? { asset: { environmentId: props.environmentId, resource } } + ? { asset: { environmentId: props.environmentId, resource, scope: props.assetScope } } : {}), ...(reference ? { reference } : {}), ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") @@ -2383,6 +2386,7 @@ function useChatMarkdownState({ renderContextReference, headingLevelOffset = 0, githubMedia = false, + assetScope, }: ChatMarkdownProps) { const resolvedTheme = useRenderedAppearance(); const markdownRef = useRef(null); @@ -2394,6 +2398,7 @@ function useChatMarkdownState({ explicitEnvironmentId, cwd, imageBaseDir, + assetScope, ].join("\u0000"); // Tagged with the scope it was opened in: a request from the previous scope can // resolve before the effect cleanup below invalidates it, and must stay hidden. @@ -2458,6 +2463,7 @@ function useChatMarkdownState({ httpBaseUrl: environmentId === null ? undefined : readPreparedConnection(environmentId)?.httpBaseUrl, createAssetUrl, + assetScope, onOpenFile: threadRef ? (path) => useRightPanelStore.getState().openFile(threadRef, path) : undefined, @@ -2812,6 +2818,7 @@ function useChatMarkdownState({ }; const componentState = { + assetScope, canOperateHost, canOperatePreview, cwd, @@ -3277,6 +3284,7 @@ const CHAT_MARKDOWN_COMPONENTS = { }, img: function MarkdownImage({ node, title, src, alt, ...props }) { const { + assetScope, expandMedia, cwd, environmentId, @@ -3388,6 +3396,7 @@ const CHAT_MARKDOWN_COMPONENTS = { threadId: threadRef.threadId, path: imageSource.path, }} + assetScope={assetScope} alt={altText} kind={kind} copyMarkdown={copyMarkdown} diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 6c8eb20275f3..43120738662f 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -42,8 +42,16 @@ function ExpandedMediaFailure({ children }: { children: ReactNode }) { function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { const asset = item.actionsSource?.asset; - const assetUrl = useAssetUrlState(asset?.environmentId ?? null, asset?.resource ?? null); - const refreshAssetUrl = useAssetUrlRefresh(asset?.environmentId ?? null, asset?.resource ?? null); + const assetUrl = useAssetUrlState( + asset?.environmentId ?? null, + asset?.resource ?? null, + asset?.scope, + ); + const refreshAssetUrl = useAssetUrlRefresh( + asset?.environmentId ?? null, + asset?.resource ?? null, + asset?.scope, + ); const src = asset ? assetUrl._tag === "Success" ? assetUrl.url + (item.srcFragment ?? "") diff --git a/apps/web/src/components/chat/ExpandedImagePreview.tsx b/apps/web/src/components/chat/ExpandedImagePreview.tsx index 2ecf5a006f08..2f57dc0b755d 100644 --- a/apps/web/src/components/chat/ExpandedImagePreview.tsx +++ b/apps/web/src/components/chat/ExpandedImagePreview.tsx @@ -2,15 +2,10 @@ import type { SnapShotSource } from "@t3tools/contracts"; import type { ComposerFileAttachment } from "../../composerDraftStore"; import { type ChatFileAttachment, type ChatImageAttachment, isVideoAttachment } from "../../types"; -import type { - AssetCreateUrlResult, - AssetResource, - EnvironmentId, - ScopedThreadRef, -} from "@t3tools/contracts"; +import type { AssetCreateUrlResult, EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { videoMimeType } from "@t3tools/shared/video"; import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; -import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { resolveAssetUrl, type AssetUrlQuery } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure, type AtomCommandResult, @@ -44,10 +39,11 @@ export async function resolveMarkdownMediaPreview(input: { cwd?: string | undefined; threadRef?: ScopedThreadRef | undefined; httpBaseUrl?: string | undefined; + assetScope?: string | undefined; onOpenFile?: ((relativePath: string) => void) | undefined; createAssetUrl: (input: { environmentId: EnvironmentId; - input: { resource: AssetResource }; + input: AssetUrlQuery; }) => Promise>; }): Promise { const media = resolveMediaSource(input.source, { @@ -67,10 +63,14 @@ export async function resolveMarkdownMediaPreview(input: { if (media.access === "unavailable" || !input.threadRef || !input.httpBaseUrl) { throw new Error("Reconnect to this environment and open the media again."); } - asset = { environmentId: input.threadRef.environmentId, resource: media.resource }; + asset = { + environmentId: input.threadRef.environmentId, + resource: media.resource, + scope: input.assetScope, + }; const result = await input.createAssetUrl({ environmentId: asset.environmentId, - input: { resource: asset.resource }, + input: { resource: asset.resource, scope: asset.scope }, }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); const assetUrl = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); @@ -150,7 +150,7 @@ export function buildAttachmentVideoPreview( export function expandedImageKey(preview: ExpandedImagePreview): string { const item = preview.images[preview.index]; const asset = item?.actionsSource?.asset; - return `${item?.src ?? (asset ? JSON.stringify([asset.environmentId, asset.resource]) : "image")}:${preview.index}`; + return `${item?.src ?? (asset ? JSON.stringify([asset.environmentId, asset.resource, asset.scope]) : "image")}:${preview.index}`; } export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1e591b568363..f43ac9ea9e35 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2679,6 +2679,7 @@ function AssistantTimelineRow({ row }: { row: Extract void; } @@ -83,10 +87,10 @@ export function useMediaActions(source: MediaActionSource) { if (!source.src) throw new Error("This media is unavailable. Try reopening the preview."); return source.src; } - const { environmentId, resource } = source.asset; + const { environmentId, resource, scope } = source.asset; const connection = readPreparedConnection(environmentId); if (!connection) throw new Error("Reconnect to this environment and try again."); - const result = await createAssetUrl({ environmentId, input: { resource } }); + const result = await createAssetUrl({ environmentId, input: { resource, scope } }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); assertCanReadMedia(); const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 22052e6ade82..dd581ba7acae 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -127,6 +127,9 @@ export function assetUrlStateFromResult( }; } +/** Client-only cache identity: later messages must re-sign files that may have changed. */ +export type AssetUrlQuery = AssetCreateUrlInput & { readonly scope?: string | undefined }; + export function createAssetEnvironmentAtoms( runtime: Atom.AtomRuntime, localMediaEnvironment?: Atom.Atom<{ @@ -134,11 +137,11 @@ export function createAssetEnvironmentAtoms( readonly httpBaseUrl: string; } | null>, ) { - const execute = Effect.fn("assets.createUrl")(function* (input: AssetCreateUrlInput) { + const execute = Effect.fn("assets.createUrl")(function* ({ resource }: AssetUrlQuery) { + const input = { resource }; const result = yield* request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.result); if (Result.isSuccess(result)) return result.success; const error = result.failure; - const resource = input.resource; const local = localMediaEnvironment ? (yield* AtomRegistry.AtomRegistry).get(localMediaEnvironment) : null; diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index b563bd8a04af..3ad7d82fbd5c 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from "@effect/vitest"; +import { describe, expect, it, vi } from "@effect/vitest"; import { EnvironmentId, type PreviewEvent, type TerminalAttachInput, type TerminalAttachStreamEvent, + ThreadId, type TerminalMetadataStreamEvent, type TerminalSummary, WS_METHODS, @@ -33,6 +34,7 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; import type * as RpcSession from "../rpc/session.ts"; +import { createAssetEnvironmentAtoms } from "./assets.ts"; import { createPreviewEnvironmentAtoms } from "./preview.ts"; import type { QueryPersistence } from "./queryPersistence.ts"; import { createTerminalEnvironmentAtoms } from "./terminal.ts"; @@ -324,6 +326,47 @@ describe("finite environment subscription lifecycle", () => { }); describe("environment query lifecycle", () => { + it.effect("caches an asset URL per rendering without sending its scope to the server", () => + Effect.gen(function* () { + const { runtime, supervisorSession } = yield* makeEnvironmentQueryHarness(Effect.void); + const sign = vi + .fn() + .mockReturnValueOnce( + Effect.succeed({ relativeUrl: "/api/assets/first/image.png", expiresAt: 999999 }), + ) + .mockReturnValueOnce( + Effect.succeed({ relativeUrl: "/api/assets/updated/image.png", expiresAt: 999999 }), + ); + yield* SubscriptionRef.set( + supervisorSession, + Option.some({ + ...QUERY_RPC_SESSION, + client: { ...QUERY_RPC_SESSION.client, [WS_METHODS.assetsCreateUrl]: sign }, + }), + ); + const assets = createAssetEnvironmentAtoms(runtime); + const resource = { + _tag: "media-file" as const, + threadId: ThreadId.make("thread"), + path: "image.png", + }; + const query = (scope: string) => + assets.createUrl({ + environmentId: QUERY_ENVIRONMENT.environmentId, + input: { resource, scope }, + }); + const registry = yield* mountEnvironmentQuery(query("message-1")); + const url = (scope: string) => + readEnvironmentQuery(registry, query(scope)).pipe(Effect.map((asset) => asset.relativeUrl)); + + expect(yield* url("message-1")).toBe("/api/assets/first/image.png"); + expect(yield* url("message-1")).toBe("/api/assets/first/image.png"); + expect(yield* url("message-2")).toBe("/api/assets/updated/image.png"); + expect(yield* url("message-1")).toBe("/api/assets/first/image.png"); + expect(sign.mock.calls).toEqual([[{ resource }], [{ resource }]]); + }).pipe(Effect.scoped), + ); + it.effect( "terminal metadata waits for snapshots and retains known sessions across restarts", () =>