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,8 +15,7 @@
SETTING_CONFIG_DEFAULT,
INITIAL_FILE_SIZE,
PROMPT_CONTENT_SEPARATOR,
PROMPT_TRIGGER_PREFIX,
RESOURCE_TRIGGER_PREFIX
PROMPT_TRIGGER_PREFIX
} from '$lib/constants';
import {
ContentPartType,
@@ -39,8 +38,23 @@
activeConversation,
pendingCwd
} from '$lib/stores/conversations.svelte';
import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types';
import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils';
import type {
FileMentionEntry,
GetPromptResult,
MCPPromptInfo,
MCPResourceInfo,
PromptMessage
} from '$lib/types';
import {
buildMentionInsertion,
findMentionToken,
isIMEComposing,
mentionLinkEndingAt,
parseClipboardContent,
takeMentionDismissSnapshot,
type MentionDismissSnapshot,
uuid
} from '$lib/utils';
import {
AudioRecorder,
convertToWav,
@@ -108,11 +122,18 @@
let isRecording = $state(false);
let recordingSupported = $state(false);
// Invisible anchor at the form's top edge so the mention popover floats above the box.
let mentionAnchor: HTMLDivElement | null = $state(null);
// Picker State
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
let isInlineResourcePickerOpen = $state(false);
let resourceSearchQuery = $state('');
let isMentionPickerOpen = $state(false);
let mentionQuery = $state('');
// Last dismissed `@`-mention token; while intact the picker does not
// reopen, so an escaped `@<query>` stays literal until edited.
let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
@@ -219,26 +240,44 @@
function handleInput() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
const cursor = textareaRef?.getCaretOffset() ?? value.length;
const mentionToken = findMentionToken(value, cursor);
// A `@` mention takes precedence; typing one switches from any other open picker.
if (mentionToken && mentionToken.query.length > 0) {
isPromptPickerOpen = false;
promptSearchQuery = '';
const isDismissedSticky =
mentionDismissedSnapshot !== null &&
mentionDismissedSnapshot.start === mentionToken.start &&
mentionDismissedSnapshot.query === mentionToken.query;
if (!isDismissedSticky) {
mentionDismissedSnapshot = null;
isMentionPickerOpen = true;
mentionQuery = mentionToken.query;
return;
}
isMentionPickerOpen = false;
mentionQuery = '';
return;
}
isMentionPickerOpen = false;
mentionQuery = '';
// Token gone or changed: reset the snapshot so a fresh `@` reopens.
if (mentionDismissedSnapshot !== null && !mentionToken) {
mentionDismissedSnapshot = null;
}
if (value.startsWith(PROMPT_TRIGGER_PREFIX) && hasServers) {
isPromptPickerOpen = true;
promptSearchQuery = value.slice(1);
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
} else if (
value.startsWith(RESOURCE_TRIGGER_PREFIX) &&
hasServers &&
mcpStore.hasResourcesCapability(perChatOverrides)
) {
isInlineResourcePickerOpen = true;
resourceSearchQuery = value.slice(1);
isPromptPickerOpen = false;
promptSearchQuery = '';
} else {
isPromptPickerOpen = false;
promptSearchQuery = '';
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
}
}
@@ -247,15 +286,30 @@
return;
}
// Backspace at a mention link's end deletes the whole token at once.
if (event.key === KeyboardKey.BACKSPACE && !event.ctrlKey && !event.metaKey && !event.altKey) {
const el = textareaRef?.getElement();
if (el instanceof HTMLTextAreaElement && el.selectionStart === el.selectionEnd) {
const link = mentionLinkEndingAt(value, el.selectionStart);
if (link) {
event.preventDefault();
value = value.slice(0, link.start) + value.slice(link.end);
onValueChange?.(value);
queueMicrotask(() => textareaRef?.setCaretOffset(link.start));
return;
}
}
}
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
isPromptPickerOpen = false;
promptSearchQuery = '';
return;
}
if (event.key === KeyboardKey.ESCAPE && isInlineResourcePickerOpen) {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
if (event.key === KeyboardKey.ESCAPE && isMentionPickerOpen) {
isMentionPickerOpen = false;
mentionQuery = '';
return;
}
@@ -432,33 +486,33 @@
textareaRef?.focus();
}
function handleInlineResourcePickerClose() {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
textareaRef?.focus();
function handleMentionPickerClose() {
if (isMentionPickerOpen) {
const cursor = textareaRef?.getCaretOffset() ?? value.length;
mentionDismissedSnapshot = takeMentionDismissSnapshot(value, cursor);
}
isMentionPickerOpen = false;
mentionQuery = '';
refocusInput();
}
function handleInlineResourceSelect() {
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
// Splice the `[name](file:///<abs path>)` link in place of the `@<query>`
// token, restoring the caret after the bindable value settles.
function handleMentionSelect(entry: FileMentionEntry) {
const cursor = textareaRef?.getCaretOffset() ?? value.length;
const token = findMentionToken(value, cursor);
if (!token) return;
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
textareaRef?.focus();
}
const built = buildMentionInsertion(entry, value, token);
if (!built) return;
function handleBrowseResources() {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
value = built.newValue;
onValueChange?.(built.newValue);
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isResourceDialogOpen = true;
queueMicrotask(() => {
textareaRef?.focus();
textareaRef?.setCaretOffset(built.caretOffset);
});
}
async function handleMicClick() {
@@ -505,17 +559,25 @@
bind:this={pickersRef}
{isPromptPickerOpen}
{promptSearchQuery}
{isInlineResourcePickerOpen}
{resourceSearchQuery}
{isMentionPickerOpen}
{mentionQuery}
{mentionAnchor}
scopePath={cwd}
onPromptPickerClose={handlePromptPickerClose}
onInlineResourcePickerClose={handleInlineResourcePickerClose}
onInlineResourceSelect={handleInlineResourceSelect}
onMentionPickerClose={handleMentionPickerClose}
onMentionOpened={() => textareaRef?.focus()}
onMentionSelect={handleMentionSelect}
onPromptLoadStart={handlePromptLoadStart}
onPromptLoadComplete={handlePromptLoadComplete}
onPromptLoadError={handlePromptLoadError}
onInlineResourceBrowse={handleBrowseResources}
/>
<div
bind:this={mentionAnchor}
class="pointer-events-none absolute top-0 right-0 left-0 h-px"
aria-hidden="true"
></div>
<div
class="{INPUT_CLASSES} overflow-hidden rounded-4xl md:rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60'
@@ -0,0 +1,258 @@
<script lang="ts">
import { File, Folder } from '@lucide/svelte';
import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { BuiltInTool, FileMentionEntryType, GlobSearchType } from '$lib/enums';
import { isMobile } from '$lib/stores/viewport.svelte';
import { config } from '$lib/stores/settings.svelte';
import * as Popover from '$lib/components/ui/popover';
import * as Tooltip from '$lib/components/ui/tooltip';
import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import type { FileMentionEntry } from '$lib/types';
import {
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
HOME_TILDE,
SEARCH_DEBOUNCE_MS
} from '$lib/constants';
/**
* Floating file/folder mention picker. The chat input is the search
* surface: `query` (typed after `@`) drives a `file_glob_search` tool
* call scoped to `scopePath`. The parent owns the "dismissed token,
* don't re-open until it changes" snapshot.
*/
interface Props {
class?: string;
isOpen: boolean;
query: string;
customAnchor?: HTMLElement | null;
scopePath?: string | null;
onClose: () => void;
onSelect: (entry: FileMentionEntry) => void;
/** Fired when `isOpen` becomes true, so the host can keep focus on the chat input. */
onOpened?: () => void;
}
let {
class: className = '',
isOpen,
query,
customAnchor = null,
scopePath = null,
onClose,
onSelect,
onOpened
}: Props = $props();
const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => displayedItems.length,
onClose: () => onClose(),
onSelect: (index) => handleSelect(displayedItems[index])
});
// When the server does not expose file_glob_search (started without
// --tools) or the user disabled it, the picker still opens but explains
// why instead of firing searches that would only fail.
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.FILE_GLOB_SEARCH));
const fileSearchEnabled = $derived(
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
);
let searchResults = $state<FileMentionEntry[]>([]);
let searchError = $state<string | null>(null);
// Coerce the depth setting to a positive integer; an invalid value
// would otherwise reach the server as max_depth 0 = unlimited.
const searchDepth = $derived.by(() => {
const n = Number(config().mentionSearchMaxDepth);
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH;
});
const home = $derived(toolsStore.serverHome);
// A smaller window than the WD picker suffices: entries are ranked client-side.
const MENTION_SEARCH_LIMIT = 50;
const search = useDebouncedSearch({
debounceMs: SEARCH_DEBOUNCE_MS,
canRun: () => isOpen && fileSearchEnabled,
getQuery: () => trimmedQuery,
run: async (query, signal, isCurrent) => {
try {
// A trailing path separator targets a directory, so also list its
// children. Accept both `/` and `\`.
const res = await runGlobSearchWithChildren(
query,
scopePath ?? home ?? HOME_TILDE,
searchDepth,
MENTION_SEARCH_LIMIT,
signal,
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
);
if (!isCurrent()) return;
if (res.error) {
searchResults = [];
searchError = res.error;
return;
}
const toEntry = (e: GlobEntryResult): FileMentionEntry => ({
path: e.path,
name: e.name,
type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE
});
searchResults = res.entries.map(toEntry);
searchError = null;
} catch (err) {
if (!isCurrent() || signal.aborted) return;
searchResults = [];
searchError = err instanceof Error ? err.message : String(err);
}
}
});
const trimmedQuery = $derived((query ?? '').trim());
const displayedItems = $derived(searchResults);
const emptyMessage = $derived.by(() => {
if (fileSearchKey === null) {
return 'File search is unavailable on this server (started without --tools)';
}
if (!fileSearchEnabled) {
return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions';
}
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
});
const showTooltip = $derived(!isMobile.current);
$effect(() => {
if (typeof window === 'undefined') return;
void toolsStore.resolveServerHome();
});
$effect(() => {
if (isOpen) {
nav.reset(0);
}
});
$effect(() => {
if (isOpen) onOpened?.();
});
$effect(() => {
const q = (query ?? '').trim();
if (!isOpen || !q || !fileSearchEnabled) {
search.cancel();
searchResults = [];
searchError = null;
return;
}
search.setLoading(true);
search.run(q);
});
function handleSelect(entry: FileMentionEntry) {
onSelect(entry);
onClose();
}
export function handleKeydown(event: KeyboardEvent): boolean {
return nav.handleKeydown(event);
}
</script>
<Popover.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<!-- Invisible form-wide trigger: stops bits-ui's outside-click detector
from closing the picker when the user clicks inside the textarea.
We open programmatically via `open={isOpen}`, so it is inert
(tabindex=-1 + pointer-events-none + opacity-0 + aria-hidden).
Positioning comes from `customAnchor` at the form's top edge. -->
<Popover.Trigger
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
aria-hidden="true"
>
<span class="sr-only">Open file mention picker</span>
</Popover.Trigger>
<Popover.Content
align="start"
side="top"
sideOffset={12}
{customAnchor}
preventScroll={false}
onkeydown={handleKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
class={[
'w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl',
className
]}
>
<ChatFormPickerList
items={displayedItems}
isLoading={search.isSearching}
selectedIndex={nav.hoveredIndex}
showSearchInput={false}
searchQuery={query ?? ''}
{emptyMessage}
itemKey={(entry) => entry.type + ':' + entry.path}
scrollTrigger={nav.scrollTrigger}
>
{#snippet item(entry, index, isSelected)}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onclick={() => handleSelect(entry)}
onmouseenter={() => nav.setHover(index)}
>
{@const Icon = entry.type === FileMentionEntryType.DIRECTORY ? Folder : File}
<Icon
class={[
'mt-0.5 h-4 w-4 shrink-0',
entry.type === FileMentionEntryType.DIRECTORY
? 'text-amber-500'
: 'text-muted-foreground'
]}
/>
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-w-0 items-center gap-2">
{#if showTooltip}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<span {...props} class="truncate text-sm font-medium">{entry.name}</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{entry.path}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="truncate text-sm font-medium">{entry.name}</span>
{/if}
<span
class="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-muted-foreground"
>
{entry.type}
</span>
</div>
<span class="min-w-0 flex-1 truncate font-mono text-left text-xs">
<HighlightedMatch text={abbreviateHome(entry.path, home)} query={trimmedQuery} />
</span>
</div>
</ChatFormPickerListItem>
{/snippet}
</ChatFormPickerList>
</Popover.Content>
</Popover.Root>
@@ -2,6 +2,7 @@
import type { Snippet } from 'svelte';
import { SearchInput } from '$lib/components/app';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
interface Props {
@@ -11,11 +12,19 @@
searchQuery: string;
showSearchInput: boolean;
searchPlaceholder?: string;
// Omit to distinguish "haven't searched yet" from "search returned nothing".
emptyMessage?: string;
autofocus?: boolean;
inputRef?: HTMLInputElement | null;
onSearchClose?: () => void;
itemKey: (item: T, index: number) => string;
item: Snippet<[T, number, boolean]>;
skeleton?: Snippet;
skeletonCount?: number;
footer?: Snippet;
// Counter bumped by the picker on keyboard nav; scrolls the selected
// row into view without scrolling on hover or result replacement.
scrollTrigger?: number;
}
let {
@@ -25,49 +34,69 @@
searchQuery = $bindable(),
showSearchInput,
searchPlaceholder = 'Search...',
emptyMessage = 'No items available',
emptyMessage,
autofocus = false,
inputRef = $bindable(null),
onSearchClose,
itemKey,
item,
skeleton,
footer
skeletonCount = 6,
footer,
scrollTrigger
}: Props = $props();
let listContainer = $state<HTMLDivElement | null>(null);
$effect(() => {
if (listContainer && selectedIndex >= 0 && selectedIndex < items.length) {
const selectedElement = listContainer.querySelector(
`[data-picker-index="${selectedIndex}"]`
) as HTMLElement;
let listPaddingTop = $derived(
showSearchInput ? (isLoading || items.length > 0 ? 'pt-13' : 'pt-10') : ''
);
if (selectedElement) {
selectedElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
}
// selectedIndex/items.length are untracked so hover and result replacement
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
useScrollActiveRow({
getTrigger: () => scrollTrigger,
getContainer: () => listContainer,
getIndex: () => selectedIndex,
getCount: () => items.length,
dataIndex: 'picker'
});
</script>
<ScrollArea>
{#if showSearchInput}
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
<SearchInput placeholder={searchPlaceholder} bind:value={searchQuery} />
<SearchInput
{autofocus}
placeholder={searchPlaceholder}
bind:value={searchQuery}
bind:ref={inputRef}
onClose={onSearchClose}
/>
</div>
{/if}
<div
bind:this={listContainer}
class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, showSearchInput && 'pt-13']}
>
<div bind:this={listContainer} class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, listPaddingTop]}>
{#if isLoading}
{#if skeleton}
{@render skeleton()}
{:else}
<div aria-busy="true" aria-live="polite" class="flex flex-col">
{#each { length: skeletonCount } as _, rowIndex (rowIndex)}
<div class="flex items-start gap-3 rounded-lg px-3 py-2">
<div class="mt-0.5 size-4 shrink-0 animate-pulse rounded-md bg-muted/60"></div>
<div class="flex min-w-0 flex-1 flex-col">
<div class="h-5 w-2/5 animate-pulse rounded-sm bg-muted/60"></div>
<div class="h-4 w-1/3 animate-pulse rounded-sm bg-muted/40"></div>
</div>
</div>
{/each}
</div>
{/if}
{:else if items && items.length === 0}
{#if emptyMessage}
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{/if}
{:else if items.length === 0}
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{:else}
{#each items as itemData, index (itemKey(itemData, index))}
{@render item(itemData, index, index === selectedIndex)}
@@ -3,21 +3,34 @@
interface Props {
isSelected?: boolean;
disabled?: boolean;
onclick: () => void;
onmouseenter?: () => void;
dataIndex?: number;
children: Snippet;
class?: string;
}
let { isSelected = false, onclick, dataIndex, children }: Props = $props();
let {
class: className = '',
isSelected = false,
disabled = false,
onclick,
onmouseenter,
dataIndex,
children
}: Props = $props();
</script>
<button
type="button"
data-picker-index={dataIndex}
{disabled}
{onclick}
{onmouseenter}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
? 'bg-accent/50'
: ''}"
: ''} {disabled ? 'cursor-not-allowed opacity-50' : ''} {className}"
>
{@render children()}
</button>
@@ -42,6 +42,7 @@
align="start"
sideOffset={12}
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
preventScroll={false}
onkeydown={onKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
>
@@ -1,237 +0,0 @@
<script lang="ts">
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
import { KeyboardKey } from '$lib/enums';
import type { MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
import { FolderOpen } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import {
ChatFormPickerPopover,
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerItemHeader,
ChatFormPickerListItemSkeleton
} from '$lib/components/app/chat';
interface Props {
class?: string;
isOpen?: boolean;
searchQuery?: string;
onClose?: () => void;
onResourceSelect?: (resource: MCPResourceInfo) => void;
onBrowse?: () => void;
}
let {
class: className = '',
isOpen = false,
searchQuery = '',
onClose,
onResourceSelect,
onBrowse
}: Props = $props();
let resources = $state<MCPResourceInfo[]>([]);
let isLoading = $state(false);
let selectedIndex = $state(0);
let internalSearchQuery = $state('');
let serverSettingsMap = $derived.by(() => {
const servers = mcpStore.getServers();
const map = new SvelteMap<string, MCPServerSettingsEntry>();
for (const server of servers) {
map.set(server.id, server);
}
return map;
});
$effect(() => {
if (isOpen) {
loadResources();
selectedIndex = 0;
}
});
$effect(() => {
if (filteredResources.length > 0 && selectedIndex >= filteredResources.length) {
selectedIndex = 0;
}
});
async function loadResources() {
isLoading = true;
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
resources = [];
return;
}
await mcpStore.fetchAllResources();
resources = mcpResourceStore.getAllResourceInfos();
} catch (error) {
console.error('[ChatFormPickerMcpResources] Failed to load resources:', error);
resources = [];
} finally {
isLoading = false;
}
}
function handleResourceClick(resource: MCPResourceInfo) {
mcpStore.attachResource(resource.uri);
onResourceSelect?.(resource);
onClose?.();
}
function isResourceAttached(uri: string): boolean {
return mcpResourceStore.isAttached(uri);
}
export function handleKeydown(event: KeyboardEvent): boolean {
if (!isOpen) return false;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
onClose?.();
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (filteredResources.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredResources.length;
}
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (filteredResources.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredResources.length - 1 : selectedIndex - 1;
}
return true;
}
if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (filteredResources[selectedIndex]) {
handleResourceClick(filteredResources[selectedIndex]);
}
return true;
}
return false;
}
let filteredResources = $derived.by(() => {
const sortedServers = mcpStore.getServers();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedResources = [...resources].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const query = (searchQuery || internalSearchQuery).toLowerCase();
if (!query) return sortedResources;
return sortedResources.filter(
(resource) =>
resource.name.toLowerCase().includes(query) ||
resource.title?.toLowerCase().includes(query) ||
resource.description?.toLowerCase().includes(query) ||
resource.uri.toLowerCase().includes(query)
);
});
let showSearchInput = $derived(resources.length > 3);
</script>
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open resource picker"
{onClose}
onKeydown={handleKeydown}
>
<ChatFormPickerList
items={filteredResources}
{isLoading}
{selectedIndex}
bind:searchQuery={internalSearchQuery}
{showSearchInput}
searchPlaceholder="Search resources..."
emptyMessage="No MCP resources available"
itemKey={(resource) => resource.serverName + ':' + resource.uri}
>
{#snippet item(resource, index, isSelected)}
{@const server = serverSettingsMap.get(resource.serverName)}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onclick={() => handleResourceClick(resource)}
>
<ChatFormPickerItemHeader
{server}
{serverLabel}
title={resource.title || resource.name}
description={resource.description}
>
{#snippet titleExtra()}
{#if isResourceAttached(resource.uri)}
<span
class="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
>
attached
</span>
{/if}
{/snippet}
{#snippet subtitle()}
<p class="mt-0.5 truncate text-xs text-muted-foreground/60">
{resource.uri}
</p>
{/snippet}
</ChatFormPickerItemHeader>
</ChatFormPickerListItem>
{/snippet}
{#snippet skeleton()}
<ChatFormPickerListItemSkeleton />
{/snippet}
{#snippet footer()}
{#if onBrowse && resources.length > 3}
<Button
class="fixed right-3 bottom-3"
type="button"
onclick={onBrowse}
variant="secondary"
size="sm"
>
<FolderOpen class="h-3 w-3" />
Browse all
</Button>
{/if}
{/snippet}
</ChatFormPickerList>
</ChatFormPickerPopover>
@@ -1,16 +1,19 @@
<script lang="ts">
import ChatFormMentionPicker from './ChatFormMentionPicker.svelte';
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
import ChatFormPickerMcpResources from './ChatFormPickerMcpResources.svelte';
import type { GetPromptResult, MCPPromptInfo } from '$lib/types';
import type { FileMentionEntry, GetPromptResult, MCPPromptInfo } from '$lib/types';
interface Props {
isPromptPickerOpen?: boolean;
promptSearchQuery?: string;
isInlineResourcePickerOpen?: boolean;
resourceSearchQuery?: string;
isMentionPickerOpen?: boolean;
mentionQuery?: string;
mentionAnchor?: HTMLElement | null;
scopePath?: string | null;
onPromptPickerClose?: () => void;
onInlineResourcePickerClose?: () => void;
onInlineResourceSelect?: () => void;
onMentionPickerClose?: () => void;
onMentionOpened?: () => void;
onMentionSelect?: (entry: FileMentionEntry) => void;
onPromptLoadStart?: (
placeholderId: string,
promptInfo: MCPPromptInfo,
@@ -18,25 +21,26 @@
) => void;
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
onPromptLoadError?: (placeholderId: string, error: string) => void;
onInlineResourceBrowse?: () => void;
}
let {
isPromptPickerOpen,
promptSearchQuery,
isInlineResourcePickerOpen,
resourceSearchQuery,
isMentionPickerOpen,
mentionQuery,
mentionAnchor,
scopePath,
onPromptPickerClose,
onInlineResourcePickerClose,
onInlineResourceSelect,
onMentionPickerClose,
onMentionOpened,
onMentionSelect,
onPromptLoadStart,
onPromptLoadComplete,
onPromptLoadError,
onInlineResourceBrowse
onPromptLoadError
}: Props = $props();
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
let resourcePickerRef: ChatFormPickerMcpResources | undefined = $state(undefined);
let mentionPickerRef: ChatFormMentionPicker | undefined = $state(undefined);
/**
* Delegates keyboard events to the active picker child.
@@ -47,7 +51,7 @@
return true;
}
if (isInlineResourcePickerOpen && resourcePickerRef?.handleKeydown(event)) {
if (isMentionPickerOpen && mentionPickerRef?.handleKeydown(event)) {
return true;
}
@@ -65,11 +69,13 @@
{onPromptLoadError}
/>
<ChatFormPickerMcpResources
bind:this={resourcePickerRef}
isOpen={isInlineResourcePickerOpen}
searchQuery={resourceSearchQuery}
onClose={onInlineResourcePickerClose}
onResourceSelect={onInlineResourceSelect}
onBrowse={onInlineResourceBrowse}
<ChatFormMentionPicker
bind:this={mentionPickerRef}
isOpen={isMentionPickerOpen ?? false}
query={mentionQuery ?? ''}
customAnchor={mentionAnchor}
scopePath={scopePath ?? null}
onClose={onMentionPickerClose ?? (() => {})}
onOpened={onMentionOpened}
onSelect={onMentionSelect ?? (() => {})}
/>
@@ -48,6 +48,16 @@
textareaElement.style.height = '1rem';
}
}
// Plain-text caret offsets for the mention-splice flow.
export function getCaretOffset(): number {
if (!textareaElement) return 0;
return textareaElement.selectionStart ?? textareaElement.value.length;
}
export function setCaretOffset(offset: number) {
textareaElement?.setSelectionRange(offset, offset);
}
</script>
<div class="flex-1 {className}">
+9 -20
View File
@@ -351,14 +351,14 @@ export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/Cha
* Generic scrollable list for picker popovers. Provides search input,
* scroll-into-view for keyboard navigation, loading skeletons, empty state,
* and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
*/
export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte';
/**
* Generic button wrapper for picker list items. Provides consistent styling,
* hover/selected states, and data-picker-index attribute for scroll-into-view.
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
*/
export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte';
@@ -376,30 +376,19 @@ export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPickers/
export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte';
/**
* **ChatFormPickerMcpResources** - MCP resource selection interface
*
* Floating picker for browsing and attaching MCP Server Resources.
* Triggered by typing `@` in the chat input.
* Loads resources from connected MCP servers and allows users to attach them to the chat context.
*
* **Features:**
* - Search/filter resources by name, title, description, or URI across all connected servers
* - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close)
* - Shows attached state for already-attached resources
* - Loading states with skeleton placeholders
* - Server information header per resource for visual identification
*
* **Exported API:**
* - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
* `@`-triggered file/folder mention picker. Resolves `@<query>` in the chat
* input to a filesystem match via the server's `file_glob_search` built-in
* tool, scoped to the conversation cwd (or server home when unset).
* Selection splices a `[name](file:///<abs path>)` link into the input.
*/
export { default as ChatFormPickerMcpResources } from './ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte';
export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
/**
* **ChatFormPickers** - Chat input picker container
*
* Container component that hosts both MCP prompt and MCP resource pickers.
* Container component that hosts the MCP prompt and file mention pickers.
* Manages shared state, keyboard navigation, and coordination between the two
* picker interfaces. Used within ChatForm for `@`-triggered pickers.
* picker interfaces. Used within ChatForm.
*/
export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte';