feat(desktop): Tab into a folder from the `@` popover
Tab and Enter shared one branch, so picking a folder always committed a chip and closed the menu — the list could show `apps/` but never open it. Reaching a nested path meant typing every segment by hand. Split the two intents. Tab re-types the token as a bare path so the next completion lists that folder's children; Enter still commits the folder itself as a chip. Files ignore the distinction — there's nowhere deeper to go. Backspace mirrors the descent, dropping one path segment per press instead of one character, so climbing out costs the same as going in.
This commit is contained in:
parent
ecd5c79636
commit
e00901259c
|
|
@ -0,0 +1,180 @@
|
|||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComposerTrigger } from './hooks/use-composer-trigger'
|
||||
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
||||
/**
|
||||
* Folder navigation in the `@` popover, driven through the REAL hook against a
|
||||
* real contentEditable.
|
||||
*
|
||||
* Tab and Enter used to be the same branch, so picking a folder always
|
||||
* committed a chip and closed the menu — there was no way to walk into a
|
||||
* subdirectory from the list. Tab now descends, Enter still commits, and
|
||||
* Backspace climbs back out one segment.
|
||||
*/
|
||||
function folderItem(path: string): Unstable_TriggerItem {
|
||||
return {
|
||||
id: `@folder:${path}|0`,
|
||||
type: 'folder',
|
||||
label: path.split('/').filter(Boolean).pop() ?? path,
|
||||
metadata: {
|
||||
icon: 'folder',
|
||||
display: path,
|
||||
meta: 'dir',
|
||||
rawText: `@folder:${path}`,
|
||||
insertId: path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setup(initialText: string) {
|
||||
const editor = document.createElement('div')
|
||||
editor.contentEditable = 'true'
|
||||
// The real composer marks its editor with this slot; `composerPlainText`
|
||||
// keys off it to decide whether a DIV contributes a trailing newline.
|
||||
// Without it the harness would silently diverge from production text.
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
renderComposerContents(editor, initialText)
|
||||
|
||||
// Caret at the end, which is where a typed trigger always leaves it.
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
range.collapse(false)
|
||||
const sel = window.getSelection()
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
|
||||
const editorRef = createRef<HTMLDivElement>() as { current: HTMLDivElement | null }
|
||||
editorRef.current = editor
|
||||
|
||||
const draftRef = { current: initialText }
|
||||
const setComposerText = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useComposerTrigger({
|
||||
at: { adapter: null, loading: false },
|
||||
draftRef,
|
||||
editorRef,
|
||||
requestMainFocus: vi.fn(),
|
||||
setComposerText,
|
||||
slash: { adapter: null, loading: false }
|
||||
})
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.refreshTrigger()
|
||||
})
|
||||
|
||||
return { editor, result, setComposerText }
|
||||
}
|
||||
|
||||
describe('@ folder navigation', () => {
|
||||
it('Tab on a folder walks into it and keeps the popover open', () => {
|
||||
const { editor, result } = setup('@app')
|
||||
|
||||
expect(result.current.trigger).toMatchObject({ kind: '@', query: 'app' })
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true })
|
||||
})
|
||||
|
||||
// Plain text, not a chip — the token is still being typed.
|
||||
expect(composerPlainText(editor)).toBe('@apps/')
|
||||
expect(editor.querySelector('[data-ref-text]')).toBeNull()
|
||||
})
|
||||
|
||||
it('descends repeatedly, one level per Tab', () => {
|
||||
const { editor, result } = setup('@apps/desk')
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(folderItem('apps/desktop'), { descend: true })
|
||||
})
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@apps/desktop/')
|
||||
})
|
||||
|
||||
it('Enter on a folder commits it as a chip instead of descending', () => {
|
||||
const { editor, result } = setup('@app')
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(folderItem('apps'))
|
||||
})
|
||||
|
||||
const chip = editor.querySelector('[data-ref-text]')
|
||||
|
||||
expect(chip).not.toBeNull()
|
||||
expect(chip?.getAttribute('data-ref-kind')).toBe('folder')
|
||||
expect(composerPlainText(editor)).toContain('@folder:')
|
||||
})
|
||||
|
||||
it('only descends for `@` folders — a file pick still commits', () => {
|
||||
const { editor, result } = setup('@main')
|
||||
|
||||
const file: Unstable_TriggerItem = {
|
||||
id: '@file:src/main.tsx|0',
|
||||
type: 'file',
|
||||
label: 'main.tsx',
|
||||
metadata: {
|
||||
icon: 'file',
|
||||
display: 'main.tsx',
|
||||
meta: 'src',
|
||||
rawText: '@file:src/main.tsx',
|
||||
insertId: 'src/main.tsx'
|
||||
}
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(file, { descend: true })
|
||||
})
|
||||
|
||||
expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('Backspace climbs out one segment at a time', () => {
|
||||
const { editor, result } = setup('@apps/desktop/')
|
||||
|
||||
let handled = false
|
||||
act(() => {
|
||||
handled = result.current.ascendTriggerPath()
|
||||
})
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(composerPlainText(editor)).toBe('@apps/')
|
||||
})
|
||||
|
||||
it('Backspace drops a partially typed segment before its parent', () => {
|
||||
const { editor, result } = setup('@apps/desk')
|
||||
|
||||
act(() => {
|
||||
result.current.ascendTriggerPath()
|
||||
})
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@apps/')
|
||||
})
|
||||
|
||||
it('leaves Backspace alone when there is no path to climb', () => {
|
||||
const { result } = setup('@apps')
|
||||
|
||||
let handled = true
|
||||
act(() => {
|
||||
handled = result.current.ascendTriggerPath()
|
||||
})
|
||||
|
||||
// No `/` in the query — normal character deletion must still happen.
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves text typed before the mention', () => {
|
||||
const { editor, result } = setup('look at @app')
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true })
|
||||
})
|
||||
|
||||
expect(composerPlainText(editor)).toBe('look at @apps/')
|
||||
})
|
||||
})
|
||||
|
|
@ -189,7 +189,7 @@ export function useComposerTrigger({
|
|||
return true
|
||||
}
|
||||
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem, options?: { descend?: boolean }) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor || !trigger) {
|
||||
|
|
@ -219,6 +219,31 @@ export function useComposerTrigger({
|
|||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
const starter = serialized.endsWith(':')
|
||||
|
||||
// Tab on a folder walks INTO it instead of committing it: re-type the
|
||||
// token as the bare path so the next `complete.path` lists that folder's
|
||||
// children, exactly as typing the path by hand would. Enter still commits
|
||||
// the folder itself — the two intents are distinct, so the keys are too.
|
||||
// Only `@` folders descend; a slash command's arg list has no hierarchy.
|
||||
const descendInto =
|
||||
options?.descend && trigger.kind === '@' && item.type === 'folder'
|
||||
? String((item.metadata as { insertId?: unknown } | undefined)?.insertId ?? '')
|
||||
: ''
|
||||
|
||||
if (descendInto) {
|
||||
const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/`
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
renderComposerContents(editor, `${prefix}@${path}`)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = composerPlainText(editor)
|
||||
setComposerText(draftRef.current)
|
||||
requestMainFocus()
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
|
||||
// it — expand to its options step so the popover shows the inline list, just
|
||||
// as typing `/personality ` by hand would. A serialized value with a space is
|
||||
|
|
@ -300,8 +325,37 @@ export function useComposerTrigger({
|
|||
finish()
|
||||
}
|
||||
|
||||
/** Backspace inside an `@` path drops the last segment (`a/b/` → `a/`)
|
||||
* instead of one character. Descending is one Tab per level, so climbing
|
||||
* back out should cost one key too rather than a held delete. Returns
|
||||
* false when the caret isn't in a path, so keydown falls through. */
|
||||
const ascendTriggerPath = () => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor || trigger?.kind !== '@' || !trigger.query.includes('/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trailing slash means we're listing a folder's children: drop that
|
||||
// folder. Otherwise a partial segment is typed — drop just that.
|
||||
const trimmed = trigger.query.replace(/\/$/, '')
|
||||
const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1)
|
||||
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
renderComposerContents(editor, `${prefix}@${parent}`)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = composerPlainText(editor)
|
||||
setComposerText(draftRef.current)
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
argStageEmpty,
|
||||
ascendTriggerPath,
|
||||
closeTrigger,
|
||||
commitTypedSlashDirective,
|
||||
refreshTrigger,
|
||||
|
|
|
|||
|
|
@ -299,6 +299,7 @@ export function ChatBar({
|
|||
// this API; keyup uses triggerKeyConsumedRef to skip its refresh.
|
||||
const {
|
||||
argStageEmpty,
|
||||
ascendTriggerPath,
|
||||
closeTrigger,
|
||||
commitTypedSlashDirective,
|
||||
refreshTrigger,
|
||||
|
|
@ -556,12 +557,24 @@ export function ChatBar({
|
|||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
replaceTriggerWithChip(item)
|
||||
// Tab means "go deeper" on a folder; Enter means "I want this one".
|
||||
// Everything else treats them alike.
|
||||
replaceTriggerWithChip(item, { descend: event.key === 'Tab' })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Backspace climbs out of an `@` path one segment at a time, mirroring
|
||||
// Tab's one-key descent. Only when the caret sits at the end of the
|
||||
// token — mid-token editing keeps normal character deletion.
|
||||
if (event.key === 'Backspace' && !event.metaKey && !event.altKey && ascendTriggerPath()) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
|
|
|||
Loading…
Reference in New Issue