feat: add workspace mentions to AI composer (#11264)

* feat: add workspace mentions to AI composer

* fix: ignore embedded mention text in composer

* fix: sync only standalone composer mentions

* fix: keep punctuation-adjacent mentions active

* fix: use unicode mention boundaries

* fix: handle unicode mention prefixes

* fix: avoid rescanning mention prefixes
This commit is contained in:
Guilhem
2026-09-22 08:19:38 +00:00
committed by GitHub
parent faf7b22be0
commit e7ba5954b6
10 changed files with 384 additions and 47 deletions
@@ -13,9 +13,10 @@
item: Item
builders: ReturnType<typeof createDropdownMenu>['builders']
meltItem: MenubarMenuElements['item']
close?: (afterClose?: () => void) => void
}
let { item, builders, meltItem }: Props = $props()
let { item, builders, meltItem, close }: Props = $props()
const {
elements: { subTrigger, subMenu },
@@ -23,6 +24,8 @@
} = untrack(() => builders).createSubmenu()
let subItems = $derived((item.submenuItems ?? []).filter((i) => !i.hide))
const CustomSubmenu = $derived(item.customSubmenu)
const closeMenu = $derived(close ?? (() => {}))
</script>
<button
@@ -46,14 +49,20 @@
{#if $subOpen}
<div
use:melt={$subMenu}
class="z-[6000] bg-surface-tertiary dark:border w-48 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
class="z-[6000] bg-surface-tertiary dark:border {CustomSubmenu
? ''
: 'w-48 overflow-y-auto py-1'} origin-top-right rounded-lg shadow-lg focus:outline-none"
>
{#each subItems as subItem}
{#if subItem.separatorTop}
<div class="my-1 border-t border-border-light"></div>
{/if}
{@render subMenuItem(subItem)}
{/each}
{#if CustomSubmenu}
<CustomSubmenu {...item.customSubmenuProps} close={closeMenu} />
{:else}
{#each subItems as subItem}
{#if subItem.separatorTop}
<div class="my-1 border-t border-border-light"></div>
{/if}
{@render subMenuItem(subItem)}
{/each}
{/if}
</div>
{/if}
@@ -132,8 +132,9 @@
})
})
export function close() {
export function close(afterClose?: () => void) {
open = false
if (afterClose) setTimeout(afterClose, 10)
}
async function computeItems(): Promise<Item[]> {
@@ -209,7 +210,7 @@
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
style={`${customWidth ? `width: ${customWidth}px;` : ''} max-height: ${maxHeight || '50vh'};`}
>
<DropdownV2Inner {aiId} items={computeItems} meltItem={item} {builders} />
<DropdownV2Inner {aiId} items={computeItems} meltItem={item} {builders} {close} />
</div>
{/if}
</div>
@@ -13,9 +13,10 @@
items?: Item[] | (() => Item[]) | (() => Promise<Item[]>)
meltItem: MenubarMenuElements['item']
builders?: ReturnType<typeof createDropdownMenu>['builders']
close?: (afterClose?: () => void) => void
}
let { aiId, items = [], meltItem, builders }: Props = $props()
let { aiId, items = [], meltItem, builders, close }: Props = $props()
let computedItems: Item[] | undefined = $state(undefined)
async function computeItems() {
@@ -102,8 +103,8 @@
{#if item.separatorTop}
<div class="my-1 border-t border-border-light"></div>
{/if}
{#if item.submenuItems && builders}
<DropdownSubmenuItem {item} {builders} {meltItem} />
{#if (item.submenuItems || item.customSubmenu) && builders}
<DropdownSubmenuItem {item} {builders} {meltItem} close={close ?? (() => {})} />
{:else if item.disabled && item.tooltip}
<!-- Wrapper carries the native `title`; the disabled button's `pointer-events-none`
lets the hover reach it so the user learns why the item is disabled. -->
@@ -3,6 +3,7 @@
import AIChatMessage from './AIChatMessage.svelte'
import AppAvailableContextList from './AppAvailableContextList.svelte'
import ChatContextPicker from './ChatContextPicker.svelte'
import WorkspaceMentionPicker from './WorkspaceMentionPicker.svelte'
import { type Snippet } from 'svelte'
import {
AlertTriangle,
@@ -548,6 +549,11 @@
await imageWork
}
function mentionWorkspaceItem(element: ContextElement) {
void aiChatInput?.addContextToSelection(element)
aiChatInput?.insertMention(element.title)
}
function onFolderInputChange(e: Event) {
const input = e.currentTarget as HTMLInputElement
// webkitdirectory files carry webkitRelativePath (`folder/sub/file`); addFiles groups
@@ -1055,6 +1061,19 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
}
]
: []),
...(inGlobal
? [
{
displayName: 'Mention file',
icon: AtSign,
customSubmenu: WorkspaceMentionPicker,
customSubmenuProps: {
onSelect: mentionWorkspaceItem,
onAfterClose: () => aiChatInput?.focusInput()
}
}
]
: []),
...(skillItems
? [
{
@@ -14,7 +14,7 @@
import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox'
import { formatMention } from './mention'
import { formatMention, hasMention, removeMentionFromText } from './mention'
import { twMerge } from 'tailwind-merge'
import { tick, untrack, type Snippet } from 'svelte'
import Portal from '$lib/components/Portal.svelte'
@@ -588,40 +588,33 @@
const contextKey = contextElementKey
/** Append `@title` to the textarea so the button-picker path stays in
* sync with the inline `@<word>` mention path — both leave a visible
* token tied to the selectedContext entry, which the textarea diffs on
* to auto-remove items when the user deletes them. No-op when the
* mention is already present so re-picking the same item doesn't
* leave duplicate tokens. */
/** Insert `@title` so button/menu picker paths stay in sync with
* the inline `@<word>` mention path — both leave a visible token tied
* to the selectedContext entry, which the textarea diffs on to
* auto-remove items when the user deletes them. No-op when the same
* standalone mention is already present, so picking the same item
* again doesn't leave duplicate tokens. */
export function insertMention(title: string) {
const target = `@${title}`
if (draft.text.split(/\s+/).includes(target)) return
if (hasMention(draft.text, title)) return
if (contextTextareaComponent) {
void contextTextareaComponent.insertMention(title)
return
}
const sep = draft.text.length === 0 || /\s$/.test(draft.text) ? '' : ' '
draft.text = `${draft.text}${sep}${target} `
draft.text = `${draft.text}${sep}${formatMention(title)} `
}
/** Strip every `@title` token from the textarea — used when the user
* deletes the corresponding badge so the badge X-button mirrors the
* inverse (text-delete-to-badge-remove) sync. Only matches `@title` as a
* standalone token (boundary on both sides) so substring matches don't
* bleed into other words; only the whitespace adjacent to the removed
* mention is collapsed so unrelated double-spaces stay intact. */
* inverse (text-delete-to-badge-remove) sync. Only matches tokens whose
* `@` starts a word, so embedded text like `owner@app.ts` is left alone
* while punctuation and non-spacing text after the token still work. */
export function removeMention(title: string) {
// Pre-zap the textarea's mention diff snapshot so the upcoming strip
// doesn't refire the removal effect on a same-title sibling — the host
// has already mutated `selectedContext` to drop the targeted entry.
contextTextareaComponent?.unsyncMention(title)
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`(^|\\s)@${escaped}(\\s|$)`, 'g')
draft.text = draft.text.replace(re, (_m, lead, trail) => {
// Boundary on at least one side → drop the mention entirely.
if (!lead || !trail) return ''
// Middle of text: keep ONE of the bracketing whitespace chars so
// the surviving tokens are still separated; preserve the leading
// one verbatim so newlines/tabs aren't downgraded to spaces.
return lead
})
draft.text = removeMentionFromText(draft.text, title)
}
export function focusInput() {
@@ -10,7 +10,13 @@
import { zIndexes } from '$lib/zIndexes'
import { twMerge } from 'tailwind-merge'
import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext'
import { MENTION_RE, mentionTitle, formatMention } from './mention'
import {
MENTION_RE,
mentionTitle,
formatMention,
hasMentionLeadingBoundary,
mentionTitlesInText
} from './mention'
import { createFloatingActions, createVirtualElement } from 'svelte-floating-ui'
import { flip, offset, shift } from 'svelte-floating-ui/dom'
import {
@@ -65,17 +71,11 @@
const aiChatManager = getAiChatManager()
function extractMentions(text: string): Set<string> {
const out = new Set<string>()
for (const m of text.matchAll(MENTION_RE)) out.add(mentionTitle(m[0]))
return out
}
// Titles currently appearing as `@title` mentions in the textarea. Compared
// against the previous snapshot in a $effect (NOT inside handleInput —
// the picker mutates `value` programmatically via `updateInstructionsWithContext`,
// which doesn't fire `oninput`, so a handleInput-only diff goes stale).
const mentionedTitles = $derived(extractMentions(value))
const mentionedTitles = $derived(mentionTitlesInText(value))
let prevMentionedTitles = $state<Set<string>>(new Set())
let showContextTooltip = $state(false)
@@ -268,7 +268,11 @@
if (!att) return match
return `<span data-paste-id="${att.id}" class="rounded bg-surface-secondary text-secondary cursor-pointer pointer-events-auto">${match}</span>`
})
html = html.replace(MENTION_RE, (match) => {
html = html.replace(MENTION_RE, (match, ...args) => {
const offset = args[args.length - 2]
if (typeof offset !== 'number' || !hasMentionLeadingBoundary(html, offset)) {
return match
}
const title = unescapeHtml(mentionTitle(match))
const inContext =
availableContext.find((c) => c.title === title) ||
@@ -566,6 +570,23 @@
showContextTooltip = false
}
export async function insertMention(title: string) {
const token = formatMention(title)
const selectionStart = textarea?.selectionStart ?? value.length
const selectionEnd = textarea?.selectionEnd ?? selectionStart
const { from, to, ids } = tokensOverlapping(selectionStart, selectionEnd)
const before = value.slice(0, from)
const after = value.slice(to)
const prefix = before.length === 0 || /\s$/.test(before) ? '' : ' '
const suffix = after.length === 0 ? ' ' : /^\s/.test(after) ? '' : ' '
const inserted = `${prefix}${token}${suffix}`
replacePasteRange(from, to, ids, inserted)
await tick()
const pos = from + inserted.length
textarea?.setSelectionRange(pos, pos)
textarea?.focus()
}
function refreshCommandSkills() {
if (commandSkillsRefreshInFlight) return
commandSkillsRefreshInFlight = true
@@ -0,0 +1,148 @@
<!--
@component
Workspace-only picker for the AI session composer's `+ -> Mention file`
submenu. It mirrors ChatContextPicker's workspace branch, but starts directly
at the workspace root because the parent menu item already names the scope.
-->
<script lang="ts">
import { Folder } from 'lucide-svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import DrillPicker from '$lib/components/DrillPicker.svelte'
import type { DrillBranch, DrillLeaf, DrillNode } from '$lib/components/drillPicker'
import {
workspaceItemDisplayPath,
type WorkspaceItem,
type WorkspaceItemKind
} from '$lib/components/workspacePicker'
import { useWorkspaceItemsLoader } from '$lib/components/workspaceItemsLoader.svelte'
import { buildWorkspaceTree, relativizeWorkspacePath } from '$lib/components/workspaceTree'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import type {
ContextElement,
WorkspaceAppElement,
WorkspaceFlowElement,
WorkspaceScriptElement
} from './context'
interface Props {
onSelect: (element: ContextElement) => void
onAfterClose?: () => void
close: (afterClose?: () => void) => void
}
type DrillPickerHandle = {
handleKeydown: (e: KeyboardEvent) => void
}
let { onSelect, onAfterClose, close }: Props = $props()
let picker: DrillPickerHandle | undefined = $state()
const operatingWorkspace = useOperatingWorkspace()
const WORKSPACE_KINDS: WorkspaceItemKind[] = ['flow', 'script', 'app']
const loader = useWorkspaceItemsLoader(
() => $operatingWorkspace,
() => WORKSPACE_KINDS
)
const loadedForTree = $derived.by(() => {
const loaded = loader.loaded
if (!loaded.app) return loaded
return { ...loaded, app: loaded.app.filter((a) => a.raw_app) }
})
const tree = $derived<DrillNode<WorkspaceItem>[]>(
buildWorkspaceTree({
loaded: loadedForTree,
kinds: WORKSPACE_KINDS,
loadingKind: loader.loadingKind,
layout: 'flat'
}) as DrillNode<WorkspaceItem>[]
)
function workspaceElement(item: WorkspaceItem): ContextElement | undefined {
if (item.kind === 'script') {
return {
type: 'workspace_script',
path: item.path,
title: item.path,
summary: item.summary,
deletable: true
} satisfies WorkspaceScriptElement & { deletable: boolean }
}
if (item.kind === 'flow') {
return {
type: 'workspace_flow',
path: item.path,
title: item.path,
summary: item.summary,
deletable: true
} satisfies WorkspaceFlowElement & { deletable: boolean }
}
if (item.kind === 'app') {
return {
type: 'workspace_app',
path: item.path,
title: item.path,
summary: item.summary,
deletable: true
} satisfies WorkspaceAppElement & { deletable: boolean }
}
}
function handlePick(leaf: DrillLeaf<WorkspaceItem>) {
const element = workspaceElement(leaf.data)
if (!element) return
onSelect(element)
close(onAfterClose)
}
function handleScopeChange(_scope: string[]) {
loader.ensureAll()
}
const pickerKeys = new Set([
'ArrowDown',
'ArrowUp',
'ArrowLeft',
'ArrowRight',
'Backspace',
'Enter',
'Tab',
'Home',
'End'
])
function handleKeydownCapture(e: KeyboardEvent) {
if (!pickerKeys.has(e.key)) return
e.stopImmediatePropagation()
picker?.handleKeydown(e)
}
</script>
{#snippet leafIcon(leaf: DrillLeaf<WorkspaceItem>)}
<RowIcon kind={leaf.data.kind} size={12} />
{/snippet}
{#snippet branchIcon(branch: DrillBranch<WorkspaceItem>)}
{#if branch.icon}
{@const Icon = branch.icon}
<Icon size={12} class="shrink-0 text-tertiary" />
{:else}
<Folder size={12} class="shrink-0 text-tertiary" />
{/if}
{/snippet}
<!-- Capture picker-owned keys before Melt's submenu listener handles menu movement. -->
<div onkeydowncapture={handleKeydownCapture}>
<DrillPicker
bind:this={picker}
{tree}
onPick={handlePick}
{leafIcon}
{branchIcon}
leafSecondary={(leaf, scope) => relativizeWorkspacePath(workspaceItemDisplayPath(leaf.data), scope)}
onScopeChange={handleScopeChange}
onFilterChange={loader.onFilterChange}
rootLoading={WORKSPACE_KINDS.some((k) => !loader.loaded[k] && loader.loadingKind[k])}
/>
</div>
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'
import { MENTION_RE, mentionTitle, formatMention } from './mention'
import {
MENTION_RE,
mentionTitle,
formatMention,
hasMention,
mentionTitlesInText,
removeMentionFromText
} from './mention'
describe('formatMention', () => {
it('leaves a simple name bare', () => {
@@ -52,3 +59,83 @@ describe('MENTION_RE', () => {
expect(mentionTitle(m[0])).toBe(name)
})
})
describe('removeMentionFromText', () => {
it('removes a bare mention and keeps neighboring words separated', () => {
expect(removeMentionFromText('before @app.ts after', 'app.ts')).toBe('before after')
})
it('removes a bracketed mention', () => {
expect(removeMentionFromText('before @[my folder/a b.ts] after', 'my folder/a b.ts')).toBe(
'before after'
)
})
it('removes an escaped bracketed mention', () => {
expect(removeMentionFromText('before @[notes \\] draft.md] after', 'notes ] draft.md')).toBe(
'before after'
)
})
it('removes edge mentions without leaving extra whitespace', () => {
expect(removeMentionFromText('@app.ts after', 'app.ts')).toBe('after')
expect(removeMentionFromText('before @app.ts', 'app.ts')).toBe('before')
})
it('keeps embedded mention-like text intact', () => {
expect(removeMentionFromText('Contact owner@app.ts about @app.ts', 'app.ts')).toBe(
'Contact owner@app.ts about'
)
})
it('keeps Unicode-prefixed embedded mention-like text intact', () => {
expect(removeMentionFromText('Contact 用户@app.ts about @app.ts', 'app.ts')).toBe(
'Contact 用户@app.ts about'
)
expect(removeMentionFromText('Contact हिंदी@app.ts about @app.ts', 'app.ts')).toBe(
'Contact हिंदी@app.ts about'
)
expect(removeMentionFromText('Contact 𐐀@app.ts about @app.ts', 'app.ts')).toBe(
'Contact 𐐀@app.ts about'
)
})
})
describe('hasMention', () => {
it('does not treat embedded mention-like text as a selected context mention', () => {
expect(hasMention('Contact owner@app.ts', 'app.ts')).toBe(false)
expect(hasMention('Contact owner@app.ts about @app.ts', 'app.ts')).toBe(true)
})
it('does not treat Unicode-prefixed embedded text as a mention', () => {
expect(hasMention('Contact 用户@app.ts', 'app.ts')).toBe(false)
expect(hasMention('Contact हिंदी@app.ts', 'app.ts')).toBe(false)
expect(hasMention('Contact 𐐀@app.ts', 'app.ts')).toBe(false)
})
it('treats punctuation next to a mention as a boundary', () => {
expect(hasMention('use @app.ts, then compare', 'app.ts')).toBe(true)
expect(hasMention('open (@app.ts)', 'app.ts')).toBe(true)
})
})
describe('mentionTitlesInText', () => {
it('extracts only standalone mentions for context synchronization', () => {
expect([...mentionTitlesInText('Contact owner@app.ts about @app.ts')]).toEqual(['app.ts'])
expect([...mentionTitlesInText('Contact owner@app.ts')]).toEqual([])
})
it('ignores Unicode-prefixed embedded text for context synchronization', () => {
expect([...mentionTitlesInText('Contact 用户@app.ts')]).toEqual([])
})
it('keeps punctuation-adjacent mentions synchronized', () => {
expect([...mentionTitlesInText('use @app.ts, then compare')]).toEqual(['app.ts'])
expect([...mentionTitlesInText('open (@app.ts)')]).toEqual(['app.ts'])
})
it('ignores combining-mark and astral-letter embedded text', () => {
expect([...mentionTitlesInText('Contact हिंदी@app.ts')]).toEqual([])
expect([...mentionTitlesInText('Contact 𐐀@app.ts')]).toEqual([])
})
})
@@ -29,3 +29,59 @@ const BARE_SAFE = /^[\w/.\-]+$/
export function formatMention(name: string): string {
return BARE_SAFE.test(name) ? `@${name}` : `@[${name.replace(/[\\\]]/g, '\\$&')}]`
}
function isWordChar(char: string | undefined): boolean {
return char !== undefined && /[\p{L}\p{N}\p{M}_]/u.test(char)
}
function codePointBefore(text: string, index: number): string | undefined {
if (index <= 0) return undefined
const prev = text.charCodeAt(index - 1)
if (prev >= 0xdc00 && prev <= 0xdfff && index > 1) return text.slice(index - 2, index)
return text[index - 1]
}
// A mention is owned by this parser when the `@` starts a token. The trailing side is
// intentionally open: non-spacing scripts often continue immediately after a mention,
// while embedded forms like `owner@app.ts` are rejected by the leading boundary.
export function hasMentionLeadingBoundary(text: string, index: number): boolean {
return !isWordChar(codePointBefore(text, index))
}
export function isStandaloneMention(text: string, match: RegExpMatchArray): boolean {
if (match.index === undefined) return false
return hasMentionLeadingBoundary(text, match.index)
}
export function hasMention(text: string, title: string): boolean {
return [...text.matchAll(MENTION_RE)].some(
(m) => mentionTitle(m[0]) === title && isStandaloneMention(text, m)
)
}
export function mentionTitlesInText(text: string): Set<string> {
const out = new Set<string>()
for (const m of text.matchAll(MENTION_RE)) {
if (isStandaloneMention(text, m)) out.add(mentionTitle(m[0]))
}
return out
}
export function removeMentionFromText(text: string, title: string): string {
let out = ''
let last = 0
for (const m of text.matchAll(MENTION_RE)) {
if (m.index === undefined || mentionTitle(m[0]) !== title) continue
let start = m.index
let end = m.index + m[0].length
if (!isStandaloneMention(text, m)) continue
const hasLead = start > 0 && /\s/.test(text[start - 1])
const hasTrail = end < text.length && /\s/.test(text[end])
if (hasLead && !hasTrail) start -= 1
if (!hasLead && hasTrail) end += 1
if (hasLead && hasTrail) end += 1
out += text.slice(last, start)
last = end
}
return out + text.slice(last)
}
+2
View File
@@ -1677,6 +1677,8 @@ export type Item = {
iconColor?: string
/** Extra props for `icon`, for an icon that does not take lucide's `size`. */
iconProps?: Record<string, any>
customSubmenu?: import('svelte').Component<any>
customSubmenuProps?: Record<string, unknown>
href?: string
hrefTarget?: '_blank' | '_self' | '_parent' | '_top'
disabled?: boolean