Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
183c26a
fix(pr): load private Bitbucket download images in PR descriptions
claude Oct 15, 2026
2d0e61b
fix(server): keep signed media URLs out of fetch failure logs
claude Oct 15, 2026
379c846
Merge branch 'main' into claude/eloquent-franklin-s5xt7t
dev-dami Oct 15, 2026
8022e71
chore: merge main into Bitbucket media fix
juliusmarminge Oct 19, 2026
8d86e7c
fix(media): load private Bitbucket downloads with environment credent…
juliusmarminge Oct 19, 2026
a35be7e
fix(web): retain public media fallbacks with older servers
juliusmarminge Oct 19, 2026
131ff7f
fix(web): preserve media links and fallback video actions
juliusmarminge Oct 19, 2026
a0f4085
fix(web): retry public Bitbucket videos without signing
juliusmarminge Oct 19, 2026
b4ad931
fix(mobile): preserve Bitbucket media fragments through renewal
juliusmarminge Oct 19, 2026
42acf64
fix(mobile): keep asset URL refresh compatible with React Compiler
juliusmarminge Oct 19, 2026
64c108e
test(web): await workflow syntax highlighting before asserting
juliusmarminge Oct 19, 2026
c85778b
Merge origin/main and retain both media retry and remote error tests
juliusmarminge Oct 20, 2026
a571bc9
test(web): deduplicate merged syntax highlighting import
juliusmarminge Oct 20, 2026
39f4ead
merge: bring current main into private Bitbucket media fix
maria-rcks Oct 26, 2026
aa951bb
fix(media): serve Bitbucket downloads through the source control driver
maria-rcks Oct 26, 2026
4f758fb
merge: retain Bitbucket fallback across the mobile preview refactor
maria-rcks Oct 26, 2026
562d11f
test(mobile): retain download fallback in async native previews
maria-rcks Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/mobile/src/components/FilePreviewModal.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mediaPreviewUri } from "../lib/mediaPreviewUri";
import * as Dialog from "./ui/Dialog";
import { useContext, useEffect, useEffectEvent, useState } from "react";

Expand Down Expand Up @@ -53,7 +54,7 @@ async function resolvePreviewItem(
}
if (!("environmentId" in item)) return { source: { ...item, name } };
// Refresh expired URLs before handing them to Quick Look or ACTION_VIEW, which retain them.
const url = await refreshAssetUrl(item.environmentId, item.resource, signal)
const url = await refreshAssetUrl(item.environmentId, item.resource, signal, item.fallbackUri)
.then((value) => {
if (!value) throw new Error("Reconnect to this environment and try again.");
return value;
Expand All @@ -64,7 +65,7 @@ async function resolvePreviewItem(
"Reconnect to this environment and try again. The file may have been moved or deleted.",
);
});
return { source: { ...item, name, uri: url + (item.srcFragment ?? "") } };
return { source: { ...item, name, uri: mediaPreviewUri(url, item.srcFragment)! } };
}

