diff --git a/opensquilla-webui/src/components/chat/ChatComposer.vue b/opensquilla-webui/src/components/chat/ChatComposer.vue index 21bd82bc14..042b54addd 100644 --- a/opensquilla-webui/src/components/chat/ChatComposer.vue +++ b/opensquilla-webui/src/components/chat/ChatComposer.vue @@ -180,9 +180,9 @@ :disabled="inputDisabled" :aria-label="t('chat.messageToSend')" :aria-describedby="sendControlHint ? sendTooltipId : undefined" - :aria-controls="paletteOpen ? paletteId || 'chat-slash-options' : undefined" - :aria-expanded="paletteOpen" - :aria-activedescendant="paletteOpen ? `${paletteId || 'chat-slash'}-option-${paletteActiveIndex}` : undefined" + :aria-controls="activePaletteOpen ? activePaletteId : undefined" + :aria-expanded="activePaletteOpen" + :aria-activedescendant="activePaletteOpen ? `${activePaletteId}-option-${activePaletteIndex}` : undefined" @beforeinput="emit('expand'); emit('beforeinput', $event)" @input="onTextareaInput" @keydown="emit('keydown', $event)" @@ -530,6 +530,10 @@ const props = withDefaults(defineProps<{ paletteOpen?: boolean paletteId?: string paletteActiveIndex?: number + /** The `@` mention palette takes over the editor's combobox semantics. */ + mentionOpen?: boolean + mentionPaletteId?: string + mentionActiveIndex?: number attachments: Attachment[] localPaths?: readonly string[] chooseAttachments?: () => Promise @@ -671,6 +675,16 @@ const runModeLabel = computed(() => t( props.runMode === 'full' ? 'chat.composer.runModeFull' : 'chat.composer.runModeSafe', )) +// The slash palette and the mention palette share one combobox editor: whichever +// is open owns aria-controls / aria-activedescendant, and the mention wins ties. +const activePaletteOpen = computed(() => Boolean(props.mentionOpen || props.paletteOpen)) +const activePaletteId = computed(() => props.mentionOpen + ? (props.mentionPaletteId || 'chat-mention-options') + : (props.paletteId || 'chat-slash-options')) +const activePaletteIndex = computed(() => props.mentionOpen + ? (props.mentionActiveIndex ?? 0) + : props.paletteActiveIndex) + const showSkillQueueSend = computed(() => props.canStop && props.isStreaming && !props.stopTargetsPlanRun @@ -944,6 +958,7 @@ watch(() => props.collapsed, (collapsed) => { if (collapsed) closeAllPopovers() }) watch(() => props.paletteOpen, open => { if (open) closeAllPopovers() }) +watch(() => props.mentionOpen, open => { if (open) closeAllPopovers() }) watch(() => props.draftKey, closeAllPopovers) function toggleAddMenu() { diff --git a/opensquilla-webui/src/components/chat/ChatMentionPalette.test.ts b/opensquilla-webui/src/components/chat/ChatMentionPalette.test.ts new file mode 100644 index 0000000000..ceeac487ce --- /dev/null +++ b/opensquilla-webui/src/components/chat/ChatMentionPalette.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom +import { createApp, h, nextTick, type App } from 'vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import i18n from '@/i18n' +import type { MentionCandidate } from '@/composables/chat/useChatMentions' +import ChatMentionPalette from './ChatMentionPalette.vue' + +const t = i18n.global.t + +// The palette only depends on `kind` plus a label/description, so the fixtures are +// cast once instead of mirroring unrelated mention-source metadata. +const directory = { kind: 'workspace_directory', label: 'src', description: '3 files' } as unknown as MentionCandidate +const file = { kind: 'workspace_file', label: 'src/math.py', description: 'Workspace file' } as unknown as MentionCandidate +const secondFile = { kind: 'workspace_file', label: 'README.md', description: 'Workspace file' } as unknown as MentionCandidate +const session = { kind: 'session', label: 'Refactor the parser', description: 'Yesterday' } as unknown as MentionCandidate + +const apps: App[] = [] +function mount(props: Record = {}) { + const host = document.createElement('div') + document.body.appendChild(host) + const app = createApp({ + render: () => h(ChatMentionPalette, { items: [], activeIndex: 0, loading: false, error: '', ...props }), + }) + app.use(i18n) + app.mount(host) + apps.push(app) + return host +} +function key(element: Element, value: string, options: KeyboardEventInit = {}) { + const event = new KeyboardEvent('keydown', { key: value, bubbles: true, cancelable: true, ...options }) + element.dispatchEvent(event) + return event +} +afterEach(() => { apps.splice(0).forEach(app => app.unmount()); document.body.innerHTML = '' }) + +describe('chat mention palette', () => { + it('groups workspace candidates before chats and renders one row per candidate', async () => { + const host = mount({ items: [directory, file, session, secondFile] }) + await nextTick() + + const groups = [...host.querySelectorAll('[role="group"]')] + expect(groups).toHaveLength(2) + expect(groups.map(group => group.getAttribute('aria-label'))).toEqual([ + t('chat.mention.groups.workspace'), + t('chat.mention.groups.sessions'), + ]) + expect(groups[0]!.querySelector('.chat-slash-group')?.textContent).toBe(t('chat.mention.groups.workspace')) + expect(groups[1]!.querySelector('.chat-slash-group')?.textContent).toBe(t('chat.mention.groups.sessions')) + expect(groups[0]!.querySelectorAll('[role="option"]')).toHaveLength(3) + expect(groups[1]!.querySelectorAll('[role="option"]')).toHaveLength(1) + expect(host.querySelectorAll('[role="option"]')).toHaveLength(4) + expect(host.querySelector('[role="listbox"]')?.getAttribute('aria-label')).toBe('mentions') + expect(host.textContent).toContain(t('chat.mention.hint')) + }) + + it('renders only the chats group when there are no workspace candidates', async () => { + const host = mount({ items: [session] }) + await nextTick() + + const groups = [...host.querySelectorAll('[role="group"]')] + expect(groups).toHaveLength(1) + expect(groups[0]!.getAttribute('aria-label')).toBe(t('chat.mention.groups.sessions')) + expect(host.querySelector('[role="option"]')?.textContent).toContain('Refactor the parser') + }) + + it('shows the empty and loading placeholders instead of an empty list', async () => { + const empty = mount({ items: [] }) + await nextTick() + expect(empty.textContent).toContain(t('chat.skillPalette.empty')) + expect(empty.querySelectorAll('[role="option"]')).toHaveLength(0) + expect(empty.querySelectorAll('[role="group"]')).toHaveLength(0) + + const loading = mount({ items: [], loading: true }) + await nextTick() + expect(loading.textContent).toContain(t('chat.skillPalette.loading')) + expect(loading.textContent).not.toContain(t('chat.skillPalette.empty')) + }) + + it('emits the candidate object when a row is chosen', async () => { + const choose = vi.fn() + const host = mount({ items: [directory, file, session], activeIndex: 1, onChoose: choose }) + await nextTick() + + const rows = [...host.querySelectorAll('[role="option"]')] + expect(rows).toHaveLength(3) + expect(rows[1]!.getAttribute('aria-selected')).toBe('true') + rows[1]!.click() + expect(choose).toHaveBeenCalledOnce() + expect(choose).toHaveBeenCalledWith(file) + rows[0]!.click() + expect(choose).toHaveBeenLastCalledWith(directory) + }) + + it('closes on Escape and stays quiet while an IME composition is active', async () => { + const close = vi.fn() + const choose = vi.fn() + const host = mount({ items: [file], onClose: close, onChoose: choose }) + await nextTick() + + const listbox = host.querySelector('[role="listbox"]')! + expect(key(listbox, 'Escape', { isComposing: true }).defaultPrevented).toBe(false) + expect(close).not.toHaveBeenCalled() + + const escape = key(listbox, 'Escape') + expect(escape.defaultPrevented).toBe(true) + expect(close).toHaveBeenCalledOnce() + expect(choose).not.toHaveBeenCalled() + }) + + it('surfaces a load error instead of the empty placeholder', async () => { + const error = t('chat.mention.unavailable') + const host = mount({ items: [], error }) + await nextTick() + + expect(host.textContent).toContain(error) + expect(host.textContent).not.toContain(t('chat.skillPalette.empty')) + expect(host.querySelector('[role="status"]')?.textContent).toBe(error) + }) +}) diff --git a/opensquilla-webui/src/components/chat/ChatMentionPalette.vue b/opensquilla-webui/src/components/chat/ChatMentionPalette.vue new file mode 100644 index 0000000000..949e1c15c7 --- /dev/null +++ b/opensquilla-webui/src/components/chat/ChatMentionPalette.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts b/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts index 769c665414..865e5b8b46 100644 --- a/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts @@ -3,6 +3,7 @@ import { ref } from 'vue' import { useChatComposerShortcuts } from './useChatComposerShortcuts' import { useLocalPathDraft } from './useLocalPathDraft' import type { ChatSlashCommand } from './useChatSlashCommands' +import type { MentionCandidate } from './useChatMentions' import type { ChatMessage, ChatPendingItem } from '@/types/chat' // The composable gates the Alt+Arrow queue chords on caret position via @@ -53,9 +54,13 @@ function harness(over: { composing?: boolean cancelMessageEdit?: () => boolean handleLongPaste?: (text: string) => boolean + mentionOpen?: boolean + mentionItems?: MentionCandidate[] } = {}) { const inputText = over.draft?.inputText ?? ref(over.inputText ?? '') const messages = ref(over.messages ?? []) + const mentionIdx = ref(0) + const mentionItems = ref(over.mentionItems ?? []) const spies = { popPendingTail: vi.fn(() => true), enqueuePendingInput: vi.fn(() => true), @@ -66,6 +71,9 @@ function harness(over: { completeSlashCmd: vi.fn(), activateSlashCmd: vi.fn(), cancelMessageEdit: vi.fn(over.cancelMessageEdit ?? (() => false)), + chooseMention: vi.fn(), + closeMention: vi.fn(), + handleMentionInput: vi.fn(), } const api = useChatComposerShortcuts({ inputText, @@ -81,11 +89,14 @@ function harness(over: { slashIdx: ref(0), filteredSlashCmds: ref(over.filteredSlashCmds ?? []), isStreaming: ref(over.isStreaming ?? false), + mentionOpen: ref(over.mentionOpen ?? false), + mentionIdx, + mentionItems, isSafariWebKit: () => over.safari ?? false, handleLongPaste: over.handleLongPaste, ...spies, }) - return { api, inputText, messages, spies } + return { api, inputText, messages, spies, mentionIdx } } function keydown(opts: { @@ -116,6 +127,11 @@ function inputEvent(inputType: string, target: unknown): InputEvent { const QUEUE = [{ id: 'q1', text: 'queued' }] as unknown as ChatPendingItem[] +const MENTIONS: MentionCandidate[] = [ + { id: 'workspace:src', kind: 'workspace_directory', label: 'src/', detail: 'src', insertion: '@src/' }, + { id: 'workspace:README.md', kind: 'workspace_file', label: 'README.md', detail: 'README.md', insertion: '@README.md' }, +] + describe('useChatComposerShortcuts', () => { it('recalls explicit path chips with Up/Down while retaining full canonical text exactly once', () => { const draft = useLocalPathDraft() @@ -635,4 +651,48 @@ describe('Escape and message edits', () => { expect(spies.closeSlashMenu).toHaveBeenCalledOnce() expect(spies.cancelMessageEdit).not.toHaveBeenCalled() }) + + it('moves the mention cursor with ArrowDown while the mention palette is open', () => { + const { api, mentionIdx, spies } = harness({ + inputText: '@', + mentionOpen: true, + mentionItems: MENTIONS, + }) + + api.onTextareaKeydown(keydown({ key: 'ArrowDown', target: field('@', 'end') })) + + expect(mentionIdx.value).toBe(1) + expect(spies.sendCurrentInput).not.toHaveBeenCalled() + }) + + it('chooses the highlighted mention on Enter instead of sending the draft', () => { + const { api, spies } = harness({ + inputText: '@', + mentionOpen: true, + mentionItems: MENTIONS, + }) + + const e = keydown({ key: 'Enter', target: field('@', 'end') }) + api.onTextareaKeydown(e) + + expect(spies.chooseMention).toHaveBeenCalledWith(MENTIONS[0]) + expect(spies.sendCurrentInput).not.toHaveBeenCalled() + expect(e.preventDefault).toHaveBeenCalledOnce() + }) + + it('closes the mention palette on Escape without touching the draft', () => { + const { api, inputText, spies } = harness({ + inputText: '@sr', + mentionOpen: true, + mentionItems: MENTIONS, + }) + + const e = keydown({ key: 'Escape', target: field('@sr', 'end') }) + api.onTextareaKeydown(e) + + expect(spies.closeMention).toHaveBeenCalledOnce() + expect(spies.cancelMessageEdit).not.toHaveBeenCalled() + expect(inputText.value).toBe('@sr') + expect(e.preventDefault).toHaveBeenCalledOnce() + }) }) diff --git a/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.ts b/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.ts index 65bc1beece..772712b79e 100644 --- a/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.ts +++ b/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.ts @@ -5,6 +5,7 @@ import type { ChatPendingItem, } from '@/types/chat' import type { ChatSlashCommand } from '@/composables/chat/useChatSlashCommands' +import type { MentionCandidate } from '@/composables/chat/useChatMentions' export interface UseChatComposerShortcutsOptions { inputText: Ref @@ -27,6 +28,13 @@ export interface UseChatComposerShortcutsOptions { closeSlashMenu: () => void completeSlashCmd: (cmd: ChatSlashCommand) => void activateSlashCmd: (cmd: ChatSlashCommand) => void + /** The `@` mention panel, when the composer exposes one. */ + mentionOpen?: Ref + mentionIdx?: Ref + mentionItems?: Ref + chooseMention?: (candidate: MentionCandidate) => void + closeMention?: () => void + handleMentionInput?: () => void popPendingTail: () => boolean enqueuePendingInput: (text: string) => boolean | Promise sendCurrentInput: () => void @@ -105,6 +113,7 @@ export function useChatComposerShortcuts(options: UseChatComposerShortcutsOption ) return if (options.composing.value || (event && 'isComposing' in event && event.isComposing)) return options.handleSlashInput() + options.handleMentionInput?.() } function onTextareaKeydown(e: KeyboardEvent) { @@ -155,6 +164,34 @@ export function useChatComposerShortcuts(options: UseChatComposerShortcutsOption } } + if (options.mentionOpen?.value) { + const mentionIdx = options.mentionIdx + const mentionItems = options.mentionItems?.value ?? [] + if (e.key === 'ArrowDown') { + e.preventDefault() + if (mentionIdx) mentionIdx.value = Math.max(0, Math.min(mentionIdx.value + 1, mentionItems.length - 1)) + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + if (mentionIdx) mentionIdx.value = Math.max(mentionIdx.value - 1, 0) + return + } + if (e.key === 'Enter' || e.key === 'Tab') { + const candidate = mentionItems[mentionIdx?.value ?? 0] + e.preventDefault() + if (!candidate) return + clearTextareaUndoState() + options.chooseMention?.(candidate) + return + } + if (e.key === 'Escape') { + e.preventDefault() + options.closeMention?.() + return + } + } + if (e.key === 'Escape' && !options.isStreaming.value) { // Cancel edits even with an empty input or pending queue. Otherwise keep // the draft, its persistence and native undo history intact. Streaming diff --git a/opensquilla-webui/src/composables/chat/useChatMentions.test.ts b/opensquilla-webui/src/composables/chat/useChatMentions.test.ts new file mode 100644 index 0000000000..04ad8086af --- /dev/null +++ b/opensquilla-webui/src/composables/chat/useChatMentions.test.ts @@ -0,0 +1,373 @@ +import { nextTick, ref } from 'vue' +import { describe, expect, it, vi } from 'vitest' + +import i18n from '@/i18n' +import type { SessionDirectory, SessionItem } from '@/modules/sessionDirectory' +import type { + WorkspaceDirectoryEntry, + WorkspaceDirectoryPage, + WorkspaceFiles, +} from '@/modules/workspaceFiles' + +import { useChatMentions } from './useChatMentions' + +const SESSION_KEY = 'agent:main:webchat:s1' +const NEXT_SESSION_KEY = 'agent:main:webchat:s2' +const BINDING = 'workspace-binding-1' + +function entry( + name: string, + path: string, + type: 'directory' | 'file', +): WorkspaceDirectoryEntry { + return { + name, + path, + type, + kind: type === 'directory' ? 'directory' : 'text', + mime: type === 'directory' ? 'inode/directory' : 'text/plain', + size: type === 'directory' ? null : 128, + mtimeNs: '1700000000000000000', + } +} + +const ROOT_ENTRIES = [entry('src', 'src', 'directory'), entry('README.md', 'README.md', 'file')] +const SRC_ENTRIES = [ + entry('lib', 'src/lib', 'directory'), + entry('main.ts', 'src/main.ts', 'file'), + entry('max.ts', 'src/max.ts', 'file'), +] + +function page(relativePath: string, entries: WorkspaceDirectoryEntry[]): WorkspaceDirectoryPage { + return { + workspaceBinding: BINDING, + relativePath, + entries, + nextCursor: null, + hasMore: false, + truncated: false, + } +} + +function session(key: string, title: string, groupLabel = 'Today'): SessionItem { + return { + key, + title, + subtitle: '', + groupLabel, + effectiveAgentId: 'main', + sessionKind: 'chat', + surface: 'webchat', + conversationKind: 'main', + status: 'idle', + runStatus: 'idle', + runLabel: '', + messageCount: 2, + updatedAt: 1, + model: 'test-model', + parent: null, + forkedFromParent: false, + hasContractGaps: false, + } +} + +const SESSIONS = [ + session('agent:main:webchat:history-1', 'Fix login redirect', 'Yesterday'), + session('agent:main:webchat:history-2', 'Release notes', 'Last week'), +] + +/** Let the mocked RPCs, the refresh await, and nextTick drain. */ +const flush = () => new Promise(resolve => { setTimeout(resolve, 0) }) + +function harness(overrides: { + workspaceFiles?: WorkspaceFiles | null + sessionDirectory?: SessionDirectory | null + withCaret?: boolean +} = {}) { + const inputText = ref('') + const sessionKey = ref(SESSION_KEY) + let caret = 0 + const getCaret = vi.fn(() => caret) + const setCaret = vi.fn((offset: number) => { caret = offset }) + const autoResizeTextarea = vi.fn() + const listDir = vi.fn(async ( + _sessionKey: string, + path = '', + _cursor?: string, + _limit?: number, + _workspaceBinding?: string, + ) => page(path, path === 'src' ? SRC_ENTRIES : ROOT_ENTRIES)) + const listPage = vi.fn(async () => ({ items: SESSIONS, hasMore: false, nextCursor: null })) + const workspaceFiles = 'workspaceFiles' in overrides + ? overrides.workspaceFiles ?? undefined + : { listDir } as unknown as WorkspaceFiles + const sessionDirectory = 'sessionDirectory' in overrides + ? overrides.sessionDirectory ?? undefined + : { + listPage, + count: vi.fn(async () => ({ value: SESSIONS.length, exact: true })), + resolve: vi.fn(async () => ({ key: SESSION_KEY, id: 's1' })), + search: vi.fn(async () => ({ sessions: [], messages: [] })), + } as unknown as SessionDirectory + + const api = useChatMentions({ + inputText, + sessionKey, + workspaceFiles, + sessionDirectory, + getCaret: overrides.withCaret === false ? undefined : getCaret, + setCaret, + autoResizeTextarea, + }) + + /** Type a whole value with the caret at its end, then run the input handler. */ + function type(value: string) { + inputText.value = value + caret = value.length + api.handleMentionInput() + } + + return { + api, + inputText, + sessionKey, + listDir, + listPage, + getCaret, + setCaret, + autoResizeTextarea, + type, + caretOf: () => caret, + ids: () => api.mentionItems.value.map(item => item.id), + pick: (id: string) => { + const candidate = api.mentionItems.value.find(item => item.id === id) + expect(candidate).toBeTruthy() + api.chooseMention(candidate!) + return candidate! + }, + } +} + +describe('chat mentions', () => { + it('opens on a bare @ with root directory entries and recent sessions', async () => { + const h = harness() + h.type('@') + await flush() + + expect(h.api.mentionOpen.value).toBe(true) + expect(h.api.mentionLoading.value).toBe(false) + expect(h.api.mentionError.value).toBe('') + expect(h.api.mentionIdx.value).toBe(0) + expect(h.listDir).toHaveBeenCalledTimes(1) + expect(h.listDir.mock.calls[0]!.slice(0, 4)).toEqual([SESSION_KEY, '', '0', 200]) + expect(h.listPage).toHaveBeenCalledTimes(1) + expect(h.listPage).toHaveBeenCalledWith({ limit: 20 }) + + expect(h.ids()).toEqual(expect.arrayContaining([ + 'workspace:src', + 'workspace:README.md', + 'session:agent:main:webchat:history-1', + 'session:agent:main:webchat:history-2', + ])) + // Workspace candidates lead, session candidates follow. + expect(h.ids().indexOf('session:agent:main:webchat:history-1')) + .toBeGreaterThan(h.ids().indexOf('workspace:README.md')) + + const directory = h.api.mentionItems.value.find(item => item.id === 'workspace:src')! + expect(directory.kind).toBe('workspace_directory') + expect(directory.label).toBe('src/') + expect(directory.insertion).toBe('@src/') + expect(directory.detail).toBe('src') + expect(directory.path).toBe('src') + + const file = h.api.mentionItems.value.find(item => item.id === 'workspace:README.md')! + expect(file.kind).toBe('workspace_file') + expect(file.label).toBe('README.md') + expect(file.insertion).toBe('@README.md') + + const recent = h.api.mentionItems.value.find(item => item.kind === 'session')! + expect(recent.label).toBe('Fix login redirect') + expect(recent.detail).toBe('Yesterday') + expect(recent.sessionKey).toBe('agent:main:webchat:history-1') + }) + + it('lists only the src directory for @src/ma and filters it by prefix', async () => { + const h = harness() + h.type('@src/ma') + await flush() + + expect(h.listDir).toHaveBeenCalledTimes(1) + expect(h.listDir.mock.calls[0]![1]).toBe('src') + expect(h.ids()).toContain('workspace:src/main.ts') + expect(h.ids()).toContain('workspace:src/max.ts') + expect(h.ids()).not.toContain('workspace:src/lib') + expect(h.ids().some(id => id.startsWith('session:'))).toBe(false) + expect(h.api.mentionOpen.value).toBe(true) + }) + + it('lists each directory once and reuses the reported workspace binding', async () => { + const h = harness() + h.type('@') + await flush() + h.type('@s') + await flush() + // Same root directory: the cached listing answers the second keystroke. + expect(h.listDir).toHaveBeenCalledTimes(1) + expect(h.listDir.mock.calls[0]![4]).toBeUndefined() + + h.type('@src/') + await flush() + expect(h.listDir).toHaveBeenCalledTimes(2) + expect(h.listDir.mock.calls[1]![1]).toBe('src') + expect(h.listDir.mock.calls[1]![4]).toBe(BINDING) + }) + + it('reopens on the chosen directory so the palette drills into its children', async () => { + const h = harness() + h.type('@sr') + await flush() + h.pick('workspace:src') + + expect(h.inputText.value).toBe('@src/ ') + // The caret stays inside the token, before the separator space, or the + // reopened palette would immediately close on whitespace. + expect(h.caretOf()).toBe('@src/'.length) + expect(h.autoResizeTextarea).toHaveBeenCalledTimes(1) + expect(h.setCaret).toHaveBeenCalledWith('@src/'.length) + + await nextTick() + await flush() + + expect(h.listDir.mock.calls.map(call => call[1])).toEqual(['', 'src']) + expect(h.api.mentionOpen.value).toBe(true) + expect(h.api.mentionLoading.value).toBe(false) + // The empty fragment lists the whole directory level; sessions still trail + // the workspace entries of the newly opened token. + const kinds = h.api.mentionItems.value.map(item => item.kind) + const workspaceKinds = kinds.filter(kind => kind.startsWith('workspace')) + expect(workspaceKinds).toEqual(['workspace_directory', 'workspace_file', 'workspace_file']) + expect(kinds.slice(workspaceKinds.length).every(kind => kind === 'session')).toBe(true) + expect(h.api.mentionItems.value + .filter(item => item.kind !== 'session') + .map(item => item.label)).toEqual(['lib/', 'main.ts', 'max.ts']) + }) + + it('replaces the token with a file mention and closes the palette', async () => { + const h = harness() + h.type('@src/ma') + await flush() + h.pick('workspace:src/main.ts') + + expect(h.inputText.value).toBe('@src/main.ts ') + expect(h.caretOf()).toBe('@src/main.ts '.length) + expect(h.autoResizeTextarea).toHaveBeenCalledTimes(1) + expect(h.api.mentionOpen.value).toBe(false) + expect(h.api.mentionItems.value).toEqual([]) + + await nextTick() + await flush() + // A file choice ends the mention: no follow-up listing for the file path. + expect(h.listDir.mock.calls.map(call => call[1])).toEqual(['src']) + }) + + it('writes a session mention with the session key suffix', async () => { + const h = harness() + h.type('@fix') + await flush() + + const sessionId = 'session:agent:main:webchat:history-1' + expect(h.ids()).toEqual([sessionId]) + h.pick(sessionId) + + expect(h.inputText.value).toBe('@Fix login redirect (session:agent:main:webchat:history-1) ') + expect(h.caretOf()).toBe(h.inputText.value.length) + expect(h.api.mentionOpen.value).toBe(false) + }) + + it('keeps the palette closed for an @ inside prose', async () => { + const h = harness() + h.type('a@b.com') + + expect(h.api.mentionOpen.value).toBe(false) + expect(h.api.mentionItems.value).toEqual([]) + expect(h.listDir).not.toHaveBeenCalled() + expect(h.listPage).not.toHaveBeenCalled() + }) + + it('reports a workspace failure without dropping the session candidates', async () => { + const h = harness() + h.listDir.mockRejectedValueOnce(new Error('workspace offline')) + h.type('@') + await flush() + + expect(h.api.mentionError.value).toBe(i18n.global.t('chat.mention.unavailable')) + expect(h.api.mentionOpen.value).toBe(true) + expect(h.api.mentionLoading.value).toBe(false) + expect(h.ids()).toEqual([ + 'session:agent:main:webchat:history-1', + 'session:agent:main:webchat:history-2', + ]) + + // The failed directory is not cached: the next keystroke retries it. + h.type('@s') + await flush() + expect(h.listDir).toHaveBeenCalledTimes(2) + expect(h.ids()).toContain('workspace:src') + expect(h.api.mentionError.value).toBe('') + }) + + it('closes the palette and clears the caches when the session changes', async () => { + const h = harness() + h.type('@') + await flush() + expect(h.api.mentionOpen.value).toBe(true) + + h.sessionKey.value = NEXT_SESSION_KEY + await nextTick() + + expect(h.api.mentionOpen.value).toBe(false) + expect(h.api.mentionItems.value).toEqual([]) + + h.type('@') + await flush() + expect(h.listDir).toHaveBeenCalledTimes(2) + expect(h.listDir.mock.calls[1]![0]).toBe(NEXT_SESSION_KEY) + expect(h.listPage).toHaveBeenCalledTimes(2) + expect(h.api.mentionOpen.value).toBe(true) + }) + + it('drops a listing that resolves after the palette already closed', async () => { + const h = harness() + let release!: (value: WorkspaceDirectoryPage) => void + h.listDir.mockReturnValueOnce(new Promise((resolve) => { + release = resolve + })) + + h.type('@') + expect(h.api.mentionOpen.value).toBe(true) + expect(h.api.mentionLoading.value).toBe(true) + + h.type('hello@') + h.type('hello @ world') + expect(h.api.mentionOpen.value).toBe(false) + expect(h.api.mentionLoading.value).toBe(false) + + release(page('', ROOT_ENTRIES)) + await flush() + + expect(h.api.mentionItems.value).toEqual([]) + expect(h.api.mentionOpen.value).toBe(false) + expect(h.api.mentionLoading.value).toBe(false) + }) + + it('still works without an injected workspace or session directory', async () => { + const h = harness({ workspaceFiles: null, sessionDirectory: null }) + h.type('@') + await flush() + + expect(h.api.mentionOpen.value).toBe(true) + expect(h.api.mentionItems.value).toEqual([]) + expect(h.api.mentionError.value).toBe('') + expect(h.api.mentionLoading.value).toBe(false) + }) +}) diff --git a/opensquilla-webui/src/composables/chat/useChatMentions.ts b/opensquilla-webui/src/composables/chat/useChatMentions.ts new file mode 100644 index 0000000000..d71cb84036 --- /dev/null +++ b/opensquilla-webui/src/composables/chat/useChatMentions.ts @@ -0,0 +1,285 @@ +import { getCurrentScope, nextTick, onScopeDispose, ref, watch, type Ref } from 'vue' +import i18n from '@/i18n' +import type { SessionDirectory, SessionItem } from '@/modules/sessionDirectory' +import type { WorkspaceDirectoryEntry, WorkspaceFiles } from '@/modules/workspaceFiles' +import { + mentionMatchRank, + mentionQueryAt, + mentionTarget, + replaceMentionQuery, + type MentionQuery, +} from '@/utils/chat/mentionQuery' + +export type MentionCandidateKind = 'workspace_directory' | 'workspace_file' | 'session' + +export interface MentionCandidate { + id: string + kind: MentionCandidateKind + label: string + detail: string + insertion: string + path?: string + sessionKey?: string +} + +export interface UseChatMentionsOptions { + inputText: Ref + sessionKey: Ref + workspaceFiles?: WorkspaceFiles | null + sessionDirectory?: SessionDirectory | null + getCaret?: () => number + setCaret?: (offset: number) => void + autoResizeTextarea?: () => void +} + +/** One listing covers a whole directory level: the palette never paginates. */ +const DIRECTORY_PAGE_LIMIT = 200 +const WORKSPACE_CANDIDATE_LIMIT = 8 +const SESSION_PAGE_LIMIT = 20 +const SESSION_CANDIDATE_LIMIT = 6 +/** Directory candidates lead, recent sessions follow; the panel stays one screen. */ +const MENTION_CANDIDATE_LIMIT = 14 + +function workspaceCandidate(entry: WorkspaceDirectoryEntry): MentionCandidate | null { + const name = typeof entry?.name === 'string' ? entry.name : '' + const path = typeof entry?.path === 'string' ? entry.path : '' + if (!name || !path) return null + if (entry.type === 'directory') { + // The trailing slash keeps the token a directory prefix, so the palette can + // list its children right after the insertion. + return { + id: `workspace:${path}`, + kind: 'workspace_directory', + label: `${name}/`, + detail: path, + insertion: `@${path}/`, + path, + } + } + return { + id: `workspace:${path}`, + kind: 'workspace_file', + label: name, + detail: path, + insertion: `@${path}`, + path, + } +} + +function sessionCandidate(session: SessionItem): MentionCandidate | null { + const key = typeof session?.key === 'string' ? session.key : '' + if (!key) return null + const label = (typeof session.title === 'string' && session.title) || key + // The `(session:)` suffix is the reference the sender resolves; the + // visible title alone is not unique across sessions. + return { + id: `session:${key}`, + kind: 'session', + label, + detail: typeof session.groupLabel === 'string' ? session.groupLabel : '', + insertion: `@${label} (session:${key})`, + sessionKey: key, + } +} + +/** Rank by match quality first, then keep the listing stable by name. */ +function rankMatches( + entries: readonly T[], + name: (entry: T) => string, + fragment: string, + limit: number, +): T[] { + return entries + .map(entry => ({ entry, rank: mentionMatchRank(name(entry), fragment) })) + .filter(item => item.rank >= 0) + .sort((a, b) => a.rank - b.rank || name(a.entry).localeCompare(name(b.entry))) + .slice(0, limit) + .map(item => item.entry) +} + +export function useChatMentions(options: UseChatMentionsOptions) { + const mentionOpen = ref(false) + const mentionIdx = ref(0) + const mentionItems = ref([]) + const mentionLoading = ref(false) + const mentionError = ref('') + /** The live `@` token; every insertion replaces exactly this range. */ + let token: MentionQuery | null = null + /** Fences stale refreshes: only the newest request may touch panel state. */ + let epoch = 0 + /** + * Listings are cached per directory for the lifetime of one workspace scope. + * The binding is echoed back by the first successful page and reused so a + * mid-palette rebind cannot splice two workspaces into one menu. + */ + const directoryLoads = new Map>() + let workspaceBinding: string | undefined + let sessionLoad: Promise | null = null + + function closeMention() { + epoch += 1 + token = null + mentionOpen.value = false + mentionLoading.value = false + mentionItems.value = [] + mentionError.value = '' + } + + function resetCaches() { + directoryLoads.clear() + workspaceBinding = undefined + sessionLoad = null + } + + // Another session owns another workspace root and another recents list. + watch(options.sessionKey, () => { + resetCaches() + closeMention() + }, { flush: 'sync' }) + + if (getCurrentScope()) onScopeDispose(() => { + epoch += 1 + resetCaches() + }) + + function listDirectory(directory: string): Promise { + const cached = directoryLoads.get(directory) + if (cached) return cached + const files = options.workspaceFiles + const listDir = files?.listDir + if (!files || typeof listDir !== 'function') return Promise.resolve([]) + const request = Promise.resolve(listDir.call( + files, + options.sessionKey.value, + directory, + '0', + DIRECTORY_PAGE_LIMIT, + workspaceBinding, + )).then((page) => { + if (typeof page?.workspaceBinding === 'string' && page.workspaceBinding) { + workspaceBinding = page.workspaceBinding + } + return Array.isArray(page?.entries) ? page.entries : [] + }) + directoryLoads.set(directory, request) + // A refused listing must not poison the cache; the next keystroke retries. + void request.catch(() => { + if (directoryLoads.get(directory) === request) directoryLoads.delete(directory) + }) + return request + } + + function listSessions(): Promise { + if (sessionLoad) return sessionLoad + const directory = options.sessionDirectory + if (!directory) return Promise.resolve([]) + const request = Promise.resolve(directory.listPage({ limit: SESSION_PAGE_LIMIT })) + .then(page => (Array.isArray(page?.items) ? page.items : [])) + sessionLoad = request + void request.catch(() => { if (sessionLoad === request) sessionLoad = null }) + return request + } + + async function collectWorkspace( + directory: string, + fragment: string, + ): Promise<{ items: MentionCandidate[]; failed: boolean }> { + // No workspace (older Gateway, no injection) simply means no file candidates. + if (typeof options.workspaceFiles?.listDir !== 'function') return { items: [], failed: false } + try { + const entries = await listDirectory(directory) + const ranked = rankMatches(entries, entry => entry.name, fragment, WORKSPACE_CANDIDATE_LIMIT) + return { + items: ranked.map(workspaceCandidate).filter( + (candidate): candidate is MentionCandidate => candidate !== null, + ), + failed: false, + } + } catch { + return { items: [], failed: true } + } + } + + async function collectSessions(fragment: string): Promise { + if (!options.sessionDirectory) return [] + try { + const sessions = await listSessions() + // Sessions are not paths: match the fragment left after any directory part. + const ranked = rankMatches( + sessions, + session => session.title || session.key, + fragment, + SESSION_CANDIDATE_LIMIT, + ) + return ranked.map(sessionCandidate).filter( + (candidate): candidate is MentionCandidate => candidate !== null, + ) + } catch { + return [] + } + } + + async function refreshCandidates(query: string) { + const operation = ++epoch + const { directory, fragment } = mentionTarget(query) + mentionLoading.value = true + mentionError.value = '' + const [workspace, sessions] = await Promise.all([ + collectWorkspace(directory, fragment), + collectSessions(fragment), + ]) + // The caret moved, the palette closed, or the session switched: the newest + // operation owns the panel, so drop this response without touching state. + if (operation !== epoch) return + mentionLoading.value = false + mentionItems.value = [...workspace.items, ...sessions].slice(0, MENTION_CANDIDATE_LIMIT) + mentionIdx.value = Math.max(0, Math.min(mentionIdx.value, mentionItems.value.length - 1)) + if (workspace.failed) mentionError.value = i18n.global.t('chat.mention.unavailable') + } + + function handleMentionInput() { + const text = options.inputText.value + const caret = options.getCaret?.() ?? text.length + const next = mentionQueryAt(text, caret) + if (!next) { + closeMention() + return + } + token = next + mentionOpen.value = true + mentionIdx.value = 0 + mentionLoading.value = true + void refreshCandidates(next.query) + } + + function chooseMention(candidate: MentionCandidate) { + const range = token + if (!range) return + const { text, caret } = replaceMentionQuery(options.inputText.value, range, `${candidate.insertion} `) + options.inputText.value = text + options.autoResizeTextarea?.() + // A directory keeps the palette alive to drill into its children, so the + // caret must stay inside the token: mentionQueryAt ends a token at + // whitespace, and a caret after the separator space would close the panel. + options.setCaret?.(candidate.kind === 'workspace_directory' ? Math.max(range.start, caret - 1) : caret) + token = null + if (candidate.kind === 'workspace_directory') { + mentionOpen.value = true + mentionItems.value = [] + void nextTick(() => handleMentionInput()) + return + } + closeMention() + } + + return { + mentionOpen, + mentionIdx, + mentionItems, + mentionLoading, + mentionError, + handleMentionInput, + chooseMention, + closeMention, + } +} diff --git a/opensquilla-webui/src/locales/de.json b/opensquilla-webui/src/locales/de.json index 094e9af6f8..020b5a53a9 100644 --- a/opensquilla-webui/src/locales/de.json +++ b/opensquilla-webui/src/locales/de.json @@ -3845,6 +3845,14 @@ } } }, + "mention": { + "groups": { + "workspace": "Arbeitsbereichsdateien", + "sessions": "Letzte Chats" + }, + "unavailable": "Arbeitsbereichsdateien sind derzeit nicht verfügbar.", + "hint": "↑↓ zum Auswählen · Enter zum Einfügen · Esc zum Schließen" + }, "sessionCreated": { "title": "Chat erstellt", "open": "Chat öffnen", diff --git a/opensquilla-webui/src/locales/en.json b/opensquilla-webui/src/locales/en.json index 4fadb3a417..d203d51b01 100644 --- a/opensquilla-webui/src/locales/en.json +++ b/opensquilla-webui/src/locales/en.json @@ -3946,6 +3946,14 @@ } } }, + "mention": { + "groups": { + "workspace": "Workspace files", + "sessions": "Recent chats" + }, + "unavailable": "Workspace files are unavailable right now.", + "hint": "↑↓ to choose · Enter to insert · Esc to close" + }, "sessionCreated": { "title": "Chat created", "open": "Open chat", diff --git a/opensquilla-webui/src/locales/es.json b/opensquilla-webui/src/locales/es.json index 694ebba83e..d28e90cc0b 100644 --- a/opensquilla-webui/src/locales/es.json +++ b/opensquilla-webui/src/locales/es.json @@ -3845,6 +3845,14 @@ } } }, + "mention": { + "groups": { + "workspace": "Archivos del espacio de trabajo", + "sessions": "Chats recientes" + }, + "unavailable": "Los archivos del espacio de trabajo no están disponibles ahora.", + "hint": "↑↓ para elegir · Intro para insertar · Esc para cerrar" + }, "sessionCreated": { "title": "Chat creado", "open": "Abrir chat", diff --git a/opensquilla-webui/src/locales/fr.json b/opensquilla-webui/src/locales/fr.json index 81171f7b72..a63eba1018 100644 --- a/opensquilla-webui/src/locales/fr.json +++ b/opensquilla-webui/src/locales/fr.json @@ -3845,6 +3845,14 @@ } } }, + "mention": { + "groups": { + "workspace": "Fichiers de l’espace de travail", + "sessions": "Conversations récentes" + }, + "unavailable": "Les fichiers de l’espace de travail sont indisponibles pour le moment.", + "hint": "↑↓ pour choisir · Entrée pour insérer · Échap pour fermer" + }, "sessionCreated": { "title": "Discussion créée", "open": "Ouvrir la discussion", diff --git a/opensquilla-webui/src/locales/ja.json b/opensquilla-webui/src/locales/ja.json index 1e4bff0db5..ef65c11b4b 100644 --- a/opensquilla-webui/src/locales/ja.json +++ b/opensquilla-webui/src/locales/ja.json @@ -3845,6 +3845,14 @@ } } }, + "mention": { + "groups": { + "workspace": "ワークスペースのファイル", + "sessions": "最近のチャット" + }, + "unavailable": "ワークスペースのファイルを読み込めません。", + "hint": "↑↓ で選択 · Enter で挿入 · Esc で閉じる" + }, "sessionCreated": { "title": "チャットを作成しました", "open": "チャットを開く", diff --git a/opensquilla-webui/src/locales/zh-Hans.json b/opensquilla-webui/src/locales/zh-Hans.json index 615415ccdc..939c28b60a 100644 --- a/opensquilla-webui/src/locales/zh-Hans.json +++ b/opensquilla-webui/src/locales/zh-Hans.json @@ -3945,6 +3945,14 @@ } } }, + "mention": { + "groups": { + "workspace": "工作区文件", + "sessions": "最近会话" + }, + "unavailable": "当前无法读取工作区文件。", + "hint": "↑↓ 选择 · Enter 插入 · Esc 关闭" + }, "sessionCreated": { "title": "聊天已创建", "open": "打开聊天", diff --git a/opensquilla-webui/src/utils/chat/mentionQuery.test.ts b/opensquilla-webui/src/utils/chat/mentionQuery.test.ts new file mode 100644 index 0000000000..28239cfe50 --- /dev/null +++ b/opensquilla-webui/src/utils/chat/mentionQuery.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { mentionMatchRank, mentionQueryAt, mentionTarget, replaceMentionQuery } from './mentionQuery' + +describe('mention query detection', () => { + it('returns no token for empty text, plain prose and e-mail addresses', () => { + expect(mentionQueryAt('')).toBeNull() + expect(mentionQueryAt('hello world')).toBeNull() + expect(mentionQueryAt('a@b.com')).toBeNull() + expect(mentionQueryAt('foo@bar')).toBeNull() + }) + + it('opens an empty query right after an @ at the start of the line', () => { + expect(mentionQueryAt('@')).toEqual({ start: 0, end: 1, query: '' }) + }) + + it('captures the typed path up to the caret', () => { + expect(mentionQueryAt('hello @src/ma')).toEqual({ start: 6, end: 13, query: 'src/ma' }) + }) + + it('stops the token at the caret instead of the end of the line', () => { + expect(mentionQueryAt('hello @src/main.ts', 10)).toEqual({ start: 6, end: 10, query: 'src' }) + expect(mentionQueryAt('hello @src/main.ts', 0)).toBeNull() + }) + + it('clamps a caret past the end of the text', () => { + expect(mentionQueryAt('@src', 99)).toEqual({ start: 0, end: 4, query: 'src' }) + }) + + it('closes the token once whitespace or a newline follows the @', () => { + expect(mentionQueryAt('@src ma')).toBeNull() + expect(mentionQueryAt('@src\nma')).toBeNull() + expect(mentionQueryAt('@src\tma')).toBeNull() + expect(mentionQueryAt('@src ma', 4)).toEqual({ start: 0, end: 4, query: 'src' }) + }) + + it('accepts an @ that follows whitespace or Chinese punctuation', () => { + expect(mentionQueryAt('use @src/ma')).toEqual({ start: 4, end: 11, query: 'src/ma' }) + expect(mentionQueryAt('(@main')).toEqual({ start: 1, end: 6, query: 'main' }) + expect(mentionQueryAt('请查看,@src/ma')).toEqual({ start: 4, end: 11, query: 'src/ma' }) + expect(mentionQueryAt('「@main')).toEqual({ start: 1, end: 6, query: 'main' }) + }) + + it('rejects a token glued to a preceding letter or digit', () => { + expect(mentionQueryAt('x@main')).toBeNull() + expect(mentionQueryAt('9@main')).toBeNull() + }) + + it('rejects control characters and over-long queries', () => { + expect(mentionQueryAt('@sr\u0001c')).toBeNull() + expect(mentionQueryAt('@' + 'a'.repeat(201))).toBeNull() + expect(mentionQueryAt('@' + 'a'.repeat(200))).toEqual({ start: 0, end: 201, query: 'a'.repeat(200) }) + }) +}) + +describe('mention targets', () => { + it('splits a nested path into directory and fragment', () => { + expect(mentionTarget('src/components')).toEqual({ directory: 'src', fragment: 'components' }) + expect(mentionTarget('src/components/App')).toEqual({ directory: 'src/components', fragment: 'App' }) + }) + + it('keeps a bare name as a root-level fragment', () => { + expect(mentionTarget('main')).toEqual({ directory: '', fragment: 'main' }) + expect(mentionTarget('')).toEqual({ directory: '', fragment: '' }) + }) + + it('normalizes Windows separators', () => { + expect(mentionTarget('src\\main')).toEqual({ directory: 'src', fragment: 'main' }) + expect(mentionTarget('src\\components\\App')).toEqual({ directory: 'src/components', fragment: 'App' }) + }) + + it('treats a trailing separator as an empty fragment', () => { + expect(mentionTarget('src/')).toEqual({ directory: 'src', fragment: '' }) + expect(mentionTarget('src\\')).toEqual({ directory: 'src', fragment: '' }) + }) +}) + +describe('mention match ranking', () => { + it('ranks exact, prefix, substring and misses in order', () => { + expect(mentionMatchRank('main.ts', '')).toBe(0) + expect(mentionMatchRank('main.ts', ' ')).toBe(0) + expect(mentionMatchRank('main.ts', 'main.ts')).toBe(0) + expect(mentionMatchRank('main.ts', 'main')).toBe(1) + expect(mentionMatchRank('spreadsheets', 'spread')).toBe(1) + expect(mentionMatchRank('src/main.ts', 'main')).toBe(2) + expect(mentionMatchRank('src/components/main.ts', 'main')).toBe(2) + expect(mentionMatchRank('main.ts', 'zzz')).toBe(-1) + }) + + it('ignores case on both sides of the comparison', () => { + expect(mentionMatchRank('Main.TS', 'main.ts')).toBe(0) + expect(mentionMatchRank('README.md', 'read')).toBe(1) + expect(mentionMatchRank('src/Readme.md', 'README')).toBe(2) + expect(mentionMatchRank('src/components/README.md', 'readme')).toBe(2) + }) +}) + +describe('mention replacement', () => { + it('swaps the token for the resolved text and reports the caret after it', () => { + const text = 'hello @src/ma world' + const range = mentionQueryAt(text, 13)! + const result = replaceMentionQuery(text, range, '@src/main.ts') + expect(result.text).toBe('hello @src/main.ts world') + expect(result.caret).toBe(6 + '@src/main.ts'.length) + expect(result.text.slice(result.caret)).toBe(' world') + }) + + it('preserves text typed after the token when the caret sits mid-draft', () => { + const text = '请 @main 并总结' + const range = mentionQueryAt(text, 7)! + expect(range.query).toBe('main') + expect(replaceMentionQuery(text, range, '@README.md')).toEqual({ + text: '请 @README.md 并总结', + caret: 2 + '@README.md'.length, + }) + }) + + it('replaces an empty query with the insertion text', () => { + const result = replaceMentionQuery('@', { start: 0, end: 1, query: '' }, '@docs/') + expect(result).toEqual({ text: '@docs/', caret: 6 }) + }) +}) diff --git a/opensquilla-webui/src/utils/chat/mentionQuery.ts b/opensquilla-webui/src/utils/chat/mentionQuery.ts new file mode 100644 index 0000000000..dada139144 --- /dev/null +++ b/opensquilla-webui/src/utils/chat/mentionQuery.ts @@ -0,0 +1,68 @@ +export interface MentionQuery { + /** Offset of the `@` that opens the token. */ + start: number + /** Caret offset closing the token (exclusive). */ + end: number + /** Text typed after `@`, without the sigil. */ + query: string +} + +/** A mention never continues a word, so e-mail addresses and handles stay prose. */ +const MENTION_BOUNDARY = /[\s()[\]{}"'`<>,。;:、!?「」『』()【】《》]/ +const MAX_MENTION_QUERY = 200 + +/** + * Only a standalone `@` token ending at the caret opens the mention palette. + * A caret inside an e-mail address, a handle, or a token that already contains + * whitespace returns null so typing prose never reopens the palette. + */ +export function mentionQueryAt(text: string, caret = text.length): MentionQuery | null { + if (typeof text !== 'string') return null + const end = Math.max(0, Math.min(Number.isFinite(caret) ? Math.trunc(caret) : text.length, text.length)) + let start = -1 + for (let index = end - 1; index >= 0; index -= 1) { + const char = text[index]! + if (char === '@') { + start = index + break + } + if (/\s/.test(char)) return null + } + if (start < 0) return null + if (start > 0 && !MENTION_BOUNDARY.test(text[start - 1]!)) return null + const query = text.slice(start + 1, end) + if (query.length > MAX_MENTION_QUERY) return null + if (/[\u0000-\u001f\u007f]/.test(query)) return null + return { start, end, query } +} + +/** Split a typed query into the directory to list and the fragment to match. */ +export function mentionTarget(raw: string): { directory: string; fragment: string } { + const normalized = raw.replace(/\\/g, '/') + const separator = normalized.lastIndexOf('/') + if (separator < 0) return { directory: '', fragment: normalized } + return { + directory: normalized.slice(0, separator), + fragment: normalized.slice(separator + 1), + } +} + +/** Rank a candidate name against the typed fragment: prefix beats substring. */ +export function mentionMatchRank(name: string, fragment: string): number { + const needle = fragment.trim().toLocaleLowerCase() + if (!needle) return 0 + const value = name.toLocaleLowerCase() + if (value === needle) return 0 + if (value.startsWith(needle)) return 1 + return value.includes(needle) ? 2 : -1 +} + +/** Replace a mention token with its resolved insertion text. */ +export function replaceMentionQuery( + text: string, + range: MentionQuery, + replacement: string, +): { text: string; caret: number } { + const next = text.slice(0, range.start) + replacement + text.slice(range.end) + return { text: next, caret: range.start + replacement.length } +} diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index d682dcd2d3..edbb61028c 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -619,6 +619,9 @@ :skills-error="skillsError" :palette-open="slashOpen" :palette-active-index="slashIdx" + :mention-open="mentionOpen" + :mention-palette-id="mentionPaletteId" + :mention-active-index="mentionIdx" :has-context="Boolean(visualizeDraft.snapshot.value)" :can-close-project="isDraftRoute() && pendingWorkspaceId !== null" :can-choose-project="gatewayAccess.canChooseProject" @@ -634,7 +637,7 @@ :collapsed="composerCollapsed && composerFxEnabled && !isNewChatLanding" :floating="composerFxEnabled && !isNewChatLanding" @expand="expandComposer" - @composition-change="composing = $event; !$event && handleSlashInput()" + @composition-change="composing = $event; !$event && (handleSlashInput(), handleMentionInput())" @beforeinput="onTextareaBeforeInput" :choose-attachments="chooseAttachments" :choose-local-file-paths="localPathPicker.choose" @@ -683,6 +686,12 @@ :composer-element="composerRef?.editorElement()?.closest('.chat-input-panel')" @close="closeSlashMenu(); composerRef?.focusTextarea()" @choose="completeSlashCmd" /> +
+ +