Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions opensquilla-webui/src/components/chat/ChatComposer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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<boolean>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
119 changes: 119 additions & 0 deletions opensquilla-webui/src/components/chat/ChatMentionPalette.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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<HTMLButtonElement>('[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)
})
})
141 changes: 141 additions & 0 deletions opensquilla-webui/src/components/chat/ChatMentionPalette.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<template>
<div :id="paletteId || 'chat-mention-options'" ref="rootRef" class="chat-slash" :style="availableHeight === null ? undefined : { maxHeight: `${availableHeight}px` }"
role="listbox" aria-label="mentions" @keydown="onKeydown">
<div class="chat-slash-list">
<div v-for="group in groups" :key="group.key" role="group" :aria-label="t(`chat.mention.groups.${group.kind}`)">
<div class="chat-slash-group" aria-hidden="true">{{ t(`chat.mention.groups.${group.kind}`) }}</div>
<ComposerCapabilityList :items="group.items" role="option" row-class="chat-slash-item" preserve-editor-focus @choose="choose" />
</div>
<div v-if="!items.length && !loading && !error" class="chat-slash-empty">{{ t('chat.skillPalette.empty') }}</div>
<div v-if="loading" class="chat-slash-empty" role="status">{{ t('chat.skillPalette.loading') }}</div>
<div v-if="error" class="chat-slash-empty" role="status">{{ error }}</div>
</div>
<div class="chat-slash-hint">{{ t('chat.mention.hint') }}</div>
</div>
</template>

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MentionCandidate } from '@/composables/chat/useChatMentions'
import type { IconName } from '@/utils/icons'
import ComposerCapabilityList, { type ComposerCapabilityItem } from './ComposerCapabilityList.vue'

const props = defineProps<{ items: MentionCandidate[]; activeIndex: number; loading: boolean; error: string; paletteId?: string;
composerElement?: HTMLElement | null }>()
const emit = defineEmits<{ choose: [candidate: MentionCandidate]; close: [] }>()
const { t } = useI18n()
const rootRef = ref<HTMLElement | null>(null)
const availableHeight = ref<number | null>(null)
let resizeObserver: ResizeObserver | null = null
function updatePlacement() {
const top = props.composerElement?.getBoundingClientRect().top
availableHeight.value = top === undefined ? null : Math.max(0, Math.floor(Math.min(
400, (window.visualViewport?.height || window.innerHeight) * 0.5,
top - (window.visualViewport?.offsetTop || 0) - 16,
)))
}
watch(() => props.composerElement, (element, previous) => {
if (previous) resizeObserver?.unobserve(previous)
if (element) resizeObserver?.observe(element)
updatePlacement()
})
onMounted(() => {
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(updatePlacement)
if (props.composerElement) resizeObserver.observe(props.composerElement)
}
window.addEventListener('resize', updatePlacement)
window.visualViewport?.addEventListener('resize', updatePlacement)
window.visualViewport?.addEventListener('scroll', updatePlacement)
updatePlacement()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
window.removeEventListener('resize', updatePlacement)
window.visualViewport?.removeEventListener('resize', updatePlacement)
window.visualViewport?.removeEventListener('scroll', updatePlacement)
})
// A mention is either a workspace entry (directory or file) or a recent chat.
// Read the candidate fields defensively so the palette keeps rendering whatever
// extra metadata the mention source attaches to a row.
type MentionRecord = { kind?: string; label?: string; name?: string; path?: string; title?: string; description?: string; detail?: string; subtitle?: string; id?: string }
const MENTION_ICONS: Record<string, IconName> = { workspace_directory: 'folder', workspace_file: 'fileText', session: 'chat' }
function record(candidate: MentionCandidate): MentionRecord {
return (candidate || {}) as unknown as MentionRecord
}
function candidateLabel(candidate: MentionCandidate): string {
const value = record(candidate).label ?? record(candidate).name ?? record(candidate).path ?? record(candidate).title ?? record(candidate).id
return typeof value === 'string' ? value : ''
}
function candidateDescription(candidate: MentionCandidate): string | undefined {
const value = record(candidate).description ?? record(candidate).detail ?? record(candidate).subtitle
return typeof value === 'string' && value ? value : undefined
}
function candidateIcon(candidate: MentionCandidate): IconName {
return MENTION_ICONS[record(candidate).kind || ''] || 'fileText'
}
const groups = computed(() => {
const workspace: ComposerCapabilityItem[] = []
const sessions: ComposerCapabilityItem[] = []
props.items.forEach((candidate, index) => {
const row: ComposerCapabilityItem = { key: String(index), id: `${props.paletteId || 'chat-mention'}-option-${index}`,
label: candidateLabel(candidate), description: candidateDescription(candidate), icon: candidateIcon(candidate),
active: index === props.activeIndex }
if (record(candidate).kind === 'session') sessions.push(row)
else workspace.push(row)
})
// Fixed group order: workspace first, then chats; empty groups never render.
return [{ key: 'workspace', kind: 'workspace', items: workspace }, { key: 'sessions', kind: 'sessions', items: sessions }]
.filter(group => group.items.length > 0)
})
function choose(item: ComposerCapabilityItem) {
const candidate = props.items[Number(item.key)]
if (candidate) emit('choose', candidate)
}
function onKeydown(event: KeyboardEvent) {
if (event.isComposing || event.keyCode === 229) {
if (event.key === 'Enter') event.preventDefault()
event.stopPropagation()
return
}
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); emit('close'); return }
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
const rows = [...(rootRef.value?.querySelectorAll<HTMLButtonElement>('[role="option"]') || [])]
const active = rows.indexOf(document.activeElement as HTMLButtonElement)
if (active < 0 || !rows.length) return
event.preventDefault(); event.stopPropagation()
const next = event.key === 'Home' ? 0 : event.key === 'End' ? rows.length - 1
: (active + (event.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length
rows[next]?.focus()
rows[next]?.scrollIntoView?.({ block: 'nearest' })
} else if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
}
</script>

<style scoped>
.chat-slash {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
right: 0;
display: flex;
flex-direction: column;
width: 100%;
max-height: min(400px, 50dvh);
box-sizing: border-box;
overflow: hidden;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
z-index: 30;
container: composer-capabilities / inline-size;
}
.chat-slash-list { min-height: 0; padding: 5px; overflow-y: auto; overscroll-behavior: contain; }
.chat-slash-group { padding: 5px 10px 3px; color: var(--text-muted); font-size: var(--fs-xs); font-weight: 500; line-height: 1.4; }
.chat-slash-list > [role="group"] + [role="group"] { margin-top: 5px; }
.chat-slash-empty, .chat-slash-hint { padding: 8px 10px; color: var(--text-muted); font-size: var(--fs-xs); line-height: 1.5; }
.chat-slash-hint { flex-shrink: 0; border-top: 1px solid var(--border); padding-inline: 15px; }
.chat-slash:has(button:focus-visible) :deep(.composer-capability.is-active:not(:focus-visible)) { background: transparent; }
</style>
Loading
Loading