function ResolvedFilePreview(props: {
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/FilePreviewModal.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { FileBackedComposerAttachment } from "../lib/composerImages";
import type { MediaActionsSource } from "../lib/mediaActionsSource";

interface FilePreviewMetadata {
readonly fallbackUri?: string;
readonly kind: "image" | "pdf" | "document";
readonly mimeType?: string;
readonly name?: string;
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/components/VideoPreviewModal.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ function NativeVideoPreview(props: {
const resolvePlaybackUrl = useEffectEvent(async (): Promise<string> => {
if (source.type !== "media") throw new Error("Could not load this video.");
if (!("resource" in source)) return source.uri;
const url = await refreshAssetUrl(source.environmentId, source.resource);
const url = await refreshAssetUrl(
source.environmentId,
source.resource,
undefined,
source.fallbackUri,
);
const playbackUrl = mediaVideoPreviewUri(source, url);
if (playbackUrl !== null) return playbackUrl;
throw new Error(
Expand Down
11 changes: 9 additions & 2 deletions apps/mobile/src/components/VideoPreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,15 @@ function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState {
const resource = "resource" in source ? source.resource : null;
const connection = usePreparedConnection(environmentId);
const asset = useAssetUrlState(environmentId, resource);
const refreshAssetUrl = useRefreshAssetUrl(environmentId, resource);
const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null);
const refreshAssetUrl = useRefreshAssetUrl(environmentId, resource, source.fallbackUri);
const uri = mediaVideoPreviewUri(
source,
asset._tag === "Success"
? asset.url
: asset._tag === "Failure"
? (source.fallbackUri ?? null)
: null,
);
return {
uri,
...(resource !== null
Expand Down
26 changes: 21 additions & 5 deletions apps/mobile/src/features/pull-requests/PullRequestMedia.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mediaPreviewUri } from "../../lib/mediaPreviewUri";
import { createContext, useContext, useMemo, useState } from "react";
import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource";
import { useFilePreviewGallery } from "../../components/FilePreviewGallery";
Expand Down Expand Up @@ -40,12 +41,27 @@ export function usePullRequestMediaUrl(url: string) {
[context, fetchUrl],
);
const asset = useAssetUrlState(context?.environmentId ?? null, resource);
const refresh = useRefreshAssetUrl(context?.environmentId ?? null, resource);
const source = pullRequestMediaSource(url, context);
const fallbackUri = "fallbackUri" in source ? source.fallbackUri : undefined;
const srcFragment = "srcFragment" in source ? source.srcFragment : undefined;
const refresh = useRefreshAssetUrl(context?.environmentId ?? null, resource, fallbackUri);
const uri = mediaPreviewUri(
asset._tag === "Success"
? asset.url
: resource
? asset._tag === "Failure"
? (fallbackUri ?? null)
: null
: url,
srcFragment,
);
return {
uri: asset._tag === "Success" ? asset.url : resource ? null : url,
unavailable: resource !== null && asset._tag === "Failure",
source: pullRequestMediaSource(url, context),
resolvePlaybackUri: resource ? async () => (await refresh()) ?? null : undefined,
uri,
unavailable: uri === null && resource !== null && asset._tag === "Failure",
source,
resolvePlaybackUri: resource
? async () => mediaPreviewUri(await refresh(), srcFragment)
: undefined,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vite-plus/test";
import { EnvironmentId } from "@t3tools/contracts";
import { filePreviewGallery } from "../../lib/filePreviewGallery";
import { pullRequestImageSource } from "./pullRequestMediaSource";
import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource";

const location = { environmentId: EnvironmentId.make("test"), cwd: "/repo" };
const url = "https://github.com/user-attachments/assets/abc-123";
Expand All @@ -16,3 +16,18 @@ describe("PR media previews", () => {
expect(gallery.every((item) => "resource" in item)).toBe(true);
});
});

it("keeps a renewable Bitbucket download with its fallback and fragment", () => {
expect(
pullRequestMediaSource("https://bitbucket.org/acme/web/downloads/demo.mp4#t=2", location),
).toEqual({
environmentId: location.environmentId,
fallbackUri: "https://bitbucket.org/acme/web/downloads/demo.mp4#t=2",
srcFragment: "#t=2",
resource: {
_tag: "github-media",
cwd: "/repo",
url: "https://api.bitbucket.org/2.0/repositories/acme/web/downloads/demo.mp4",
},
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { markdownImageSourceFragment } from "@t3tools/client-runtime/markdown-images";
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
import { SOURCE_CONTROL_MEDIA_ASSET, type EnvironmentId } from "@t3tools/contracts";
import type { FilePreviewItem } from "../../components/FilePreviewModal.types";
Expand All @@ -9,11 +10,15 @@ export interface PullRequestMediaLocation {

/** Keep the original resource identity so previewers can resolve and renew its capability. */
export function pullRequestMediaSource(url: string, location: PullRequestMediaLocation | null) {
const fetchUrl = sourceControlClients.mediaUrl(url)?.fetchUrl;
const media = sourceControlClients.mediaUrl(url);
const fetchUrl = media?.fetchUrl;
return location && fetchUrl
? {
environmentId: location.environmentId,
resource: { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: location.cwd, url: fetchUrl },
...(media?.definition.kind === "bitbucket"
? { fallbackUri: url, srcFragment: markdownImageSourceFragment(url) }
: {}),
}
: { uri: url };
}
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/lib/mediaPreviewUri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** Fallback URLs may already contain the authored fragment. */
export function mediaPreviewUri(uri: string | null, srcFragment?: string): string | null {
if (uri === null || !srcFragment) return uri;
return uri.split("#", 1)[0] + srcFragment;
}
4 changes: 3 additions & 1 deletion apps/mobile/src/lib/videoPreviewSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {
} from "@t3tools/contracts";
import { videoMimeType } from "@t3tools/shared/video";

import { mediaPreviewUri } from "./mediaPreviewUri";
import type { DraftComposerFileAttachment } from "./composerImages";
import type { MediaActionsSource } from "./mediaActionsSource";

export type MediaVideoPreviewSource = {
readonly fallbackUri?: string;
readonly type: "media";
readonly name: string;
readonly mimeType: string;
Expand Down Expand Up @@ -38,7 +40,7 @@ export function mediaVideoPreviewUri(
assetUrl: string | null,
): string | null {
if ("uri" in source) return source.uri;
return assetUrl === null ? null : assetUrl + (source.srcFragment ?? "");
return mediaPreviewUri(assetUrl, source.srcFragment);
}

export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string {
Expand Down
62 changes: 61 additions & 1 deletion apps/mobile/src/state/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ vi.mock("@effect/atom-react", () => ({
: AsyncResult.initial(false),
}));
vi.mock("./session", () => ({
environmentSession: { sessionStateAtom: () => ({}) },
environmentSession: { sessionStateAtom: () => ({}), preparedConnectionValueAtom: () => ({}) },
usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }),
}));
vi.mock("./presentation", () => ({
Expand All @@ -43,9 +43,18 @@ vi.mock("@t3tools/client-runtime/state/assets", async (importOriginal) => ({
...(await importOriginal<typeof import("@t3tools/client-runtime/state/assets")>()),
createAssetEnvironmentAtoms: () => ({ createUrl: state.assetQuery }),
}));
vi.mock("./atom-registry", () => ({
appAtomRegistry: { get: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }) },
}));
vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({
...(await original<typeof import("@t3tools/client-runtime/state/runtime")>()),
executeAtomQuery: () => state.mint(),
}));
vi.mock("./use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint }));

import { useRefreshAssetUrl, useAssetUrlState } from "./assets";
import { mediaVideoPreviewUri } from "../lib/videoPreviewSource";
import { pullRequestMediaSource } from "../features/pull-requests/pullRequestMediaSource";

const environmentId = EnvironmentId.make("asset-environment");
const threadId = ThreadId.make("asset-thread");
Expand Down Expand Up @@ -102,3 +111,54 @@ it("lets the server authorize an explicit refresh before the client grant loads"
state.mint.mockResolvedValue(AsyncResult.failure(Cause.fail(denied)));
await expect(useRefreshAssetUrl(environmentId, resource)()).resolves.toBeNull();
});

it("preserves the authored Bitbucket fragment through resolution, renewal, and fallback", async () => {
const url = "https://bitbucket.org/acme/web/downloads/demo.mp4?download=1#t=2";
const source = {
type: "media" as const,
name: "demo.mp4",
mimeType: "video/mp4",
...pullRequestMediaSource(url, { environmentId, cwd: "/repo" }),
};
if (!("resource" in source)) throw new Error("Expected a renewable media resource");
const refresh = useRefreshAssetUrl(environmentId, source.resource, source.fallbackUri);
const asset = useAssetUrlState(environmentId, source.resource);
expect(mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null)).toBe(
"https://host.test/api/assets/image.png#t=2",
);
const unsupported = new Error("Unsupported asset resource: bitbucket-media");
state.mint.mockResolvedValue(AsyncResult.failure(Cause.fail(unsupported)));
expect(mediaVideoPreviewUri(source, await refresh())).toBe(url);

state.mint.mockRejectedValue(unsupported);
expect(mediaVideoPreviewUri(source, await refresh())).toBe(url);
await expect(useRefreshAssetUrl(environmentId, resource)()).rejects.toBe(unsupported);

state.mint.mockResolvedValue(
AsyncResult.success({ relativeUrl: "/api/assets/renewed.mp4", expiresAt: 2 }),
);
expect(mediaVideoPreviewUri(source, await refresh())).toBe(
"https://host.test/api/assets/renewed.mp4#t=2",
);
});

it("keeps the Bitbucket fallback in async native preview resolution", async () => {
const fallback = "https://bitbucket.org/acme/web/downloads/demo.mp4#t=2";
const { refreshAssetUrl } = await import("./assets");
const unsupported = new Error("Media signing unavailable");
state.mint.mockRejectedValue(unsupported);
await expect(refreshAssetUrl(environmentId, resource, undefined, fallback)).resolves.toBe(
fallback,
);
await expect(refreshAssetUrl(environmentId, resource)).rejects.toBe(unsupported);
state.mint.mockResolvedValue(AsyncResult.failure(Cause.fail(unsupported)));
await expect(refreshAssetUrl(environmentId, resource, undefined, fallback)).resolves.toBe(
fallback,
);
state.mint.mockResolvedValue(
AsyncResult.success({ relativeUrl: "/api/assets/renewed.mp4", expiresAt: 2 }),
);
await expect(refreshAssetUrl(environmentId, resource, undefined, fallback)).resolves.toBe(
"https://host.test/api/assets/renewed.mp4",
);
});
45 changes: 29 additions & 16 deletions apps/mobile/src/state/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export function useAssetUrl(
export function useRefreshAssetUrl(
environmentId: EnvironmentId | null,
resource: AssetResource | null,
fallbackUri?: string,
): () => Promise<string | null> {
const connection = usePreparedConnection(environmentId);
const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null;
Expand All @@ -88,35 +89,47 @@ export function useRefreshAssetUrl(
reportFailure: false,
});
return useCallback(async () => {
if (environmentId === null || resource === null || httpBaseUrl === null) return null;
const state = assetUrlStateFromResult(
await createUrl({ environmentId, input: { resource } }),
httpBaseUrl,
);
return state._tag === "Success" ? state.url : null;
}, [createUrl, environmentId, httpBaseUrl, resource]);
if (environmentId === null || resource === null || httpBaseUrl === null)
return fallbackUri ?? null;
let result;
try {
result = await createUrl({ environmentId, input: { resource } });
} catch (error) {
if (fallbackUri !== undefined) return fallbackUri;
throw error;
}
const state = assetUrlStateFromResult(result, httpBaseUrl);
return state._tag === "Success" ? state.url : (fallbackUri ?? null);
}, [createUrl, environmentId, httpBaseUrl, resource, fallbackUri]);
}

/** {@link useRefreshAssetUrl} for async tasks that resolve several resources at once. */
export async function refreshAssetUrl(
environmentId: EnvironmentId,
resource: AssetResource,
signal?: AbortSignal,
fallbackUri?: string,
): Promise<string | null> {
const connectionAtom = environmentSession.preparedConnectionValueAtom(environmentId);
if (appAtomRegistry.get(connectionAtom)._tag === "None") return null;
if (appAtomRegistry.get(connectionAtom)._tag === "None") return fallbackUri ?? null;
const atom = assetEnvironment.createUrl({ environmentId, input: { resource } });
const result = await executeAtomQuery(appAtomRegistry, atom, {
label: atom.label?.[0] ?? "asset url",
refresh: true,
reportFailure: false,
...(signal ? { signal } : {}),
});
let result;
try {
result = await executeAtomQuery(appAtomRegistry, atom, {
label: atom.label?.[0] ?? "asset url",
refresh: true,
reportFailure: false,
...(signal ? { signal } : {}),
});
} catch (error) {
if (fallbackUri !== undefined && !signal?.aborted) return fallbackUri;
throw error;
}
// The query can wait through a reconnect; resolve against the connection that answered it.
const connection = appAtomRegistry.get(connectionAtom);
if (connection._tag === "None") return null;
if (connection._tag === "None") return fallbackUri ?? null;
const state = assetUrlStateFromResult(result, connection.value.httpBaseUrl);
return state._tag === "Success" ? state.url : null;
return state._tag === "Success" ? state.url : (fallbackUri ?? null);
}

export interface AuthorizedAssetUrl {
Expand Down
23 changes: 21 additions & 2 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,8 @@ function useChatMarkdownState({
) => {
const requestId = ++mediaRequestId.current;
void resolveMarkdownMediaPreview({
environmentId: environmentId ?? undefined,
sourceControlMedia,
source,
resolvedFilePath,
cwd,
Expand All @@ -531,7 +533,11 @@ function useChatMarkdownState({
const selected = preview.images[preview.index];
expandMedia(
selected && selected.type !== "video" && markdownRef.current
? markdownImageGallery(clickedImage ?? markdownRef.current, selected)
? markdownImageGallery(
clickedImage ?? markdownRef.current,
selected,
sourceControlMedia && environmentId && cwd ? { environmentId, cwd } : undefined,
)
: preview,
);
}
Expand Down Expand Up @@ -1424,6 +1430,9 @@ const CHAT_MARKDOWN_COMPONENTS = {
) {
return (
<ChatMarkdownAssetImage
mediaLocation={
sourceControlMedia && environmentId && cwd ? { environmentId, cwd } : undefined
}
environmentId={environmentId}
resource={{ _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd, url: hostMediaUrl }}
alt={altText}
Expand All @@ -1436,7 +1445,11 @@ const CHAT_MARKDOWN_COMPONENTS = {
srcFragment={markdownImageSourceFragment(classifiedSrc)}
originalUrl={resolveProtocolRelativeMediaUrl(directUri)}
framed={false}
fallbackSrc={hostMediaUrl}
fallbackSrc={
sourceControlClients.mediaUrl(hostMediaUrl)?.definition.kind === "bitbucket"
? resolveProtocolRelativeMediaUrl(directUri).split("#", 1)[0]
: hostMediaUrl
}
onImageExpand={imageExpand}
/>
);
Expand Down Expand Up @@ -1466,6 +1479,9 @@ const CHAT_MARKDOWN_COMPONENTS = {
}
return (
<ChatMarkdownImage
mediaLocation={
sourceControlMedia && environmentId && cwd ? { environmentId, cwd } : undefined
}
key={mediaSrc}
src={mediaSrc}
alt={altText}
Expand All @@ -1483,6 +1499,9 @@ const CHAT_MARKDOWN_COMPONENTS = {
if (imageSource._tag === "WorkspaceFile" && threadRef) {
return (
<ChatMarkdownAssetImage
mediaLocation={
sourceControlMedia && cwd ? { environmentId: threadRef.environmentId, cwd } : undefined
}
environmentId={threadRef.environmentId}
resource={{
_tag: "media-file",
Expand Down
Loading
Loading