Files
paseo/packages/app/src/components/markdown/images.ts
T
Mohamed Boudra 85be2121b8 Link #123 issue and pull request references in chat (#8636)
* feat(app): link #123 issue and pull request references in Markdown

Agents cite issues and pull requests by number; reading one meant looking
up the URL by hand. Markdown in a workspace now links forge shorthand
against the workspace's origin remote: `#N`, GitLab and Gitea `!N`, and
cross-repository `owner/repo#N` on the same host.

Each forge's URL grammar declares which sigil maps to which path, so the
syntax stays per forge: GitHub has no `!`, and its `#` targets /issues/,
which GitHub redirects to the pull request. Recognition goes through the
Markdown engine's text link regex so code spans, fences and authored links
stay plain without a second Markdown parse while streaming. The reference
alternative sits before path recognition so `group/project#12` is not read
as a file path.

* fix(app): keep http:// for self-hosted forge web links

A self-hosted forge cloned over http:// serves its web UI over http on
the same origin. Forcing https made reference, blob and tree links fail
against an HTTP-only port. Cloud hosts and SSH remotes stay on https.
2026-11-03 13:00:24 +07:00

144 lines
5.3 KiB
TypeScript

import { Image } from "react-native";
import { isWeb } from "@/constants/platform";
import { useMemo, useEffect, useId } from "react";
import { useFetchQueries } from "@/data/query";
import { createAssistantImageFileAcquisition } from "@/assistant-image/file-acquisition";
import {
persistAttachmentFromBytes,
resolveAttachmentPreviewUrl,
releaseAttachmentPreviewUrl,
} from "@/attachments/service";
import { createAssistantImageAcquisitionCache } from "@/assistant-image/acquisition-cache";
import { retainAttachmentForGarbageCollection } from "@/attachments/gc-retention";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { resolveMarkdownPreviewImageSource } from "./image-source";
import { prepareMarkdownSource } from "./source";
import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding";
const urls = createAssistantImageAcquisitionCache<{ uri: string; attachment: AttachmentMetadata }>({
capacity: 500,
onRetain: ({ uri, attachment }) => {
const release = retainAttachmentForGarbageCollection(attachment.id);
return () => {
release();
void releaseAttachmentPreviewUrl({ attachment, url: uri });
};
},
});
export interface MarkdownImageContext {
client?: Pick<DaemonClient, "readFile"> | null;
workspaceRoot?: string;
serverId?: string;
filePath?: string;
}
export function useMarkdownSource(
text: string,
context: MarkdownImageContext,
links: { commandNames?: ReadonlySet<string>; forgeReferencePattern?: string } = {},
) {
const occurrenceKey = useId();
const { commandNames, forgeReferencePattern } = links;
const prepared = useMemo(
() =>
prepareMarkdownSource(text, context.workspaceRoot, {
supportsSvg: isWeb,
commandNames,
forgeReferencePattern,
}),
[text, context.workspaceRoot, commandNames, forgeReferencePattern],
);
const sources = [...new Set(prepared.images.map((image) => image.source))];
const acquisitions = sources.map((source) => {
const resolved = resolveMarkdownPreviewImageSource({
source,
markdownPath: context.filePath ?? "",
workspaceRoot: context.workspaceRoot,
});
return createAssistantImageFileAcquisition({
port: context.client
? {
readFile: (cwd, path) => context.client!.readFile(cwd, path),
persist: persistAttachmentFromBytes,
}
: null,
resolution: resolved
? resolveAssistantImageSource({ source: resolved, workspaceRoot: context.workspaceRoot })
: null,
serverId: context.serverId,
occurrenceKey,
unavailableMessage: "Image unavailable",
});
});
const results = useFetchQueries(
acquisitions.map((acquisition, index) => ({
queryKey: ["markdownImage", acquisition?.key ?? sources[index]],
enabled: acquisition !== null,
immutableWhen: () => true,
dataShape: "value",
gcTime: 0,
queryFn: async () => {
if (!acquisition) throw new Error("No image acquisition");
return urls.acquire(acquisition.key, async () => {
const attachment = await acquisition.locate();
const uri = await resolveAttachmentPreviewUrl(attachment);
return { attachment, uri };
});
},
})),
);
useEffect(() => {
const releases = results.flatMap((result, index) => {
const acquisition = acquisitions[index];
if (!result.data || !acquisition) return [];
const value = result.data;
return [urls.acquireRetained(acquisition.key, async () => value).release];
});
return () => releases.forEach((release) => release());
}, [results, acquisitions]);
const resolved = new Map<string, string>();
results.forEach((result, index) => {
if (result.data) resolved.set(sources[index]!, result.data.uri);
});
const displayUrls = sources.map(
(source) =>
resolved.get(source) ?? (/^(https?:|data:|blob:)/i.test(source) ? source : undefined),
);
const decoded = useFetchQueries(
displayUrls.map((uri, index) => ({
queryKey: ["markdownImageDecode", occurrenceKey, uri ?? sources[index]],
refetchOnReconnect: true,
enabled: uri !== undefined,
immutableWhen: () => true,
dataShape: "value",
gcTime: 0,
queryFn: () => {
if (!uri) throw new Error("No image URL to decode");
return decodeMarkdownImage(uri, Image.getSize);
},
})),
);
const statuses = sources.map((_source, index) =>
getMarkdownImageStatus({
canAcquire: acquisitions[index] !== null,
url: displayUrls[index],
acquisitionFailed: results[index].isError,
decodeFailed: decoded[index].isError,
decoded: decoded[index].data === true,
}),
);
const unavailableImages = new Set(
sources.filter((_source, index) => statuses[index] === "unavailable"),
);
const pendingImages = new Set(sources.filter((_source, index) => statuses[index] === "pending"));
return {
markdown: prepared.render(resolved, unavailableImages, pendingImages),
linkRecognition: prepared.linkRecognition,
linkSource: prepared.linkSource,
imageSources: Object.fromEntries([...resolved].map(([source, url]) => [url, source])),
};
}