ui: Filesystem @mentions for Chat Form (#26715)

* base : @-mention picker foundation - glob search, picker nav, highlight

* feat : @-mention file/folder picker and mention badges in message bubbles

* fix: Imports

* feat : wire the @-mention picker into the chat form

* fix: Bound the glob-search result cache key and prune stale entries
This commit is contained in:
Aleksander Grygier
2026-08-07 18:45:54 +02:00
committed by GitHub
parent 4cb22cd537
commit 23634783c5
40 changed files with 1839 additions and 399 deletions
@@ -15,6 +15,7 @@
import { SvelteMap } from 'svelte/reactivity';
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
import { rehypeFileBadge } from './plugins/rehype/file-badge';
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
@@ -174,6 +175,7 @@
}) // Add syntax highlighting
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
.use(rehypeEnhanceLinks) // Add target="_blank" to links
.use(rehypeFileBadge) // Render file:// anchors as inline badge chips
.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
.use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
@@ -0,0 +1,100 @@
/**
* Rehype plugin that rewrites `file://` markdown anchors into the inline
* @-mention chip, reusing the visual contract from
* `$lib/constants/mention-badge`.
*
* The chip is presentational: `file://` navigation is blocked from
* http(s) pages, so the anchor becomes a plain `<span>` (no link role,
* no tab stop); the full path stays available on `title`.
*/
import { decodeFileLinkPath, getMentionBadgeIconPaths, getMentionBadgeLabel } from '$lib/utils';
import {
FILE_URI_PREFIX,
MENTION_BADGE_CLASSNAME,
MENTION_BADGE_ICON_CLASSNAME,
MENTION_BADGE_SVG_ATTRIBUTES,
PATH_SEPARATOR,
SETTINGS_KEYS
} from '$lib/constants';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { Plugin } from 'unified';
import type { Root, Element } from 'hast';
import { visit } from 'unist-util-visit';
// Trailing path separators mark a directory and are kept out of the label.
const TRAILING_SEPARATOR_REGEX = /\/+$/;
function decodeHrefPath(href: string): string {
const stripped = href.startsWith(FILE_URI_PREFIX) ? href.slice(FILE_URI_PREFIX.length) : href;
return decodeFileLinkPath(stripped);
}
function labelFromFileUrl(href: string): string {
const decoded = decodeHrefPath(href);
const trimmed = decoded.replace(TRAILING_SEPARATOR_REGEX, '');
const slash = trimmed.lastIndexOf(PATH_SEPARATOR);
return slash === -1 ? trimmed : trimmed.slice(slash + 1);
}
// A trailing `/` in the target marks a directory and selects the folder
// icon, matching the convention the mention picker inserts with.
function iconElement(href: string): Element {
return {
type: 'element',
tagName: 'svg',
properties: {
...MENTION_BADGE_SVG_ATTRIBUTES,
className: MENTION_BADGE_ICON_CLASSNAME.split(' ').filter(Boolean)
},
children: getMentionBadgeIconPaths(href).map((d) => ({
type: 'element',
tagName: 'path',
properties: { d },
children: []
}))
};
}
export const rehypeFileBadge: Plugin<[], Root> = () => {
return (tree: Root) => {
visit(tree, 'element', (node: Element) => {
if (node.tagName !== 'a') return;
const props = node.properties ?? {};
const href = typeof props.href === 'string' ? props.href : null;
if (!href || !href.startsWith(FILE_URI_PREFIX)) return;
const label = labelFromFileUrl(href);
const titleAttr = typeof props.title === 'string' ? props.title : href;
const decodedPath = decodeHrefPath(href);
node.tagName = 'span';
node.properties = {
className: MENTION_BADGE_CLASSNAME.split(' ').filter(Boolean),
title: titleAttr.startsWith(FILE_URI_PREFIX) ? decodedPath : titleAttr
};
node.children = [
iconElement(href),
{
type: 'element',
tagName: 'span',
properties: { className: ['shrink-0', 'truncate'] },
children: [
{
type: 'text',
value: getMentionBadgeLabel(
label,
decodedPath,
settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
toolsStore.serverHome
)
}
]
}
];
});
};
};