fix(markdown): restore file link hover tooltips on web

This commit is contained in:
Mohamed Boudra
2026-10-15 16:05:50 +02:00
parent 8057a65f41
commit 648f290687
8 changed files with 289 additions and 102 deletions
@@ -1,3 +1,4 @@
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { waitForPermissionPrompt } from "../support/helpers/permissions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -70,6 +71,11 @@ test("chat and file preview share the library renderer and resolve file links",
await expect(preview.locator(".enrm-text")).toContainText("Second paragraph.");
await expect(preview.getByRole("table")).toContainText("42");
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
await expectFileLinkTooltip(
page,
message.getByRole("link", { name: "src/说明.md:2-3" }),
"packages/app/src/说明.md:2-3",
);
await message.getByRole("link", { name: "src/说明.md:2-3" }).click();
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
@@ -90,3 +96,8 @@ test("chat and file preview share the library renderer and resolve file links",
await session.cleanup();
}
});
async function expectFileLinkTooltip(page: Page, link: Locator, path: string) {
await link.hover();
await expect(page.getByRole("tooltip")).toHaveText(path);
}
@@ -9,4 +9,4 @@ export {
type AssistantFileLinkResolverProviderProps,
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver";
export { useAssistantFileLinkActions } from "./use-file-link";
export { useAssistantFileLinkActions, useFileLink } from "./use-file-link";
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
import { formatFileLinkTooltipPath } from "./tooltip-path";
import type { InlinePathTarget } from "./parse";
import {
useAssistantFileLinkResolverContext,
@@ -18,6 +19,7 @@ import {
export interface UseFileLinkResult {
target: InlinePathTarget | null;
tooltipPath: string | null;
onHoverIn: () => void;
onPress: () => void;
open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
@@ -134,7 +136,11 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
return query.data ?? null;
}, [query.data, resolution]);
return useMemo(() => ({ target, onHoverIn, onPress, open }), [target, onHoverIn, onPress, open]);
const tooltipPath = target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null;
return useMemo(
() => ({ target, tooltipPath, onHoverIn, onPress, open }),
[target, tooltipPath, onHoverIn, onPress, open],
);
}
export function useAssistantFileLinkActions(): AssistantFileLinkActions {
@@ -1,21 +1,17 @@
import { View } from "react-native";
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
import { memo, useMemo, useState } from "react";
import {
EnrichedMarkdownText,
type LinkPressEvent,
type CodeBlockRendererProps,
} from "react-native-enriched-markdown";
import { EnrichedMarkdownText, type LinkPressEvent } from "react-native-enriched-markdown";
import { withUnistyles } from "react-native-unistyles";
import {
useAssistantFileLinkActions,
useAssistantFileLinkEnvironment,
} from "@/assistant-file-links";
import { useStableEvent } from "@/hooks/use-stable-event";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
import { WEB_RENDERERS } from "./renderers";
import { createMarkdownStyles } from "./styles";
import { useMarkdownSource, type MarkdownImageContext } from "./images";
@@ -26,21 +22,7 @@ const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true }
const regularStyle = (theme: Theme) => createMarkdownStyles(theme);
const compactStyle = (theme: Theme) => createMarkdownStyles(theme, true);
const ThemedCodeBlock = withUnistyles(HighlightedCodeBlock);
const codeStyle = (theme: Theme) => ({
inheritedStyles: {
color: theme.colors.foreground,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
textStyle: { backgroundColor: theme.colors.surface2, padding: 12, borderRadius: 8 },
});
function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
if (language?.toLowerCase() === "mermaid") return <MermaidFence code={code} />;
return <ThemedCodeBlock code={code} language={language} uniProps={codeStyle} />;
}
const WEB_CONTAINER = { userSelect: "text" as const };
const WEB_RENDERERS = { codeBlock: WebCodeBlock };
export interface MarkdownRendererProps extends MarkdownImageContext {
text: string;
@@ -0,0 +1,3 @@
import type { MarkdownRenderers } from "react-native-enriched-markdown";
export const WEB_RENDERERS: MarkdownRenderers | undefined = undefined;
@@ -0,0 +1,56 @@
import { Text, View, type ViewStyle } from "react-native";
import type {
CodeBlockRendererProps,
LinkRendererProps,
MarkdownRenderers,
} from "react-native-enriched-markdown";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useFileLink } from "@/assistant-file-links";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import type { Theme } from "@/styles/theme";
import { MermaidFence } from "./fence/mermaid";
import { markdownLinkSource } from "./source";
const ThemedCodeBlock = withUnistyles(HighlightedCodeBlock);
const codeStyle = (theme: Theme) => ({
inheritedStyles: {
color: theme.colors.foreground,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
textStyle: { backgroundColor: theme.colors.surface2, padding: 12, borderRadius: 8 },
});
function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
if (language?.toLowerCase() === "mermaid") return <MermaidFence code={code} />;
return <ThemedCodeBlock code={code} language={language} uniProps={codeStyle} />;
}
function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType));
const anchor = <a {...anchorProps}>{children}</a>;
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild onHoverIn={onHoverIn}>
<View style={INLINE_LINK}>{anchor}</View>
</TooltipTrigger>
{tooltipPath ? (
<TooltipContent side="top" align="start" maxWidth={520}>
<Text selectable={false} style={styles.tooltipPath}>
{tooltipPath}
</Text>
</TooltipContent>
) : null}
</Tooltip>
);
}
const INLINE_LINK: ViewStyle = { display: "inline-flex" as ViewStyle["display"] };
const styles = StyleSheet.create((theme) => ({
tooltipPath: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));
export const WEB_RENDERERS: MarkdownRenderers = { codeBlock: WebCodeBlock, link: WebLink };
+10 -8
View File
@@ -18,17 +18,19 @@ function linkMetadata() {
String.raw`^(?:${FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`,
),
},
linkSource(
url: string,
sourceType?: "markdown" | "text" | "inline-code",
): AssistantFileLinkSource {
if (sourceType === "inline-code") return { href: url, text: url, sourceType };
if (sourceType === "text") return { href: url, text: url, markup: "linkify" };
return { href: url, text: url };
},
linkSource: markdownLinkSource,
};
}
export function markdownLinkSource(
url: string,
sourceType?: "markdown" | "text" | "inline-code",
): AssistantFileLinkSource {
if (sourceType === "inline-code") return { href: url, text: url, sourceType };
if (sourceType === "text") return { href: url, text: url, markup: "linkify" };
return { href: url, text: url };
}
export interface MarkdownImage {
source: string;
alt: string;
File diff suppressed because one or more lines are too long