From e00901259c6383744dc6ac13d0939f88f3154971 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 16:20:58 -0500 Subject: [PATCH] feat(desktop): Tab into a folder from the `@` popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../composer/at-folder-navigation.test.tsx | 180 ++++++++++++++++++ .../composer/hooks/use-composer-trigger.ts | 56 +++++- apps/desktop/src/app/chat/composer/index.tsx | 15 +- 3 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx diff --git a/apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx b/apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx new file mode 100644 index 0000000000000..25df1140c5849 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx @@ -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() 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/') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index 2df218e78f30d..5f0be41c80c0b 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -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, diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index b5a76f3490408..6ce8289fa0af0 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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