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
@@ -0,0 +1,67 @@
import { debounce } from '$lib/utils/debounce';
/**
* Shared debounced async-search machinery for the chat-form pickers:
* AbortController + sequence counter to discard stale responses, a
* debounce, and a live `isSearching` flag.
*/
export interface UseDebouncedSearchOptions {
debounceMs: number;
/** Fire-time guard: a scheduled call that outlives a reset is dropped. */
canRun: () => boolean;
/** Live query, used to drop a scheduled call whose query changed. */
getQuery: () => string;
/** Perform the search and commit results; bail out when `isCurrent()` is false. */
run: (query: string, signal: AbortSignal, isCurrent: () => boolean) => void | Promise<void>;
}
export function useDebouncedSearch(opts: UseDebouncedSearchOptions) {
let controller: AbortController | null = null;
let searchSeq = 0;
let isSearching = $state(false);
function isCurrent(seq: number) {
return seq === searchSeq;
}
function cancel() {
controller?.abort();
searchSeq++;
isSearching = false;
}
const schedule = debounce((query: string) => {
if (!opts.canRun() || query !== opts.getQuery().trim()) return;
void start(query);
}, opts.debounceMs);
async function start(query: string) {
cancel();
const fresh = new AbortController();
controller = fresh;
const mySeq = ++searchSeq;
isSearching = true;
try {
await opts.run(query, fresh.signal, () => isCurrent(mySeq));
} finally {
if (isCurrent(mySeq)) isSearching = false;
}
}
return {
get isSearching() {
return isSearching;
},
/** Bump the loading flag synchronously (e.g. before the debounce fires). */
setLoading(value: boolean) {
isSearching = value;
},
run(query: string) {
schedule(query);
},
cancel
};
}
export type UseDebouncedSearchReturn = ReturnType<typeof useDebouncedSearch>;
@@ -0,0 +1,108 @@
import { KeyboardKey } from '$lib/enums';
/**
* Shared keyboard navigation state for the chat-form pickers: a highlighted
* row, a scroll trigger, and Arrow/Escape/Enter handling.
*/
export interface UsePickerNavigationOptions {
/** Gates all key handling. */
isOpen: () => boolean;
count: () => number;
/**
* Resolve the row to highlight for a movement step, or -1 when no move
* is possible. Defaults to plain wraparound across `count()`.
*/
step?: (from: number, dir: 1 | -1) => number;
onClose: () => void;
/** Called on Enter when `hoveredIndex` points at a selectable row. */
onSelect: (index: number) => void;
}
function wrapStep(from: number, dir: 1 | -1, count: number): number {
return dir === 1 ? (from + 1) % count : from <= 0 ? count - 1 : from - 1;
}
export function usePickerNavigation(opts: UsePickerNavigationOptions) {
let hoveredIndex = $state(-1);
let scrollTrigger = $state(0);
function resolve(from: number, dir: 1 | -1): number {
const n = opts.count();
if (n === 0) return -1;
if (opts.step) return opts.step(from, dir);
return wrapStep(from, dir, n);
}
function move(dir: 1 | -1) {
const next = resolve(hoveredIndex, dir);
if (next >= 0) {
hoveredIndex = next;
scrollTrigger++;
}
}
/** Reset the highlight without bumping the scroll trigger. */
function reset(index: number) {
hoveredIndex = index;
}
/** Bump the scroll trigger without moving the highlight. */
function bumpScroll() {
scrollTrigger++;
}
/** Mouse hover highlights a row but must NOT bump the scroll trigger. */
function setHover(index: number) {
hoveredIndex = index;
}
function handleKeydown(event: KeyboardEvent): boolean {
if (!opts.isOpen()) return false;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
opts.onClose();
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
move(1);
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
move(-1);
return true;
}
if (event.key === KeyboardKey.ENTER) {
if (hoveredIndex >= 0 && hoveredIndex < opts.count()) {
event.preventDefault();
opts.onSelect(hoveredIndex);
return true;
}
// No selectable row - let the caller's Enter-to-submit run.
return false;
}
return false;
}
return {
get hoveredIndex() {
return hoveredIndex;
},
get scrollTrigger() {
return scrollTrigger;
},
reset,
setHover,
move,
bumpScroll,
handleKeydown
};
}
export type UsePickerNavigationReturn = ReturnType<typeof usePickerNavigation>;
@@ -0,0 +1,47 @@
import { untrack } from 'svelte';
/**
* Scrolls the highlighted row of a picker list into view when the scroll
* trigger is bumped, without scrolling on mouse hover or result
* replacement.
*/
export interface UseScrollActiveRowOptions {
/** Counter bumped by keyboard nav; `undefined` disables the effect. */
getTrigger: () => number | undefined;
getContainer: () => HTMLDivElement | null;
getIndex: () => number;
getCount: () => number;
/** Attribute prefix, e.g. 'picker' for `[data-picker-index="0"]`. */
dataIndex: string;
}
export function useScrollActiveRow(opts: UseScrollActiveRowOptions) {
let lastTrigger: number | null = null;
$effect(() => {
const trigger = opts.getTrigger();
if (trigger === undefined) return;
// Skip the initial run on mount: the list opens with the first row
// already in view, and scrolling here fires before the popover is
// positioned, which would scroll the whole page to the top.
if (lastTrigger === null) {
lastTrigger = trigger;
return;
}
if (trigger === lastTrigger) return;
lastTrigger = trigger;
untrack(() => {
const container = opts.getContainer();
const index = opts.getIndex();
if (!container || index < 0 || index >= opts.getCount()) return;
const row = container.querySelector(
`[data-${opts.dataIndex}-index="${index}"]`
) as HTMLElement | null;
row?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
});
}
export type UseScrollActiveRowReturn = ReturnType<typeof useScrollActiveRow>;