Skip to content
1 change: 1 addition & 0 deletions apps/mobile/src/components/FilePreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>("uri" in source ? source.uri : null);
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/components/FilePreviewModal.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/components/VideoPreviewModal.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/components/VideoPreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
55 changes: 38 additions & 17 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge";
import { fileChipClipboardPath, fileChipMenu, resolveFileChipTarget } from "./fileChipMenu";
import { useFileChipShare } from "./useFileChipShare";
import {
MarkdownImageAssetScopeContext,
MarkdownImageAvailableWidthContext,
ThreadMarkdownImage,
ThreadMarkdownImageUnavailable,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -824,6 +825,12 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
readonly renderImage: MarkdownImageRenderer;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
}) {
const scope = useContext(MarkdownImageAssetScopeContext);
const resolveImageSource = props.linkHandlers.resolveImageSource;
const scopedImageSource = useCallback<MarkdownImageSourceResolver>(
(image) => resolveImageSource?.(image, scope) ?? null,
[resolveImageSource, scope],
);
const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]);

Expand All @@ -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}
/>
);
Expand Down Expand Up @@ -1391,15 +1404,17 @@ function renderFeedEntry(
>
{renderedText.trim().length > 0 ? (
<MarkdownImageAvailableWidthContext value={props.markdownContentWidth}>
<AssistantMarkdownContent
markdown={renderedText}
environmentId={props.environmentId}
markdownStyles={styles}
linkHandlers={props.markdownLinkHandlers}
onUseArtifactTemplate={props.onUseArtifactTemplate}
renderImage={props.renderMarkdownImage}
skills={props.skills}
/>
<MarkdownImageAssetScopeContext value={message.id}>
<AssistantMarkdownContent
markdown={renderedText}
environmentId={props.environmentId}
markdownStyles={styles}
linkHandlers={props.markdownLinkHandlers}
onUseArtifactTemplate={props.onUseArtifactTemplate}
renderImage={props.renderMarkdownImage}
skills={props.skills}
/>
</MarkdownImageAssetScopeContext>
</MarkdownImageAvailableWidthContext>
) : null}
{attachments.map((attachment) => {
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 ?? []),
]
: [];
Expand Down
14 changes: 12 additions & 2 deletions apps/mobile/src/features/threads/ThreadMarkdownImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>(undefined);

export function ThreadMarkdownImageView(props: {
readonly uri: string | null;
readonly sourceKey: string;
Expand Down Expand Up @@ -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 (
<ThreadMarkdownImageView
Expand All @@ -274,7 +282,7 @@ export function ThreadMarkdownImage(props: {
: undefined
}
alt={props.alt}
actionsSource={props.actionsSource}
actionsSource={actionsSource}
onPressPreview={(source) => {
const { uri: _uri, ...rest } = source as FilePreviewSource & { uri?: string };
props.onPressPreview(
Expand All @@ -283,6 +291,8 @@ export function ThreadMarkdownImage(props: {
gallery: undefined,
environmentId: props.environmentId,
resource: props.resource,
scope,
actionsSource,
}),
);
}}
Expand Down
15 changes: 13 additions & 2 deletions apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 (
Expand All @@ -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
}
/>
);
}
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/messageImageGallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function messageImageGallery(
environmentId: EnvironmentId;
threadId: ThreadId;
workspaceRoot: string | null | undefined;
scope?: string | undefined;
},
): FilePreviewItem[] {
const images: FilePreviewItem[] = [];
Expand Down
16 changes: 12 additions & 4 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -546,7 +546,11 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
return (
<ScrollView nestedScrollEnabled className="ml-7 max-h-96 py-1">
{props.activities.map((row) => (
<View key={row.id}>{props.renderReasoning(row.workEntry.detail ?? "")}</View>
<View key={row.id}>
<MarkdownImageAssetScopeContext value={row.workEntry.id}>
{props.renderReasoning(row.workEntry.detail ?? "")}
</MarkdownImageAssetScopeContext>
</View>
))}
</ScrollView>
);
Expand Down Expand Up @@ -1190,7 +1194,9 @@ const ThreadWorkLogRow = function ThreadWorkLogRow(
) : null}
{viewedImagePath ? (
<View className="pb-1.5">
{props.renderImage({ href: viewedImagePath, alt: null, title: null })}
<MarkdownImageAssetScopeContext value={row.workEntry.id}>
{props.renderImage({ href: viewedImagePath, alt: null, title: null })}
</MarkdownImageAssetScopeContext>
</View>
) : null}
<FilePreviewGallery
Expand Down Expand Up @@ -1219,7 +1225,9 @@ const ThreadWorkLogRow = function ThreadWorkLogRow(
contentContainerStyle={{ paddingRight: 8 }}
>
{reasoning ? (
props.renderReasoning(reasoning.text)
<MarkdownImageAssetScopeContext value={row.workEntry.id}>
{props.renderReasoning(reasoning.text)}
</MarkdownImageAssetScopeContext>
) : call ? (
[
call.command,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/filePreviewGallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/lib/markdownMedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -94,6 +95,7 @@ export function resolveMarkdownMediaPreview(
: {
environmentId: input.environmentId,
resource: media.resource,
scope: input.scope,
...(srcFragment ? { srcFragment } : {}),
};
const actionsSource: MediaActionsSource =
Expand All @@ -104,6 +106,7 @@ export function resolveMarkdownMediaPreview(
environmentId: input.environmentId,
threadId: input.threadId,
resource: media.resource,
scope: input.scope,
name,
mimeType,
};
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/mediaActionsSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export type MediaActionsSource = {
readonly environmentId: EnvironmentId;
readonly threadId?: ThreadId;
readonly resource: AssetResource;
readonly scope?: string | undefined;
}
);
7 changes: 4 additions & 3 deletions apps/mobile/src/lib/videoPreviewSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/native/SelectableMarkdownText.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("rea

export type MarkdownImageSourceResolver = (
image: MarkdownImageRequest,
scope?: string,
) =>
| { uri: string; headers?: Record<string, string> }
| null
Expand Down
Loading
Loading