mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Merge pull request #74668 from NousResearch/bb/composer-attach
Attaching a file, folder, or link works like a picker, not a syntax
This commit is contained in:
commit
9650f555d0
22 changed files with 1283 additions and 303 deletions
131
apps/desktop/src/app/chat/composer/directive-label.test.ts
Normal file
131
apps/desktop/src/app/chat/composer/directive-label.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
|
||||
import { classify } from './hooks/use-at-completions'
|
||||
import { useComposerTrigger } from './hooks/use-composer-trigger'
|
||||
import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
||||
/** A row exactly as tui_gateway's complete.path emits it, run through the
|
||||
* real classify() the popover uses. */
|
||||
function backendRow(text: string, display: string, meta: string): Unstable_TriggerItem {
|
||||
const c = classify({ text, display, meta })
|
||||
|
||||
return {
|
||||
id: `${text}|0`,
|
||||
type: c.type,
|
||||
label: c.display,
|
||||
metadata: { icon: c.type, display: c.display, meta: c.meta, rawText: text, insertId: c.insertId }
|
||||
}
|
||||
}
|
||||
|
||||
function typed(text: string) {
|
||||
const editor = document.createElement('div')
|
||||
|
||||
editor.contentEditable = 'true'
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
editor.append(document.createTextNode(text))
|
||||
|
||||
const range = document.createRange()
|
||||
|
||||
range.selectNodeContents(editor)
|
||||
range.collapse(false)
|
||||
|
||||
const sel = window.getSelection()
|
||||
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
|
||||
const editorRef = { current: editor as HTMLDivElement | null }
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useComposerTrigger({
|
||||
at: { adapter: null, loading: false },
|
||||
draftRef: { current: text },
|
||||
editorRef,
|
||||
requestMainFocus: vi.fn(),
|
||||
setComposerText: vi.fn(),
|
||||
slash: { adapter: null, loading: false }
|
||||
})
|
||||
)
|
||||
|
||||
act(() => result.current.refreshTrigger())
|
||||
|
||||
return { editor, result }
|
||||
}
|
||||
|
||||
/** The label the sent message renders for a committed draft. */
|
||||
function sentLabel(draft: string) {
|
||||
return hermesDirectiveFormatter
|
||||
.parse(draft)
|
||||
.filter((s): s is Extract<typeof s, { kind: 'mention' }> => s.kind === 'mention')
|
||||
.map(s => s.label)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
describe('one label per reference, on every surface', () => {
|
||||
it('the popover row, the committed chip, and the sent chip all read the same', () => {
|
||||
const cases = [
|
||||
{ text: '@folder:apps/desktop/', display: 'desktop/', meta: 'dir' },
|
||||
{ text: '@file:apps/desktop/src/main.tsx', display: 'main.tsx', meta: 'apps/desktop/src' },
|
||||
{ text: '@folder:apps/desktop/src/', display: 'src/', meta: 'dir' }
|
||||
]
|
||||
|
||||
for (const entry of cases) {
|
||||
const item = backendRow(entry.text, entry.display, entry.meta)
|
||||
const { editor, result } = typed('@desk')
|
||||
|
||||
act(() => result.current.replaceTriggerWithChip(item))
|
||||
|
||||
const row = String((item.metadata as { display: string }).display)
|
||||
const chip = editor.querySelector('[data-ref-text]')?.textContent ?? ''
|
||||
|
||||
expect(chip).toBe(row)
|
||||
expect(sentLabel(composerPlainText(editor))).toBe(row)
|
||||
}
|
||||
})
|
||||
|
||||
it('a folder pick reads as its path, not a bare basename', () => {
|
||||
// `src` and `desktop` repeat all over a repo — the row you picked said
|
||||
// where it was, and the chip has to keep saying it.
|
||||
const item = backendRow('@folder:apps/desktop/', 'desktop/', 'dir')
|
||||
|
||||
expect(item.label).toBe('apps/desktop/')
|
||||
|
||||
const { editor, result } = typed('@desk')
|
||||
|
||||
act(() => result.current.replaceTriggerWithChip(item))
|
||||
|
||||
expect(editor.querySelector('[data-ref-text]')?.textContent).toBe('apps/desktop/')
|
||||
})
|
||||
|
||||
it('Tab-descend leaves the live query, and the scope when there is one', () => {
|
||||
const { editor, result } = typed('@folder:desk')
|
||||
|
||||
act(() =>
|
||||
result.current.replaceTriggerWithChip(backendRow('@folder:apps/desktop/', 'desktop/', 'dir'), {
|
||||
descend: true
|
||||
})
|
||||
)
|
||||
|
||||
// Mid-browse the editor holds the live query, scope included — that's the
|
||||
// path being typed, not a label, and it's what the next completion reads.
|
||||
expect(composerPlainText(editor)).toBe('@folder:apps/desktop/')
|
||||
})
|
||||
|
||||
it('a url still reads host + path on every surface', () => {
|
||||
const item = backendRow('@url:https://github.com/NousResearch/hermes-agent/pull/74533', '', '')
|
||||
const { editor, result } = typed('@gith')
|
||||
|
||||
act(() => result.current.replaceTriggerWithChip(item))
|
||||
|
||||
const expected = 'github.com/NousResearch/hermes-agent/pull/74533'
|
||||
|
||||
expect(item.label).toBe(expected)
|
||||
expect(editor.querySelector('[data-ref-text]')?.textContent).toBe(expected)
|
||||
expect(sentLabel(composerPlainText(editor))).toBe(expected)
|
||||
})
|
||||
})
|
||||
164
apps/desktop/src/app/chat/composer/directive-scope.test.ts
Normal file
164
apps/desktop/src/app/chat/composer/directive-scope.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComposerTrigger } from './hooks/use-composer-trigger'
|
||||
import { pathifyRefs } from './path-refs'
|
||||
import { composerPlainText, insertComposerContentsAtCaret, RICH_INPUT_SLOT } from './rich-editor'
|
||||
import { detectTrigger, openDirectiveScope, textBeforeCaret } from './text-utils'
|
||||
import { linkifyUrls } from './url-refs'
|
||||
|
||||
function folderItem(rel: string): Unstable_TriggerItem {
|
||||
const rawText = `@folder:${rel}/`
|
||||
|
||||
return {
|
||||
id: `${rawText}|0`,
|
||||
type: 'folder',
|
||||
label: rel.split('/').filter(Boolean).pop() ?? rel,
|
||||
metadata: { icon: 'folder', display: `${rel}/`, meta: 'dir', rawText, insertId: `${rel}/` }
|
||||
}
|
||||
}
|
||||
|
||||
/** Literally-typed text, caret `fromEnd` characters before the end. */
|
||||
function typed(text: string, fromEnd = 0) {
|
||||
const editor = document.createElement('div')
|
||||
|
||||
editor.contentEditable = 'true'
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
|
||||
const node = document.createTextNode(text)
|
||||
|
||||
editor.append(node)
|
||||
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(node, text.length - fromEnd)
|
||||
range.collapse(true)
|
||||
|
||||
const sel = window.getSelection()
|
||||
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
function withTrigger(editor: HTMLDivElement, draft: string) {
|
||||
const editorRef = { current: editor as HTMLDivElement | null }
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useComposerTrigger({
|
||||
at: { adapter: null, loading: false },
|
||||
draftRef: { current: draft },
|
||||
editorRef,
|
||||
requestMainFocus: vi.fn(),
|
||||
setComposerText: vi.fn(),
|
||||
slash: { adapter: null, loading: false }
|
||||
})
|
||||
)
|
||||
|
||||
act(() => result.current.refreshTrigger())
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** The composer's paste handler, minus the clipboard plumbing. */
|
||||
function paste(editor: HTMLDivElement, text: string) {
|
||||
insertComposerContentsAtCaret(editor, pathifyRefs(linkifyUrls(text)), openDirectiveScope(editor))
|
||||
}
|
||||
|
||||
describe('directive scope is a browse mode, not text to maintain', () => {
|
||||
it('Tab-descend carries the scope down instead of dropping to a bare path', () => {
|
||||
const editor = typed('@folder:apps/deskt')
|
||||
const result = withTrigger(editor, '@folder:apps/deskt')
|
||||
|
||||
expect(result.current.trigger).toMatchObject({ kind: '@', scope: 'folder', value: 'apps/deskt' })
|
||||
|
||||
act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop'), { descend: true }))
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@folder:apps/desktop/')
|
||||
})
|
||||
|
||||
it('Backspace climbs the path, then drops the whole scope', () => {
|
||||
const editor = typed('@folder:apps/desktop/')
|
||||
const result = withTrigger(editor, '@folder:apps/desktop/')
|
||||
|
||||
act(() => result.current.ascendTriggerPath())
|
||||
expect(composerPlainText(editor)).toBe('@folder:apps/')
|
||||
|
||||
act(() => result.current.refreshTrigger())
|
||||
act(() => result.current.ascendTriggerPath())
|
||||
expect(composerPlainText(editor)).toBe('@folder:')
|
||||
|
||||
// The scope is one unit: Backspace drops it whole rather than nibbling
|
||||
// back through `:`, `r`, `e`, `d`, `l`, `o`, `f`.
|
||||
act(() => result.current.refreshTrigger())
|
||||
act(() => result.current.ascendTriggerPath())
|
||||
expect(composerPlainText(editor)).toBe('@')
|
||||
})
|
||||
|
||||
it('leaves Backspace alone when there is no scope and no path', () => {
|
||||
const editor = typed('@apps')
|
||||
const result = withTrigger(editor, '@apps')
|
||||
|
||||
let handled = true
|
||||
|
||||
act(() => {
|
||||
handled = result.current.ascendTriggerPath()
|
||||
})
|
||||
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
|
||||
it('a pick mid-message keeps the trailing prose and consumes the whole token', () => {
|
||||
const editor = typed('@folder:apps/deskt and some trailing words', 24)
|
||||
const result = withTrigger(editor, '@folder:apps/deskt and some trailing words')
|
||||
|
||||
act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop')))
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@folder:`apps/desktop/` and some trailing words')
|
||||
expect(editor.querySelector('[data-ref-kind="folder"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('pasting into an open @url: scope consumes it instead of stacking', () => {
|
||||
const editor = typed('refer to @url:')
|
||||
|
||||
paste(editor, 'https://github.com/NousResearch/hermes-agent/pull/74533')
|
||||
|
||||
expect(composerPlainText(editor)).toBe('refer to @url:`https://github.com/NousResearch/hermes-agent/pull/74533`')
|
||||
expect(editor.textContent).not.toContain('@url:@url:')
|
||||
})
|
||||
|
||||
it('a normal paste with no open scope is untouched', () => {
|
||||
const editor = typed('look at ')
|
||||
|
||||
paste(editor, 'https://example.com/x')
|
||||
|
||||
expect(composerPlainText(editor)).toBe('look at @url:`https://example.com/x`')
|
||||
})
|
||||
|
||||
it('scope parsing leaves an unscoped @ query alone', () => {
|
||||
expect(detectTrigger('@apps/desk')).toMatchObject({ kind: '@', value: 'apps/desk' })
|
||||
expect(detectTrigger('@apps/desk')?.scope).toBeUndefined()
|
||||
})
|
||||
|
||||
it('openDirectiveScope only fires on an EMPTY scope', () => {
|
||||
// The count is what a paste consumes: `@url:` is 5 characters of syntax
|
||||
// the user never typed and shouldn't be left holding.
|
||||
expect(openDirectiveScope(typed('@url:'))).toBe(5)
|
||||
expect(openDirectiveScope(typed('@url:https://x.com'))).toBe(0)
|
||||
expect(openDirectiveScope(typed('plain text'))).toBe(0)
|
||||
})
|
||||
|
||||
it('chips stay atomic to scope detection', () => {
|
||||
const editor = typed('@folder:apps/desktop/')
|
||||
const result = withTrigger(editor, '@folder:apps/desktop/')
|
||||
|
||||
act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop')))
|
||||
|
||||
// A committed chip is one object-replacement char, so a fresh `@` typed
|
||||
// after it opens an unscoped browse rather than inheriting the old scope.
|
||||
expect(detectTrigger(`${textBeforeCaret(editor)}@`)?.scope).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
|
||||
import { useAtCompletions } from './use-at-completions'
|
||||
|
||||
function gatewayStub(latencyMs = 40) {
|
||||
const calls: string[] = []
|
||||
|
||||
const gateway = {
|
||||
request: vi.fn(async (_method: string, params: { word: string }) => {
|
||||
calls.push(params.word)
|
||||
await new Promise(r => setTimeout(r, latencyMs))
|
||||
|
||||
return { items: [{ text: `@folder:${params.word.slice(1)}x/`, display: 'x/', meta: 'dir' }] }
|
||||
})
|
||||
}
|
||||
|
||||
return { calls, gateway }
|
||||
}
|
||||
|
||||
function setup(latencyMs = 40) {
|
||||
const { calls, gateway } = gatewayStub(latencyMs)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAtCompletions({ gateway: gateway as never, sessionId: 's1', cwd: '/repo' })
|
||||
)
|
||||
|
||||
return { calls, result }
|
||||
}
|
||||
|
||||
/** Type a burst of keystrokes `gapMs` apart, like a person. */
|
||||
async function type(result: { current: { adapter: { search?: (q: string) => unknown } } }, queries: string[], gapMs: number) {
|
||||
for (const q of queries) {
|
||||
act(() => {
|
||||
result.current.adapter.search?.(q)
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(gapMs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('PERF: @ path completions are cached and skip the debounce', () => {
|
||||
it('serves a repeated query with no round trip and no spinner', async () => {
|
||||
vi.useFakeTimers()
|
||||
queryClient.clear()
|
||||
|
||||
const { calls, result } = setup()
|
||||
|
||||
// First visit to `apps/` pays the round trip.
|
||||
await type(result, ['apps/'], 0)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
})
|
||||
|
||||
const afterFirst = calls.length
|
||||
|
||||
expect(afterFirst).toBe(1)
|
||||
|
||||
// Walk away and come back — Tab in, Backspace out, retype. Every one of
|
||||
// these used to be a fresh git ls-files + rank on the backend.
|
||||
await type(result, ['apps/desktop/', 'apps/', 'apps/desktop/', 'apps/'], 0)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
})
|
||||
|
||||
// Two distinct paths, so exactly two round trips total — the repeats are free.
|
||||
expect(calls.length).toBe(2)
|
||||
expect(result.current.loading).toBe(false)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('a cached query paints without waiting out the debounce', async () => {
|
||||
vi.useFakeTimers()
|
||||
queryClient.clear()
|
||||
|
||||
const { calls, result } = setup()
|
||||
|
||||
await type(result, ['apps/'], 0)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
})
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
|
||||
// Re-ask for the cached query and advance by far less than the 60ms
|
||||
// debounce. A cached answer resolves in a microtask, so it must paint
|
||||
// without the timer and without ever flipping the spinner on.
|
||||
act(() => {
|
||||
result.current.adapter.search?.('apps/')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(calls.length).toBe(1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { refChipLabel } from '@/components/assistant-ui/directive-text'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { cachedPathCompletion, hasCachedPathCompletion } from '@/lib/slash-completion-cache'
|
||||
import { normalize } from '@/lib/text'
|
||||
|
||||
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
|
||||
|
|
@ -60,7 +62,14 @@ function classify(entry: CompletionEntry): {
|
|||
return {
|
||||
type: kind,
|
||||
insertId: rest,
|
||||
display: textValue(entry.display, rest || `@${kind}:`),
|
||||
// The row shows exactly what picking it produces. Upstream keeps one
|
||||
// label per item and hands it to the chip verbatim (DirectiveNode's
|
||||
// `__label = item.label`); our wire format is `@kind:value`, which can't
|
||||
// carry a label the way their `:type[label]{name=id}` does, so the same
|
||||
// invariant is held by deriving both ends from refChipLabel. Without
|
||||
// this the list said `desktop/`, the editor said `apps/desktop/`, and
|
||||
// the chip said `desktop` — three names for one folder.
|
||||
display: rest ? refChipLabel(kind, rest) : textValue(entry.display, `@${kind}:`),
|
||||
meta: textValue(entry.meta)
|
||||
}
|
||||
}
|
||||
|
|
@ -82,6 +91,11 @@ export function useAtCompletions(options: {
|
|||
const { gateway, sessionId, cwd } = options
|
||||
const enabled = Boolean(gateway)
|
||||
|
||||
// Cache key: the completion depends on the query AND the directory it's
|
||||
// resolved against, so a cwd or session change can't serve another tree's
|
||||
// listing.
|
||||
const cacheKey = useCallback((query: string) => `${cwd ?? ''}|${sessionId ?? ''}|${query}`, [cwd, sessionId])
|
||||
|
||||
const fetcher = useCallback(
|
||||
async (query: string): Promise<CompletionPayload> => {
|
||||
const starters = starterEntries(query)
|
||||
|
|
@ -102,7 +116,14 @@ export function useAtCompletions(options: {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.path', params)
|
||||
// De-duplicated the same way `/` completions are. Walking a path is
|
||||
// inherently repetitive — Tab into a folder, Backspace out, retype a
|
||||
// segment — and every one of those steps used to be a fresh
|
||||
// `git ls-files` + rank on the backend (~40ms of the ~50ms round trip
|
||||
// measured on this repo's 8k files).
|
||||
const result = await cachedPathCompletion(cacheKey(query), () =>
|
||||
gateway.request<{ items?: CompletionEntry[] }>('complete.path', params)
|
||||
)
|
||||
const items = result.items ?? []
|
||||
|
||||
return { items: items.length > 0 ? items : starters, query }
|
||||
|
|
@ -110,7 +131,7 @@ export function useAtCompletions(options: {
|
|||
return { items: starters, query }
|
||||
}
|
||||
},
|
||||
[gateway, sessionId, cwd]
|
||||
[cacheKey, gateway, sessionId, cwd]
|
||||
)
|
||||
|
||||
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
|
||||
|
|
@ -135,7 +156,13 @@ export function useAtCompletions(options: {
|
|||
}
|
||||
}, [])
|
||||
|
||||
return useLiveCompletionAdapter({ enabled, fetcher, toItem })
|
||||
// A query already in cache skips both the debounce and the loading state.
|
||||
// This is what makes walking a tree feel instant rather than merely fast:
|
||||
// the 60ms debounce exists to avoid a request per keystroke, and it buys
|
||||
// nothing when the answer is already in hand.
|
||||
const isCached = useCallback((query: string) => hasCachedPathCompletion(cacheKey(query)), [cacheKey])
|
||||
|
||||
return useLiveCompletionAdapter({ enabled, fetcher, isCached, toItem })
|
||||
}
|
||||
|
||||
/** Re-export `classify` for use by the formatter (insertion side). */
|
||||
|
|
|
|||
|
|
@ -12,15 +12,59 @@ import {
|
|||
slashCommandToken
|
||||
} from '../composer-utils'
|
||||
import {
|
||||
appendComposerContents,
|
||||
caretOffsetInEditor,
|
||||
composerPlainText,
|
||||
placeCaretEnd,
|
||||
placeCaretAtOffset,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
replaceBeforeCaret,
|
||||
RICH_INPUT_SLOT,
|
||||
slashChipElement
|
||||
} from '../rich-editor'
|
||||
import { detectTrigger, textBeforeCaret, type TriggerState } from '../text-utils'
|
||||
|
||||
/**
|
||||
* Rebuild-from-text fallback for carets the range walk can't anchor (a
|
||||
* non-collapsed selection, a caret not preceded by contiguous text). It
|
||||
* re-renders the whole editor from serialized text, so it only runs when the
|
||||
* in-place path reports failure — never as the default.
|
||||
*
|
||||
* The split is around the CARET, not the end of the draft. Slicing
|
||||
* `length - tokenLength` off the end assumed the trigger token was the last
|
||||
* thing in the editor: a completion picked mid-message chopped the trailing
|
||||
* prose off and stranded a partial `folder:` in front of the chip, because the
|
||||
* window it removed wasn't the token the user was typing.
|
||||
*/
|
||||
export function rebuildAroundCaret(editor: HTMLDivElement, tokenLength: number, insert: DocumentFragment | string) {
|
||||
const current = composerPlainText(editor)
|
||||
const caret = caretOffsetInEditor(editor)
|
||||
const prefix = current.slice(0, Math.max(0, caret - tokenLength))
|
||||
const suffix = current.slice(caret)
|
||||
|
||||
if (typeof insert === 'string') {
|
||||
renderComposerContents(editor, `${prefix}${insert}${suffix}`)
|
||||
placeCaretAtOffset(editor, prefix.length + insert.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Measure before appending — moving a fragment empties it. Appending the
|
||||
// element rather than re-serializing keeps mid-message slash pills alive:
|
||||
// they have no text hydration, unlike `@` refs and the leading command.
|
||||
const scratch = document.createElement('div')
|
||||
|
||||
scratch.dataset.slot = RICH_INPUT_SLOT
|
||||
scratch.append(insert.cloneNode(true))
|
||||
|
||||
const inserted = composerPlainText(scratch)
|
||||
|
||||
renderComposerContents(editor, prefix)
|
||||
editor.append(insert)
|
||||
appendComposerContents(editor, suffix)
|
||||
placeCaretAtOffset(editor, prefix.length + inserted.length)
|
||||
}
|
||||
|
||||
interface CompletionSource {
|
||||
adapter: Unstable_TriggerAdapter | null
|
||||
loading: boolean
|
||||
|
|
@ -238,16 +282,8 @@ export function useComposerTrigger({
|
|||
// and a pick must be exactly one undo step.
|
||||
recordUndoPoint?.()
|
||||
|
||||
// Rebuild-from-text fallback for carets the range walk can't anchor (a
|
||||
// non-collapsed selection, a caret not preceded by contiguous text). It
|
||||
// re-renders the whole editor from serialized text, so it only runs when
|
||||
// the in-place path reports failure — never as the default.
|
||||
const rebuildWith = (render: (prefix: string) => void) => {
|
||||
const current = composerPlainText(editor)
|
||||
|
||||
render(current.slice(0, Math.max(0, current.length - trigger.tokenLength)))
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
const rebuildAround = (insert: DocumentFragment | string) =>
|
||||
rebuildAroundCaret(editor, trigger.tokenLength, insert)
|
||||
|
||||
// Action items (e.g. "Browse all sessions…") run a side effect instead of
|
||||
// inserting a chip: strip the typed trigger token, then fire the action.
|
||||
|
|
@ -256,7 +292,7 @@ export function useComposerTrigger({
|
|||
|
||||
if (runAction) {
|
||||
if (!replaceBeforeCaret(editor, trigger.tokenLength, document.createDocumentFragment())) {
|
||||
rebuildWith(prefix => renderComposerContents(editor, prefix))
|
||||
rebuildAround('')
|
||||
}
|
||||
|
||||
draftRef.current = composerPlainText(editor)
|
||||
|
|
@ -290,12 +326,17 @@ export function useComposerTrigger({
|
|||
|
||||
if (descendInto) {
|
||||
const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/`
|
||||
// Carry the browse scope down with the path. Dropping it turned an
|
||||
// explicit `@folder:` browse into a bare `@apps/desktop/` token halfway
|
||||
// through, so the next completion silently widened back to files and the
|
||||
// committed chip had to re-guess the kind from a trailing slash.
|
||||
const scope = trigger.scope ? `${trigger.scope}:` : ''
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
fragment.append(document.createTextNode(`@${path}`))
|
||||
fragment.append(document.createTextNode(`@${scope}${path}`))
|
||||
|
||||
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
|
||||
rebuildWith(prefix => renderComposerContents(editor, `${prefix}@${path}`))
|
||||
rebuildAround(`@${scope}${path}`)
|
||||
}
|
||||
|
||||
return finish(true)
|
||||
|
|
@ -322,62 +363,73 @@ export function useComposerTrigger({
|
|||
const chip = slashKind
|
||||
? slashChipElement(serialized, slashKind)
|
||||
: directive
|
||||
? refChipElement(directive[1], directive[2])
|
||||
? // Carry the picked row's own label into the chip rather than letting
|
||||
// it re-derive one from the value. Upstream's DirectiveNode does the
|
||||
// same (`__label = item.label`), and it's what makes the list and the
|
||||
// chip agree: you get the string you just read, not a second guess at
|
||||
// it. Falls back to the shared deriver for callers with no label.
|
||||
refChipElement(directive[1], directive[2], (item.metadata as { display?: string })?.display || item.label)
|
||||
: null
|
||||
|
||||
// The trailing space is a convenience for "keep typing after the chip", so
|
||||
// it's wrong when the caret already has whitespace in front of it — a pick
|
||||
// made mid-sentence would leave a double space in the prose.
|
||||
const followedBySpace = /^\s/.test(composerPlainText(editor).slice(caretOffsetInEditor(editor)))
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
chip ? fragment.append(chip, document.createTextNode(' ')) : fragment.append(document.createTextNode(text))
|
||||
chip
|
||||
? fragment.append(chip, ...(followedBySpace ? [] : [document.createTextNode(' ')]))
|
||||
: fragment.append(document.createTextNode(followedBySpace ? text.trimEnd() : text))
|
||||
|
||||
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
|
||||
rebuildWith(prefix => {
|
||||
if (chip) {
|
||||
// The failed in-place attempt never consumed the fragment, so the
|
||||
// chip + trailing space land here instead. Appending the element
|
||||
// keeps mid-message slash pills alive — they have no text
|
||||
// hydration, unlike `@` refs and the leading command.
|
||||
renderComposerContents(editor, prefix)
|
||||
editor.append(fragment)
|
||||
} else {
|
||||
renderComposerContents(editor, `${prefix}${text}`)
|
||||
}
|
||||
})
|
||||
// The failed in-place attempt never consumed the fragment, so the chip +
|
||||
// trailing space are re-inserted around the caret here. Moving the
|
||||
// element (rather than re-serializing) keeps mid-message slash pills
|
||||
// alive — they have no text hydration, unlike `@` refs and the leading
|
||||
// command.
|
||||
rebuildAround(chip ? fragment : text)
|
||||
}
|
||||
|
||||
finish(keepTriggerOpen)
|
||||
}
|
||||
|
||||
/** 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
|
||||
* instead of one character, and once the path is empty it drops the browse
|
||||
* scope (`@folder:` → `@`) rather than nibbling `:`, `r`, `e`, `d`… back
|
||||
* through the directive syntax the user never typed. Descending is one Tab
|
||||
* per level, so climbing back out costs one key per level too. 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('/')) {
|
||||
if (!editor || trigger?.kind !== '@') {
|
||||
return false
|
||||
}
|
||||
|
||||
const scope = trigger.scope ? `${trigger.scope}:` : ''
|
||||
|
||||
if (!trigger.value.includes('/') && !scope) {
|
||||
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(/\/$/, '')
|
||||
// folder. Otherwise a partial segment is typed — drop just that. With the
|
||||
// value already empty, the only thing left to drop is the scope itself.
|
||||
const trimmed = trigger.value.replace(/\/$/, '')
|
||||
const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1)
|
||||
const next = trigger.value ? `${scope}${parent}` : ''
|
||||
|
||||
recordUndoPoint?.()
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
fragment.append(document.createTextNode(`@${parent}`))
|
||||
fragment.append(document.createTextNode(`@${next}`))
|
||||
|
||||
// In place first: the destructive re-render fallback rebuilds the editor
|
||||
// from text, which is exactly what used to demote a leading command pill
|
||||
// to plaintext on every Backspace inside a path.
|
||||
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
renderComposerContents(editor, `${prefix}@${parent}`)
|
||||
placeCaretEnd(editor)
|
||||
rebuildAroundCaret(editor, trigger.tokenLength, `@${next}`)
|
||||
}
|
||||
|
||||
draftRef.current = composerPlainText(editor)
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ import {
|
|||
import { useComposerScope } from './scope'
|
||||
import { ComposerStatusStack } from './status-stack'
|
||||
import { CodingStatusRow } from './status-stack/coding-row'
|
||||
import { extractClipboardImageBlobs } from './text-utils'
|
||||
import { extractClipboardImageBlobs, openDirectiveScope } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
import { isRedoShortcut, isUndoShortcut } from './undo-history'
|
||||
|
|
@ -491,8 +491,13 @@ export function ChatBar({
|
|||
// Links in the paste land as `@url:` chips rather than a wall of URL text —
|
||||
// the same reference the "Add URL" dialog inserts, parsed in place so a link
|
||||
// mid-sentence keeps its position. Bare `@path` tokens promote the same way.
|
||||
// A paste into an open `@url:`/`@file:` scope CONSUMES that scope instead of
|
||||
// stacking on it — the scope is the browse mode the user is pasting into,
|
||||
// not text they typed and want to keep (`@url:@url:\`https://…\``).
|
||||
const scope = openDirectiveScope(event.currentTarget)
|
||||
|
||||
recordUndoPoint()
|
||||
insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText)))
|
||||
insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText)), scope)
|
||||
scheduleFlushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
|
|
@ -1138,6 +1143,7 @@ export function ChatBar({
|
|||
loading={triggerLoading}
|
||||
onHover={setTriggerActive}
|
||||
onPick={replaceTriggerWithChip}
|
||||
scope={trigger.scope}
|
||||
/>
|
||||
)}
|
||||
{!poppedOut && (
|
||||
|
|
|
|||
72
apps/desktop/src/app/chat/composer/inline-references.test.ts
Normal file
72
apps/desktop/src/app/chat/composer/inline-references.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { refAttrs, refAttrsHtml } from '@/components/assistant-ui/directive-text'
|
||||
import { REFERENCE_STYLES, referenceKind, referenceStyle } from '@/components/assistant-ui/reference-kinds'
|
||||
|
||||
/**
|
||||
* There is ONE inline-reference system: `class="ref"` + `data-ref="<kind>"`.
|
||||
* A pasted link, an `@file:` chip, a `/skill`, a `@session:` the agent wrote —
|
||||
* all the same markup, styled by the `.ref` rules in styles.css.
|
||||
*/
|
||||
describe('the inline reference contract', () => {
|
||||
it('marks any element as a reference of a given kind', () => {
|
||||
expect(refAttrs('file')).toEqual({ className: 'ref', 'data-ref': 'file' })
|
||||
expect(refAttrsHtml('skill')).toBe('class="ref" data-ref="skill"')
|
||||
})
|
||||
|
||||
it('an unkinded reference is a plain link, not a broken one', () => {
|
||||
// A bare external link has no kind — it keeps the default link colour
|
||||
// rather than being tagged with a wrong one.
|
||||
expect(refAttrs()).toEqual({ className: 'ref' })
|
||||
expect(refAttrsHtml()).toBe('class="ref"')
|
||||
})
|
||||
|
||||
it('normalises an unknown kind instead of emitting it raw', () => {
|
||||
// A kind CSS has no rule for would silently render unstyled; coercing to
|
||||
// `other` keeps it inside the system.
|
||||
expect(refAttrs('wat')['data-ref']).toBe('other')
|
||||
expect(referenceKind('wat')).toBe('other')
|
||||
})
|
||||
|
||||
it('ships no colour from TypeScript — the theme owns every accent', () => {
|
||||
// The whole point of keying on `data-ref`: a skin restyles all references
|
||||
// at once, and no hex or color-mix() is hardcoded in a component.
|
||||
for (const [kind, style] of Object.entries(REFERENCE_STYLES)) {
|
||||
expect(style, `${kind} must not carry a colour`).not.toHaveProperty('color')
|
||||
}
|
||||
|
||||
expect(JSON.stringify(refAttrs('url'))).not.toMatch(/color|#[0-9a-f]{3}/i)
|
||||
})
|
||||
|
||||
it('gives every kind a glyph and a label', () => {
|
||||
for (const [kind, style] of Object.entries(REFERENCE_STYLES)) {
|
||||
expect(style.codicon, `${kind} codicon`).toBeTruthy()
|
||||
expect(style.label, `${kind} label`).toBeTruthy()
|
||||
|
||||
// Emoji rows render the emoji itself instead of a glyph.
|
||||
if (kind !== 'emoji') {
|
||||
expect(style.paths.length, `${kind} paths`).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps commands and skills visually distinct', () => {
|
||||
// Different data-ref values, so the stylesheet can accent them apart.
|
||||
expect(refAttrs('skill')['data-ref']).not.toBe(refAttrs('command')['data-ref'])
|
||||
expect(referenceStyle('skill').codicon).not.toBe(referenceStyle('command').codicon)
|
||||
})
|
||||
})
|
||||
|
||||
describe('references are text, not badges', () => {
|
||||
it('carries no layout, padding, or background of its own', () => {
|
||||
// Everything visual lives in the stylesheet. If a component starts adding
|
||||
// its own chrome here, that's the drift this system exists to prevent.
|
||||
const { className } = refAttrs('file')
|
||||
|
||||
expect(className).toBe('ref')
|
||||
|
||||
for (const chrome of ['bg-', 'rounded', 'px-', 'py-', 'border', 'inline-flex', 'text-[']) {
|
||||
expect(className).not.toContain(chrome)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -7,15 +7,15 @@
|
|||
* plain-text round-trip.
|
||||
*/
|
||||
import {
|
||||
DIRECTIVE_CHIP_CLASS,
|
||||
directiveIconElement,
|
||||
directiveIconSvg,
|
||||
formatRefValue,
|
||||
refAttrsHtml,
|
||||
refChipLabel,
|
||||
slashChipClass,
|
||||
type SlashChipKind,
|
||||
slashIconElement
|
||||
} from '@/components/assistant-ui/directive-text'
|
||||
import { referenceKind } from '@/components/assistant-ui/reference-kinds'
|
||||
|
||||
import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
|
||||
|
||||
|
|
@ -60,42 +60,38 @@ export function refChipHtml(kind: string, rawValue: string, displayLabel?: strin
|
|||
|
||||
const label = displayLabel || refChipLabel(kind, id)
|
||||
|
||||
return `<span contenteditable="false" title="${escapeHtml(id)}" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(label)}</span></span>`
|
||||
return `<span contenteditable="false" title="${escapeHtml(id)}" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" ${refAttrsHtml(kind)}>${directiveIconSvg(kind)}${escapeHtml(label)}</span>`
|
||||
}
|
||||
|
||||
export function refChipElement(kind: string, rawValue: string, displayLabel?: string) {
|
||||
const id = unquoteRef(rawValue)
|
||||
const text = `@${kind}:${quoteRefValue(id)}`
|
||||
const chip = document.createElement('span')
|
||||
const label = document.createElement('span')
|
||||
|
||||
chip.contentEditable = 'false'
|
||||
chip.title = id
|
||||
chip.dataset.refText = text
|
||||
chip.dataset.refId = id
|
||||
chip.dataset.refKind = kind
|
||||
chip.className = DIRECTIVE_CHIP_CLASS
|
||||
label.className = 'truncate'
|
||||
label.textContent = displayLabel || refChipLabel(kind, id)
|
||||
chip.append(directiveIconElement(kind), label)
|
||||
chip.className = 'ref'
|
||||
chip.dataset.ref = referenceKind(kind)
|
||||
chip.append(directiveIconElement(kind), document.createTextNode(displayLabel || refChipLabel(kind, id)))
|
||||
|
||||
return chip
|
||||
}
|
||||
|
||||
/** A non-editable pill for a picked slash command (`/skin nous`, `/tropes`).
|
||||
/** A non-editable reference for a picked slash command (`/skin nous`, `/tropes`).
|
||||
* `data-ref-text` carries the literal command so `composerPlainText` round-trips
|
||||
* it back to the exact text that gets submitted. */
|
||||
export function slashChipElement(command: string, kind: SlashChipKind, label?: string) {
|
||||
const chip = document.createElement('span')
|
||||
const text = document.createElement('span')
|
||||
|
||||
chip.contentEditable = 'false'
|
||||
chip.dataset.refText = command
|
||||
chip.dataset.slashKind = kind
|
||||
chip.className = slashChipClass(kind)
|
||||
text.className = 'truncate'
|
||||
text.textContent = label || command
|
||||
chip.append(slashIconElement(kind), text)
|
||||
chip.className = 'ref'
|
||||
chip.dataset.ref = kind
|
||||
chip.append(slashIconElement(kind), document.createTextNode(label || command))
|
||||
|
||||
return chip
|
||||
}
|
||||
|
|
@ -228,8 +224,24 @@ function atTokenBoundary(editor: HTMLElement, range: Range | null): boolean {
|
|||
* — Chromium's editing pipeline is ~O(n²) on large multiline blobs.
|
||||
*
|
||||
* The text arrives whole rather than typed, so a `/command` ending it is
|
||||
* complete rather than half-written and chips like the rest. */
|
||||
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) {
|
||||
* complete rather than half-written and chips like the rest.
|
||||
*
|
||||
* `consumeBefore` characters immediately before the caret are swallowed by the
|
||||
* insert. That's how a paste into an open `@url:` scope replaces the scope
|
||||
* instead of stacking on it (`@url:@url:\`https://…\``). */
|
||||
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string, consumeBefore = 0) {
|
||||
const scoped = consumeBefore > 0 ? rangeBeforeCaret(editor, consumeBefore) : null
|
||||
|
||||
if (scoped) {
|
||||
scoped.deleteContents()
|
||||
scoped.collapse(true)
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(scoped)
|
||||
}
|
||||
|
||||
const hit = composerSelectionRange(editor)
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
|
|
|
|||
|
|
@ -4,19 +4,19 @@ import { blobDedupeKey, detectTrigger, extractClipboardImageBlobs } from './text
|
|||
|
||||
describe('detectTrigger', () => {
|
||||
it('detects a bare slash trigger with an empty query', () => {
|
||||
expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1 })
|
||||
expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1, value: '' })
|
||||
})
|
||||
|
||||
it('detects a slash command query', () => {
|
||||
expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6 })
|
||||
expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6, value: 'skill' })
|
||||
})
|
||||
|
||||
it('detects a bare at-mention trigger with an empty query', () => {
|
||||
expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1 })
|
||||
expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1, value: '' })
|
||||
})
|
||||
|
||||
it('detects an at-mention query', () => {
|
||||
expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5 })
|
||||
expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5, value: 'file' })
|
||||
})
|
||||
|
||||
it('returns null for plain text', () => {
|
||||
|
|
@ -27,17 +27,20 @@ describe('detectTrigger', () => {
|
|||
expect(detectTrigger('/personality ')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality ',
|
||||
tokenLength: 13
|
||||
tokenLength: 13,
|
||||
value: 'personality '
|
||||
})
|
||||
expect(detectTrigger('/personality alic')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality alic',
|
||||
tokenLength: 17
|
||||
tokenLength: 17,
|
||||
value: 'personality alic'
|
||||
})
|
||||
expect(detectTrigger('/tools enable foo')).toEqual({
|
||||
kind: '/',
|
||||
query: 'tools enable foo',
|
||||
tokenLength: 17
|
||||
tokenLength: 17,
|
||||
value: 'tools enable foo'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -54,14 +57,25 @@ describe('detectTrigger', () => {
|
|||
it('keeps the at-mention live while walking into subfolders', () => {
|
||||
// A `/` inside the query is path navigation, not the end of the token —
|
||||
// the popover has to stay open so the next directory level can load.
|
||||
expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3 })
|
||||
expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6 })
|
||||
expect(detectTrigger('@~/Desktop/')).toEqual({ kind: '@', query: '~/Desktop/', tokenLength: 11 })
|
||||
expect(detectTrigger('@/usr/local')).toEqual({ kind: '@', query: '/usr/local', tokenLength: 11 })
|
||||
expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3, value: './' })
|
||||
expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6, value: './src' })
|
||||
expect(detectTrigger('@~/Desktop/')).toEqual({
|
||||
kind: '@',
|
||||
query: '~/Desktop/',
|
||||
tokenLength: 11,
|
||||
value: '~/Desktop/'
|
||||
})
|
||||
expect(detectTrigger('@/usr/local')).toEqual({
|
||||
kind: '@',
|
||||
query: '/usr/local',
|
||||
tokenLength: 11,
|
||||
value: '/usr/local'
|
||||
})
|
||||
expect(detectTrigger('@apps/desktop/src')).toEqual({
|
||||
kind: '@',
|
||||
query: 'apps/desktop/src',
|
||||
tokenLength: 17
|
||||
tokenLength: 17,
|
||||
value: 'apps/desktop/src'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -70,20 +84,48 @@ describe('detectTrigger', () => {
|
|||
// assistant-ui's Lexical DirectivePlugin gets the same semantics from node
|
||||
// boundaries: typing a trigger right after a chip (no space) still opens
|
||||
// the popover, and a chip inside a token ends it.
|
||||
expect(detectTrigger('\uFFFC@Desk')).toEqual({ kind: '@', query: 'Desk', tokenLength: 5 })
|
||||
expect(detectTrigger('\uFFFC@Desk')).toEqual({ kind: '@', query: 'Desk', tokenLength: 5, value: 'Desk' })
|
||||
// Not position 0, so it's an inline reference — not a command invocation.
|
||||
expect(detectTrigger('\uFFFC/cle')).toEqual({ inline: true, kind: '/', query: 'cle', tokenLength: 4 })
|
||||
expect(detectTrigger('\uFFFC/cle')).toEqual({
|
||||
inline: true,
|
||||
kind: '/',
|
||||
query: 'cle',
|
||||
tokenLength: 4,
|
||||
value: 'cle'
|
||||
})
|
||||
// The placeholder itself never leaks into a query.
|
||||
expect(detectTrigger('@a\uFFFCb')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the at-mention live for a typed ref kind with a path', () => {
|
||||
it('splits a typed ref kind off as the browse scope', () => {
|
||||
// `@folder:apps/` is ONE token with TWO parts. The kind is the mode the
|
||||
// user is browsing in, so it's held as `scope` rather than left in `value`
|
||||
// for every consumer to re-parse (or, worse, to preserve by hand).
|
||||
expect(detectTrigger('@file:src/main.tsx')).toEqual({
|
||||
kind: '@',
|
||||
query: 'file:src/main.tsx',
|
||||
tokenLength: 18
|
||||
scope: 'file',
|
||||
tokenLength: 18,
|
||||
value: 'src/main.tsx'
|
||||
})
|
||||
expect(detectTrigger('@folder:apps/')).toEqual({ kind: '@', query: 'folder:apps/', tokenLength: 13 })
|
||||
expect(detectTrigger('@folder:apps/')).toEqual({
|
||||
kind: '@',
|
||||
query: 'folder:apps/',
|
||||
scope: 'folder',
|
||||
tokenLength: 13,
|
||||
value: 'apps/'
|
||||
})
|
||||
// A scope with nothing typed after it is the empty-browse state the
|
||||
// popover renders a header for.
|
||||
expect(detectTrigger('@url:')).toEqual({ kind: '@', query: 'url:', scope: 'url', tokenLength: 5, value: '' })
|
||||
})
|
||||
|
||||
it('only treats a KNOWN kind as a scope', () => {
|
||||
// `@teknium1:` is a handle with a colon, not a directive — inventing a
|
||||
// scope for it would make Backspace eat the whole word.
|
||||
expect(detectTrigger('@teknium1:')?.scope).toBeUndefined()
|
||||
expect(detectTrigger('@teknium1:')?.value).toBe('teknium1:')
|
||||
expect(detectTrigger('@localhost:8080')?.scope).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still ends the at-mention token at whitespace', () => {
|
||||
|
|
@ -92,15 +134,28 @@ describe('detectTrigger', () => {
|
|||
expect(detectTrigger('look at @apps/desktop')).toEqual({
|
||||
kind: '@',
|
||||
query: 'apps/desktop',
|
||||
tokenLength: 13
|
||||
tokenLength: 13,
|
||||
value: 'apps/desktop'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a mid-message slash as an inline reference', () => {
|
||||
// Skills have to be reachable anywhere in a prompt, not just at position 0.
|
||||
expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1 })
|
||||
expect(detectTrigger('hello /clean')).toEqual({ kind: '/', inline: true, query: 'clean', tokenLength: 6 })
|
||||
expect(detectTrigger('text\n/skill')).toEqual({ kind: '/', inline: true, query: 'skill', tokenLength: 6 })
|
||||
expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1, value: '' })
|
||||
expect(detectTrigger('hello /clean')).toEqual({
|
||||
kind: '/',
|
||||
inline: true,
|
||||
query: 'clean',
|
||||
tokenLength: 6,
|
||||
value: 'clean'
|
||||
})
|
||||
expect(detectTrigger('text\n/skill')).toEqual({
|
||||
kind: '/',
|
||||
inline: true,
|
||||
query: 'skill',
|
||||
tokenLength: 6,
|
||||
value: 'skill'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not carry arg completion into an inline slash reference', () => {
|
||||
|
|
|
|||
|
|
@ -9,9 +9,27 @@ export interface TriggerState {
|
|||
inline?: boolean
|
||||
kind: '@' | '/' | ':'
|
||||
query: string
|
||||
/** The `@kind:` prefix the user scoped the browse to, when there is one. */
|
||||
scope?: DirectiveScope
|
||||
tokenLength: number
|
||||
/** `query` minus the `scope:` prefix — the value actually being typed. */
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Directive kinds the `@` popover can scope a browse to. Mirrors the starter
|
||||
* rows in use-at-completions and the gateway's `complete.path` prefixes. */
|
||||
export const DIRECTIVE_SCOPES = ['file', 'folder', 'url', 'image', 'tool', 'git'] as const
|
||||
|
||||
export type DirectiveScope = (typeof DIRECTIVE_SCOPES)[number]
|
||||
|
||||
// Picking "attach a folder" types `@folder:` into the editor, and everything
|
||||
// after it is the value being browsed. Parsing that prefix off the query is
|
||||
// what lets the rest of the composer treat it as the BROWSE MODE it is rather
|
||||
// than characters the user has to maintain by hand — Tab-descending has to
|
||||
// carry it down, Backspace has to drop it whole, and a chip landing on it has
|
||||
// to consume it.
|
||||
const AT_SCOPE_RE = new RegExp(`^(${DIRECTIVE_SCOPES.join('|')}):(.*)$`)
|
||||
|
||||
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
|
||||
// single tokens, and a path is part of that token: `@./src/`, `@~/Desktop/`,
|
||||
// and `@file:src/foo` all have to keep the popover live while the user walks
|
||||
|
|
@ -146,6 +164,15 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
|
|||
return serializeTextBefore(editor, range.startContainer, range.startOffset)
|
||||
}
|
||||
|
||||
/** How many characters of directive scope the caret is sitting inside (`@url:`
|
||||
* with nothing typed after it), or 0. A paste lands INTO that scope: the scope
|
||||
* text is consumed rather than left in front of the chip as leftover syntax. */
|
||||
export function openDirectiveScope(editor: HTMLDivElement): number {
|
||||
const trigger = detectTrigger(textBeforeCaret(editor) ?? '')
|
||||
|
||||
return trigger?.kind === '@' && trigger.scope && !trigger.value ? trigger.tokenLength : 0
|
||||
}
|
||||
|
||||
export function detectTrigger(textBefore: string): TriggerState | null {
|
||||
// An inline `/skill` is a reference dropped into prose, so it carries no args
|
||||
// and the whole match is the token the chip replaces. Checked before the
|
||||
|
|
@ -156,19 +183,28 @@ export function detectTrigger(textBefore: string): TriggerState | null {
|
|||
if (inline) {
|
||||
const query = inline[2] ?? ''
|
||||
|
||||
return { inline: true, kind: '/', query, tokenLength: 1 + query.length }
|
||||
return { inline: true, kind: '/', query, tokenLength: 1 + query.length, value: query }
|
||||
}
|
||||
|
||||
const command = SLASH_COMMAND_TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (command) {
|
||||
return { kind: '/', query: command[2], tokenLength: 1 + command[2].length }
|
||||
return { kind: '/', query: command[2], tokenLength: 1 + command[2].length, value: command[2] }
|
||||
}
|
||||
|
||||
const at = AT_TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (at) {
|
||||
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
|
||||
const query = at[2]
|
||||
const scoped = AT_SCOPE_RE.exec(query)
|
||||
|
||||
return {
|
||||
kind: '@',
|
||||
query,
|
||||
...(scoped ? { scope: scoped[1] as DirectiveScope } : {}),
|
||||
tokenLength: 1 + query.length,
|
||||
value: scoped ? (scoped[2] ?? '') : query
|
||||
}
|
||||
}
|
||||
|
||||
// After `@` so a directive starter's colon (`@file:`) stays an `@` query.
|
||||
|
|
@ -177,7 +213,7 @@ export function detectTrigger(textBefore: string): TriggerState | null {
|
|||
const emoji = $reactionsEnabled.get() ? EMOJI_TRIGGER_RE.exec(textBefore) : null
|
||||
|
||||
if (emoji) {
|
||||
return { kind: ':', query: emoji[2], tokenLength: 1 + emoji[2].length }
|
||||
return { kind: ':', query: emoji[2], tokenLength: 1 + emoji[2].length, value: emoji[2] }
|
||||
}
|
||||
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: {
|
||||
composer: {
|
||||
lookupLoading: 'Loading…',
|
||||
lookupNoMatches: 'No matches',
|
||||
lookupTry: 'Try',
|
||||
lookupOr: 'or'
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
function atItem(type: string, display: string, rawText: string, meta = '') {
|
||||
return {
|
||||
id: `${rawText}|0`,
|
||||
type,
|
||||
label: display,
|
||||
metadata: { icon: type, display, meta, rawText, insertId: display }
|
||||
}
|
||||
}
|
||||
|
||||
function slashItem(command: string, group: string, meta = '') {
|
||||
return {
|
||||
id: `${command}|0`,
|
||||
type: 'slash',
|
||||
label: command.slice(1),
|
||||
metadata: { command, display: command, meta, group, action: '', rawText: command }
|
||||
}
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
/** The rendered shape of one row: does it have an icon, and how is it laid out?
|
||||
* Icons are codicons — an `<i class="codicon codicon-<name>">`, not an SVG. */
|
||||
function rowShape(root: HTMLElement) {
|
||||
const row = root.querySelector('button') as HTMLElement
|
||||
const icon = row.querySelector('i.codicon')
|
||||
|
||||
return {
|
||||
hasIcon: Boolean(icon),
|
||||
iconName: icon?.className.match(/codicon-([\w-]+)/)?.[1],
|
||||
classes: row.className
|
||||
}
|
||||
}
|
||||
|
||||
describe('@ and / are one menu', () => {
|
||||
it('a slash row has an icon, just like an @ row', () => {
|
||||
const at = render(
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={0}
|
||||
items={[atItem('folder', 'apps/desktop/', '@folder:apps/desktop/', 'dir')]}
|
||||
kind="@"
|
||||
loading={false}
|
||||
onHover={noop}
|
||||
onPick={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
const atShape = rowShape(at.container)
|
||||
at.unmount()
|
||||
|
||||
const slash = render(
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={0}
|
||||
items={[slashItem('/work', 'Skills', 'Start in a worktree')]}
|
||||
kind="/"
|
||||
loading={false}
|
||||
onHover={noop}
|
||||
onPick={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
const slashShape = rowShape(slash.container)
|
||||
|
||||
// The whole point: `/` used to render a stacked, icon-less row.
|
||||
expect(slashShape.hasIcon).toBe(true)
|
||||
expect(atShape.hasIcon).toBe(true)
|
||||
expect(slashShape.classes).toBe(atShape.classes)
|
||||
|
||||
// And the glyph reflects the kind, not one generic bullet.
|
||||
expect(slashShape.iconName).toBe('zap')
|
||||
expect(atShape.iconName).toBe('folder')
|
||||
})
|
||||
|
||||
it('renders the name and description for both kinds', () => {
|
||||
const { rerender } = render(
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={0}
|
||||
items={[slashItem('/work', 'Skills', 'Start in a worktree')]}
|
||||
kind="/"
|
||||
loading={false}
|
||||
onHover={noop}
|
||||
onPick={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('/work')).toBeTruthy()
|
||||
expect(screen.getByText('Start in a worktree')).toBeTruthy()
|
||||
|
||||
rerender(
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={0}
|
||||
items={[atItem('file', 'src/main.tsx', '@file:src/main.tsx', 'src')]}
|
||||
kind="@"
|
||||
loading={false}
|
||||
onHover={noop}
|
||||
onPick={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('src/main.tsx')).toBeTruthy()
|
||||
expect(screen.getByText('src')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('an emoji row stays icon-less — the emoji IS the icon', () => {
|
||||
const { container } = render(
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={0}
|
||||
items={[{ id: ':joy:|0', type: 'emoji', label: '😂 :joy:', metadata: { display: '😂 :joy:' } }]}
|
||||
kind=":"
|
||||
loading={false}
|
||||
onHover={noop}
|
||||
onPick={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(rowShape(container).hasIcon).toBe(false)
|
||||
})
|
||||
|
||||
it('labels the active browse scope from the shared vocabulary', () => {
|
||||
render(
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={0}
|
||||
items={[atItem('folder', 'apps/', '@folder:apps/', 'dir')]}
|
||||
kind="@"
|
||||
loading={false}
|
||||
onHover={noop}
|
||||
onPick={noop}
|
||||
scope="folder"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Folders')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,39 +1,14 @@
|
|||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import { referenceKind, referenceStyle } from '@/components/assistant-ui/reference-kinds'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { COMPLETION_DRAWER_BELOW_CLASS, COMPLETION_DRAWER_CLASS, CompletionDrawerEmpty } from './completion-drawer'
|
||||
|
||||
const AT_ICON_BY_TYPE: Record<string, string> = {
|
||||
diff: 'diff',
|
||||
file: 'book',
|
||||
folder: 'folder',
|
||||
git: 'git-branch',
|
||||
image: 'file-media',
|
||||
simple: 'symbol-misc',
|
||||
staged: 'diff-added',
|
||||
tool: 'tools',
|
||||
url: 'globe'
|
||||
}
|
||||
|
||||
function atIcon(item: Unstable_TriggerItem) {
|
||||
const meta = item.metadata as { rawText?: string } | undefined
|
||||
const raw = meta?.rawText || item.label
|
||||
|
||||
if (raw.startsWith('@diff')) {
|
||||
return AT_ICON_BY_TYPE.diff
|
||||
}
|
||||
|
||||
if (raw.startsWith('@staged')) {
|
||||
return AT_ICON_BY_TYPE.staged
|
||||
}
|
||||
|
||||
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
|
||||
}
|
||||
import type { DirectiveScope } from './text-utils'
|
||||
|
||||
interface RowMeta {
|
||||
display?: string
|
||||
|
|
@ -41,12 +16,41 @@ interface RowMeta {
|
|||
meta?: string
|
||||
}
|
||||
|
||||
const ROW_BASE_CLASS = [
|
||||
'relative flex w-full cursor-default select-none rounded-md px-2 py-1 text-left',
|
||||
/** The kind a row represents, for its icon. `@` rows carry it as the item type;
|
||||
* `/` rows carry it as the completion group (Skills / Themes / Commands). */
|
||||
function rowKind(item: Unstable_TriggerItem, isSlash: boolean): string {
|
||||
const meta = item.metadata as (RowMeta & { rawText?: string }) | undefined
|
||||
|
||||
if (isSlash) {
|
||||
const group = meta?.group?.trim()
|
||||
|
||||
return group === 'Skills' ? 'skill' : group === 'Themes' ? 'theme' : 'command'
|
||||
}
|
||||
|
||||
// The gateway's simple refs (`@diff`, `@staged`) share one item type, so the
|
||||
// glyph comes from the directive itself.
|
||||
const raw = meta?.rawText || item.label
|
||||
|
||||
if (raw.startsWith('@diff')) {
|
||||
return 'diff'
|
||||
}
|
||||
|
||||
if (raw.startsWith('@staged')) {
|
||||
return 'staged'
|
||||
}
|
||||
|
||||
return item.type
|
||||
}
|
||||
|
||||
const ROW_CLASS = [
|
||||
'relative flex w-full cursor-default select-none items-center gap-2 rounded-md px-2 py-1 text-left',
|
||||
'outline-hidden transition-colors hover:bg-(--ui-bg-tertiary)',
|
||||
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
|
||||
].join(' ')
|
||||
|
||||
const GROUP_HEADER_CLASS =
|
||||
'select-none px-2 pb-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
|
||||
|
||||
interface ComposerTriggerPopoverProps {
|
||||
activeIndex: number
|
||||
items: readonly Unstable_TriggerItem[]
|
||||
|
|
@ -55,8 +59,24 @@ interface ComposerTriggerPopoverProps {
|
|||
onHover: (index: number) => void
|
||||
onPick: (item: Unstable_TriggerItem) => void
|
||||
placement?: 'bottom' | 'top'
|
||||
/** The `@kind:` browse the list is filtered to, when there is one. Rendered
|
||||
* as a header so the scope reads as the mode it is — the raw `@folder:` in
|
||||
* the editor otherwise looks like syntax the user has to finish by hand. */
|
||||
scope?: DirectiveScope
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer's completion list, for every trigger.
|
||||
*
|
||||
* `@` and `/` render through the SAME row: icon, name, description. They used
|
||||
* to be two layouts in one file — `@` horizontal with an icon, `/` stacked with
|
||||
* none — which is why picking a file and picking a skill felt like features
|
||||
* from different apps. Icons and accents come from the shared reference
|
||||
* vocabulary, so a row looks like the chip it will become.
|
||||
*
|
||||
* `:` emoji is the one exception: the emoji IS the icon, so it renders as a
|
||||
* single display string (Slack's exact shape).
|
||||
*/
|
||||
export function ComposerTriggerPopover({
|
||||
activeIndex,
|
||||
items,
|
||||
|
|
@ -64,11 +84,13 @@ export function ComposerTriggerPopover({
|
|||
loading,
|
||||
onHover,
|
||||
onPick,
|
||||
placement = 'top'
|
||||
placement = 'top',
|
||||
scope
|
||||
}: ComposerTriggerPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.composer
|
||||
const isSlash = kind === '/'
|
||||
const isEmoji = kind === ':'
|
||||
|
||||
let lastGroup: string | undefined
|
||||
|
||||
|
|
@ -80,6 +102,7 @@ export function ComposerTriggerPopover({
|
|||
onMouseDown={event => event.preventDefault()}
|
||||
role="listbox"
|
||||
>
|
||||
{scope && <div className={cn(GROUP_HEADER_CLASS, 'pt-0.5')}>{referenceStyle(scope).label}</div>}
|
||||
{items.length === 0 ? (
|
||||
loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
|
||||
|
|
@ -93,7 +116,7 @@ export function ComposerTriggerPopover({
|
|||
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : kind === ':' ? (
|
||||
) : isEmoji ? (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">:joy:</span>.
|
||||
</>
|
||||
|
|
@ -114,61 +137,28 @@ export function ComposerTriggerPopover({
|
|||
const isFirstHeader = lastGroup === undefined
|
||||
lastGroup = group || lastGroup
|
||||
const active = index === activeIndex
|
||||
const refKind = referenceKind(rowKind(item, isSlash))
|
||||
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
{showHeader && (
|
||||
<div
|
||||
className={cn(
|
||||
'select-none px-2 pb-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)',
|
||||
isFirstHeader ? 'pt-0.5' : 'pt-2'
|
||||
)}
|
||||
>
|
||||
{group}
|
||||
</div>
|
||||
)}
|
||||
{showHeader && <div className={cn(GROUP_HEADER_CLASS, isFirstHeader ? 'pt-0.5' : 'pt-2')}>{group}</div>}
|
||||
<button
|
||||
className={cn(ROW_BASE_CLASS, isSlash ? 'flex-col gap-0' : 'items-center gap-2')}
|
||||
className={ROW_CLASS}
|
||||
data-highlighted={active ? '' : undefined}
|
||||
onClick={() => onPick(item)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
{isSlash ? (
|
||||
<>
|
||||
{/* Active row (keyboard nav or hover) un-truncates inline so
|
||||
long command names / descriptions stay readable without a
|
||||
floating tooltip. */}
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium leading-snug text-foreground',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{description && (
|
||||
<span
|
||||
className={cn(
|
||||
'leading-snug text-(--ui-text-tertiary)',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : kind === ':' ? (
|
||||
// Just the emoji + :shortcode:, Slack-style — no icon column.
|
||||
{isEmoji ? (
|
||||
// The emoji is its own icon — a glyph column beside it reads
|
||||
// as decoration.
|
||||
<span className="min-w-0 shrink truncate leading-5 text-foreground">{display}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||
<Codicon name={atIcon(item)} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
|
||||
{display}
|
||||
<span className="grid size-4 shrink-0 place-items-center text-(--ref-color)" data-ref={refKind}>
|
||||
<Codicon name={referenceStyle(refKind).codicon} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && (
|
||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@ describe('hermesDirectiveFormatter.parse', () => {
|
|||
it('still parses unquoted paths', () => {
|
||||
const segments = hermesDirectiveFormatter.parse('@file:src/main.tsx the entry point')
|
||||
|
||||
// The label keeps its directory: it's the same string the `@` popover row
|
||||
// showed, and a bare `main.tsx` can't tell two files apart.
|
||||
expect(segments).toEqual([
|
||||
{ kind: 'mention', type: 'file', label: 'main.tsx', id: 'src/main.tsx' },
|
||||
{ kind: 'mention', type: 'file', label: 'src/main.tsx', id: 'src/main.tsx' },
|
||||
{ kind: 'text', text: ' the entry point' }
|
||||
])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,46 +13,38 @@ import { useSessionLinkTitle } from '@/lib/session-link-title'
|
|||
import { parseSessionRefValue, sessionRefFallbackLabel } from '@/lib/session-refs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { referenceKind, referenceStyle } from './reference-kinds'
|
||||
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
|
||||
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
|
||||
|
||||
/** Single source of truth for chip icon glyphs (Tabler outline @ 24×24).
|
||||
* Used both by the rendered <DirectiveIcon> and the raw SVG markup the
|
||||
* contenteditable composer embeds via `directiveIconSvg`. */
|
||||
const ICON_PATHS: Record<HermesRefType, string[]> = {
|
||||
file: [
|
||||
'M14 3v4a1 1 0 0 0 1 1h4',
|
||||
'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2',
|
||||
'M9 9l1 0',
|
||||
'M9 13l6 0',
|
||||
'M9 17l6 0'
|
||||
],
|
||||
folder: [
|
||||
'M5 19l2.757 -7.351a1 1 0 0 1 .936 -.649h12.307a1 1 0 0 1 .986 1.164l-.996 5.211a2 2 0 0 1 -1.964 1.625h-14.026a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2'
|
||||
],
|
||||
url: [
|
||||
'M9 15l6 -6',
|
||||
'M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464',
|
||||
'M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463'
|
||||
],
|
||||
image: [
|
||||
'M15 8h.01',
|
||||
'M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12',
|
||||
'M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5',
|
||||
'M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3'
|
||||
],
|
||||
tool: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'],
|
||||
line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'],
|
||||
terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0'],
|
||||
session: ['M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227']
|
||||
}
|
||||
|
||||
const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28']
|
||||
/** Icon glyphs come from the shared reference vocabulary, so the popover row
|
||||
* and the chip can never drift apart. */
|
||||
const iconPathsFor = (type: string) => referenceStyle(type).paths
|
||||
|
||||
const SVG_ATTRS =
|
||||
'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
|
||||
|
||||
const iconPathsFor = (type: string) => ICON_PATHS[type as HermesRefType] ?? ICON_FALLBACK
|
||||
/**
|
||||
* The class + attributes that make any element an inline reference. Pair with
|
||||
* the `.ref` rules in styles.css, which own the per-kind accent — pass the kind
|
||||
* and the theme decides the colour.
|
||||
*
|
||||
* One helper for every surface: the composer's contenteditable chips, a sent
|
||||
* message's mentions, a markdown link, a completion row's glyph. If it points
|
||||
* at something from inside text, it goes through here.
|
||||
*/
|
||||
export function refAttrs(kind?: string, extra?: string): { className: string; 'data-ref'?: string } {
|
||||
const className = extra ? `ref ${extra}` : 'ref'
|
||||
|
||||
return kind ? { className, 'data-ref': referenceKind(kind) } : { className }
|
||||
}
|
||||
|
||||
/** The same thing as a raw attribute string, for HTML built by hand. */
|
||||
export function refAttrsHtml(kind?: string): string {
|
||||
return kind ? `class="ref" data-ref="${referenceKind(kind)}"` : 'class="ref"'
|
||||
}
|
||||
|
||||
|
||||
/** SVG markup string for embedding directly in HTML (composer contenteditable). */
|
||||
export function directiveIconSvg(type: string) {
|
||||
|
|
@ -60,12 +52,11 @@ export function directiveIconSvg(type: string) {
|
|||
.map(d => `<path d="${d}"/>`)
|
||||
.join('')
|
||||
|
||||
return `<svg ${SVG_ATTRS} class="size-3 shrink-0 opacity-80">${inner}</svg>`
|
||||
return `<svg ${SVG_ATTRS}>${inner}</svg>`
|
||||
}
|
||||
|
||||
function iconElementFromPaths(paths: string[]) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
svg.setAttribute('class', 'size-3 shrink-0 opacity-80')
|
||||
svg.setAttribute('fill', 'none')
|
||||
svg.setAttribute('stroke', 'currentColor')
|
||||
svg.setAttribute('stroke-linecap', 'round')
|
||||
|
|
@ -87,46 +78,17 @@ export function directiveIconElement(type: string) {
|
|||
return iconElementFromPaths(iconPathsFor(type))
|
||||
}
|
||||
|
||||
/** Per-type slash-command pill styling. The composer inserts these chips when a
|
||||
* command is picked; the kind drives a theme-aware accent so commands, skills,
|
||||
* and themes read distinctly (Cursor-style). */
|
||||
/** Commands, skills, and themes are three more reference kinds — no separate
|
||||
* pill styling, just the shared `.ref` treatment with their own accent. */
|
||||
export type SlashChipKind = 'command' | 'skill' | 'theme'
|
||||
|
||||
const SLASH_ICON_PATHS: Record<SlashChipKind, string[]> = {
|
||||
command: ['M5 7l5 5l-5 5', 'M12 19l7 0'],
|
||||
skill: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'],
|
||||
theme: [
|
||||
'M3 21v-4a4 4 0 1 1 4 4h-4',
|
||||
'M21 3a16 16 0 0 0 -12.8 10.2',
|
||||
'M21 3a16 16 0 0 1 -10.2 12.8',
|
||||
'M10.6 9a9 9 0 0 1 4.4 4.4'
|
||||
]
|
||||
}
|
||||
|
||||
const SLASH_CHIP_VARIANT: Record<SlashChipKind, string> = {
|
||||
command:
|
||||
'bg-[color-mix(in_srgb,var(--ui-accent)_14%,transparent)] text-[color-mix(in_srgb,var(--ui-accent)_82%,var(--foreground))]',
|
||||
skill:
|
||||
'bg-[color-mix(in_srgb,var(--ui-warm)_18%,transparent)] text-[color-mix(in_srgb,var(--ui-warm)_82%,var(--foreground))]',
|
||||
theme:
|
||||
'bg-[color-mix(in_srgb,var(--ui-accent-secondary)_16%,transparent)] text-[color-mix(in_srgb,var(--ui-accent-secondary)_82%,var(--foreground))]'
|
||||
}
|
||||
|
||||
export const SLASH_CHIP_BASE_CLASS =
|
||||
'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-[-0.12em] text-[0.86em] font-medium leading-none'
|
||||
|
||||
export function slashChipClass(kind: SlashChipKind): string {
|
||||
return `${SLASH_CHIP_BASE_CLASS} ${SLASH_CHIP_VARIANT[kind]}`
|
||||
}
|
||||
|
||||
export function slashIconElement(kind: SlashChipKind) {
|
||||
return iconElementFromPaths(SLASH_ICON_PATHS[kind])
|
||||
return iconElementFromPaths(iconPathsFor(kind))
|
||||
}
|
||||
|
||||
const DirectiveIcon: FC<{ type: string; className?: string }> = ({
|
||||
type,
|
||||
className = 'size-3 shrink-0 opacity-80'
|
||||
}) => (
|
||||
/** The glyph for a reference kind. Size, spacing, and opacity come from the
|
||||
* `.ref > svg` rules — the icon only has to say which shape it is. */
|
||||
const DirectiveIcon: FC<{ type: string; className?: string }> = ({ type, className }) => (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
|
|
@ -143,18 +105,6 @@ const DirectiveIcon: FC<{ type: string; className?: string }> = ({
|
|||
</svg>
|
||||
)
|
||||
|
||||
/** Shared chip styling — used by both the rendered <DirectiveChip> and the
|
||||
* raw HTML composer chips in `rich-editor.ts`. Neutral subtle wash + plain
|
||||
* muted-foreground text so chips read as quiet tags on any bubble color.
|
||||
*
|
||||
* `align-[-0.12em]` rather than `align-middle`: `middle` centers the pill on
|
||||
* the x-height midpoint, which sits above the center of the surrounding text
|
||||
* box, so the chip visibly rides low next to the words it's nestled in. The
|
||||
* em nudge lands the chip's own text baseline on the line's baseline (measured
|
||||
* to within 0.08px) without growing the line box. */
|
||||
export const DIRECTIVE_CHIP_CLASS =
|
||||
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-[-0.12em] text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground'
|
||||
|
||||
/**
|
||||
* Parses our composer's `@type:value` references into directive segments
|
||||
* so they render as inline chips in user messages instead of raw text.
|
||||
|
|
@ -332,10 +282,18 @@ function parseDirectiveText(text: string): Unstable_DirectiveSegment[] {
|
|||
return segments
|
||||
}
|
||||
|
||||
/** Display text for a `@kind:value` chip. Shared with the composer's
|
||||
* contenteditable chips so a link reads the same before and after send: the
|
||||
* host leads (scheme and `www.` are noise) and the path rides along for the
|
||||
* chip's `truncate` to cut — a bare hostname can't tell two links apart. */
|
||||
/** The single display label for a `@kind:value` reference — used by the `@`
|
||||
* popover row, the composer chip, and the sent-message chip alike, so a
|
||||
* reference reads the same everywhere. Upstream keeps one label on the
|
||||
* directive node and hands it to every consumer verbatim; our wire format
|
||||
* (`@kind:value`) can't carry a label, so this is the shared deriver that
|
||||
* holds the same invariant.
|
||||
*
|
||||
* Paths keep their directory for the reason links keep theirs: a bare
|
||||
* basename can't tell two references apart (`src`, `index.ts`, `main.tsx`
|
||||
* repeat all over a repo), and browsing into `apps/desktop/` only to be
|
||||
* handed a chip reading `desktop` throws away the context you navigated for.
|
||||
* The chip's `truncate` cuts the overflow. */
|
||||
export function refChipLabel(type: string, id: string): string {
|
||||
if (type === 'terminal') {
|
||||
return id || 'terminal'
|
||||
|
|
@ -356,9 +314,9 @@ export function refChipLabel(type: string, id: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
const tail = id.split(/[\\/]/).filter(Boolean).pop()
|
||||
|
||||
return tail || id
|
||||
// `./` is noise the completer emits, not part of the reference. A trailing
|
||||
// slash is kept — it's what distinguishes a folder from a file.
|
||||
return id.replace(/^\.\//, '') || id
|
||||
}
|
||||
|
||||
function safeEmbeddedImages(text: string) {
|
||||
|
|
@ -528,7 +486,7 @@ export const SessionRefLink: FC<{
|
|||
|
||||
return (
|
||||
<a
|
||||
className="link-chip wrap-anywhere"
|
||||
{...refAttrs('session', 'wrap-anywhere')}
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
@ -537,7 +495,7 @@ export const SessionRefLink: FC<{
|
|||
}}
|
||||
title={value}
|
||||
>
|
||||
<DirectiveIcon className="mr-1 inline size-[0.82em] align-[-0.08em] opacity-70" type="session" />
|
||||
<DirectiveIcon type="session" />
|
||||
{resolved}
|
||||
</a>
|
||||
)
|
||||
|
|
@ -546,22 +504,9 @@ export const SessionRefLink: FC<{
|
|||
/** A skill referenced inside a sent message — the rendered twin of the
|
||||
* composer's slash pill, so a picked skill stays a chip after send. */
|
||||
const SlashChip: FC<{ kind: SlashChipKind; label: string; value: string }> = ({ kind, label, value }) => (
|
||||
<span className={slashChipClass(kind)} data-slot="aui_slash-chip" title={value}>
|
||||
<svg
|
||||
className="size-3 shrink-0 opacity-80"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{SLASH_ICON_PATHS[kind].map(d => (
|
||||
<path d={d} key={d} />
|
||||
))}
|
||||
</svg>
|
||||
<span className="truncate">{label}</span>
|
||||
<span {...refAttrs(kind)} data-slot="aui_slash-chip" title={value}>
|
||||
<DirectiveIcon type={kind} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
|
||||
|
|
@ -576,14 +521,13 @@ const DirectiveChip: FC<{
|
|||
const body = (
|
||||
<>
|
||||
<DirectiveIcon type={type} />
|
||||
<span className="truncate">{label}</span>
|
||||
{label}
|
||||
</>
|
||||
)
|
||||
|
||||
const props = {
|
||||
className: cn(DIRECTIVE_CHIP_CLASS, onClick && 'cursor-pointer transition-colors hover:text-foreground'),
|
||||
...refAttrs(type, cn('wrap-anywhere', onClick && 'cursor-pointer')),
|
||||
'data-directive-id': id,
|
||||
'data-directive-type': type,
|
||||
'data-slot': 'aui_directive-chip',
|
||||
title: id
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string
|
|||
return (
|
||||
<span className="block">
|
||||
<button
|
||||
className="mt-2 link-chip bg-transparent text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
className="mt-2 ref text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={open}
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -223,7 +223,7 @@ function MediaAttachment({ path }: { path: string }) {
|
|||
return (
|
||||
<span className="wrap-anywhere">
|
||||
<a
|
||||
className="link-chip wrap-anywhere"
|
||||
className="ref wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
@ -273,7 +273,7 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
|
|||
if (!target || !/^https?:\/\//i.test(target)) {
|
||||
return (
|
||||
<a
|
||||
className={cn('link-chip wrap-anywhere', className)}
|
||||
className={cn('ref wrap-anywhere', className)}
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
|
|
@ -370,7 +370,7 @@ function MarkdownImageContent({ className, src, alt, ...props }: ComponentProps<
|
|||
<span className="my-2 block text-sm text-muted-foreground">
|
||||
Couldn't load {name}.{' '}
|
||||
<button
|
||||
className="link-chip bg-transparent font-medium text-foreground hover:text-foreground"
|
||||
className="ref font-medium text-foreground"
|
||||
onClick={open}
|
||||
type="button"
|
||||
>
|
||||
|
|
|
|||
131
apps/desktop/src/components/assistant-ui/reference-kinds.ts
Normal file
131
apps/desktop/src/components/assistant-ui/reference-kinds.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* The composer's reference vocabulary: one place that decides what a `@file:`,
|
||||
* a `@folder:`, a picked `/skill`, or any other reference LOOKS like — its
|
||||
* icon, its accent, and the word for its kind.
|
||||
*
|
||||
* Both surfaces that show a reference read from this table:
|
||||
*
|
||||
* - the trigger popover row (browsing for one)
|
||||
* - the chip (having picked one)
|
||||
*
|
||||
* so a thing is the same color with the same glyph wherever you meet it. They
|
||||
* used to be two hand-maintained icon maps and two unrelated row layouts, which
|
||||
* is why `@` and `/` looked like features from different apps.
|
||||
*/
|
||||
|
||||
/** Every kind of thing the composer can reference. */
|
||||
export type ReferenceKind =
|
||||
| 'file'
|
||||
| 'folder'
|
||||
| 'url'
|
||||
| 'image'
|
||||
| 'tool'
|
||||
| 'line'
|
||||
| 'terminal'
|
||||
| 'session'
|
||||
| 'git'
|
||||
| 'diff'
|
||||
| 'staged'
|
||||
| 'command'
|
||||
| 'skill'
|
||||
| 'theme'
|
||||
| 'emoji'
|
||||
| 'other'
|
||||
|
||||
interface ReferenceStyle {
|
||||
/** Codicon name — the popover row's leading glyph. */
|
||||
codicon: string
|
||||
/** Tabler outline path data — the inline SVG a rendered reference uses. */
|
||||
paths: string[]
|
||||
/** Section label when a surface groups by this kind. */
|
||||
label: string
|
||||
}
|
||||
|
||||
// Colour is NOT here. A reference's accent lives in styles.css keyed on
|
||||
// `data-ref="<kind>"`, so a theme restyles every reference at once and no hex
|
||||
// or color-mix() ships from TypeScript. This table owns the two things CSS
|
||||
// can't express: which glyph, and what to call the kind.
|
||||
|
||||
const FILE_PATHS = [
|
||||
'M14 3v4a1 1 0 0 0 1 1h4',
|
||||
'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2',
|
||||
'M9 9l1 0',
|
||||
'M9 13l6 0',
|
||||
'M9 17l6 0'
|
||||
]
|
||||
|
||||
const TERMINAL_PATHS = ['M5 7l5 5l-5 5', 'M12 19l7 0']
|
||||
|
||||
export const REFERENCE_STYLES: Record<ReferenceKind, ReferenceStyle> = {
|
||||
file: { codicon: 'file', paths: FILE_PATHS, label: 'Files' },
|
||||
folder: {
|
||||
codicon: 'folder',
|
||||
paths: [
|
||||
'M5 19l2.757 -7.351a1 1 0 0 1 .936 -.649h12.307a1 1 0 0 1 .986 1.164l-.996 5.211a2 2 0 0 1 -1.964 1.625h-14.026a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2'
|
||||
],
|
||||
label: 'Folders'
|
||||
},
|
||||
url: {
|
||||
codicon: 'globe',
|
||||
paths: [
|
||||
'M9 15l6 -6',
|
||||
'M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464',
|
||||
'M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463'
|
||||
],
|
||||
label: 'Links'
|
||||
},
|
||||
image: {
|
||||
codicon: 'file-media',
|
||||
paths: [
|
||||
'M15 8h.01',
|
||||
'M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12',
|
||||
'M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5',
|
||||
'M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3'
|
||||
],
|
||||
label: 'Images'
|
||||
},
|
||||
tool: {
|
||||
codicon: 'tools',
|
||||
paths: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'],
|
||||
label: 'Tools'
|
||||
},
|
||||
line: {
|
||||
codicon: 'list-selection',
|
||||
paths: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'],
|
||||
label: 'Lines'
|
||||
},
|
||||
terminal: { codicon: 'terminal', paths: TERMINAL_PATHS, label: 'Terminal' },
|
||||
session: {
|
||||
codicon: 'comment-discussion',
|
||||
paths: ['M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227'],
|
||||
label: 'Sessions'
|
||||
},
|
||||
git: { codicon: 'git-branch', paths: ['M7 18l0 -12', 'M7 8a2 2 0 1 0 0 -4a2 2 0 0 0 0 4'], label: 'Git' },
|
||||
diff: { codicon: 'diff', paths: ['M12 5l0 14', 'M5 12l14 0'], label: 'Changes' },
|
||||
staged: { codicon: 'diff-added', paths: ['M12 5l0 14', 'M5 12l14 0'], label: 'Staged' },
|
||||
command: { codicon: 'terminal', paths: TERMINAL_PATHS, label: 'Commands' },
|
||||
skill: { codicon: 'zap', paths: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'], label: 'Skills' },
|
||||
theme: {
|
||||
codicon: 'symbol-color',
|
||||
paths: [
|
||||
'M3 21v-4a4 4 0 1 1 4 4h-4',
|
||||
'M21 3a16 16 0 0 0 -12.8 10.2',
|
||||
'M21 3a16 16 0 0 1 -10.2 12.8',
|
||||
'M10.6 9a9 9 0 0 1 4.4 4.4'
|
||||
],
|
||||
label: 'Themes'
|
||||
},
|
||||
emoji: { codicon: 'smiley', paths: [], label: 'Emoji' },
|
||||
other: { codicon: 'symbol-misc', paths: FILE_PATHS, label: 'Other' }
|
||||
}
|
||||
|
||||
const KNOWN = new Set(Object.keys(REFERENCE_STYLES))
|
||||
|
||||
/** Coerce any incoming type string to a kind we have a style for. */
|
||||
export function referenceKind(type: string | undefined): ReferenceKind {
|
||||
return type && KNOWN.has(type) ? (type as ReferenceKind) : 'other'
|
||||
}
|
||||
|
||||
export function referenceStyle(type: string | undefined): ReferenceStyle {
|
||||
return REFERENCE_STYLES[referenceKind(type)]
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
releaseActiveComposer
|
||||
} from '@/app/chat/composer/focus'
|
||||
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
||||
import { rebuildAroundCaret } from '@/app/chat/composer/hooks/use-composer-trigger'
|
||||
import { useComposerUndo } from '@/app/chat/composer/hooks/use-composer-undo'
|
||||
import { useEmojiCompletions } from '@/app/chat/composer/hooks/use-emoji-completions'
|
||||
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
|
||||
|
|
@ -42,7 +43,7 @@ import {
|
|||
replaceBeforeCaret,
|
||||
RICH_INPUT_SLOT
|
||||
} from '@/app/chat/composer/rich-editor'
|
||||
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||
import { detectTrigger, openDirectiveScope, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import { isRedoShortcut, isUndoShortcut } from '@/app/chat/composer/undo-history'
|
||||
import { chipTypedUrlOnSpace, linkifyUrls } from '@/app/chat/composer/url-refs'
|
||||
|
|
@ -352,9 +353,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
: fragment.append(document.createTextNode(text))
|
||||
|
||||
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
|
||||
const current = composerPlainText(editor)
|
||||
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
|
||||
placeCaretEnd(editor)
|
||||
rebuildAroundCaret(editor, trigger.tokenLength, text)
|
||||
}
|
||||
|
||||
finish()
|
||||
|
|
@ -555,8 +554,14 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
rememberInitialDraft()
|
||||
recordUndoPoint()
|
||||
|
||||
// Links land as `@url:` chips, same as the main composer.
|
||||
insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText)))
|
||||
// Links land as `@url:` chips, same as the main composer — including
|
||||
// consuming an open `@url:` scope rather than stacking a second directive
|
||||
// in front of the chip.
|
||||
insertComposerContentsAtCaret(
|
||||
event.currentTarget,
|
||||
pathifyRefs(linkifyUrls(pastedText)),
|
||||
openDirectiveScope(event.currentTarget)
|
||||
)
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function tagged<T extends keyof typeof TAG_CLASSES>(Tag: T) {
|
|||
function MarkdownAnchor({ children, className, href, ...rest }: ComponentProps<'a'>) {
|
||||
if (!href || !/^https?:\/\//i.test(href)) {
|
||||
return (
|
||||
<a className={cn('link-chip', className)} href={href} {...rest}>
|
||||
<a className={cn('ref', className)} href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({
|
|||
if (failed && image) {
|
||||
return (
|
||||
<a
|
||||
className="mt-2 link-chip inline-block wrap-anywhere"
|
||||
className="mt-2 ref inline-block wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ export function ExternalLink({
|
|||
|
||||
return (
|
||||
<a
|
||||
className={cn('link-chip', className)}
|
||||
className={cn('ref', className)}
|
||||
href={target}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
|
|
|
|||
|
|
@ -47,6 +47,32 @@ export function peekCachedSlashCompletion<T>(key: string): T | undefined {
|
|||
return hasCachedSlashCompletion(key) ? queryClient.getQueryData<T>([SLASH_COMPLETIONS_KEY, key]) : undefined
|
||||
}
|
||||
|
||||
// `@` path completions are a directory listing, which unlike the command
|
||||
// catalog CAN change under the user (a build writes files, a branch switch
|
||||
// rewrites a tree). They get the same de-duplication but a short TTL: long
|
||||
// enough that walking back up a path you just walked down is instant, short
|
||||
// enough that the listing never looks stale.
|
||||
const PATH_COMPLETIONS_KEY = 'path-completions'
|
||||
const PATH_COMPLETIONS_TTL_MS = 15_000
|
||||
|
||||
/** Serve an `@` path completion from cache, fetching only when stale. */
|
||||
export function cachedPathCompletion<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
return queryClient.fetchQuery({
|
||||
queryKey: [PATH_COMPLETIONS_KEY, key],
|
||||
queryFn: fetcher,
|
||||
gcTime: PATH_COMPLETIONS_TTL_MS,
|
||||
staleTime: PATH_COMPLETIONS_TTL_MS,
|
||||
retry: false
|
||||
})
|
||||
}
|
||||
|
||||
/** True when `cachedPathCompletion(key)` will resolve without a round trip. */
|
||||
export function hasCachedPathCompletion(key: string): boolean {
|
||||
const state = queryClient.getQueryState([PATH_COMPLETIONS_KEY, key])
|
||||
|
||||
return state?.data !== undefined && Date.now() - state.dataUpdatedAt < PATH_COMPLETIONS_TTL_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumped on every invalidation. The composer's completion adapter de-dupes by
|
||||
* query, so an unchanged `/` would never re-ask on its own — it watches this
|
||||
|
|
|
|||
|
|
@ -583,36 +583,107 @@
|
|||
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 0.125rem 0.125rem;
|
||||
}
|
||||
|
||||
/* Inline content links: color at rest, a tinted chip on hover, never an
|
||||
underline. Tint is currentColor-relative (the old `decoration-current/20`
|
||||
idiom), so text and fill share a hue on every theme from one `color`. */
|
||||
.link-chip {
|
||||
/* The one knob: resting fill. 0% = color-only until hovered. */
|
||||
--link-chip-tint: 0%;
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
INLINE REFERENCES — the one system for anything that points at something
|
||||
from inside a run of text.
|
||||
|
||||
A pasted link, an `@file:` the user picked, a `/skill`, a `@session:` the
|
||||
agent wrote: all the same species. They render the same way in the composer
|
||||
and in a sent message, because they're the same thing before and after send.
|
||||
|
||||
A reference is TEXT, not a badge — colour and an optional icon, no fill, no
|
||||
padding, no border. A pill turns every mention into a widget the eye has to
|
||||
step over, and mid-sentence that's most of the sentence.
|
||||
|
||||
Usage: `class="ref"` plus `data-ref="<kind>"` for the accent. Kinds live in
|
||||
`components/assistant-ui/reference-kinds.ts` (icon + label); their colour
|
||||
lives here, so a theme restyles every reference at once and TS never ships a
|
||||
hex. No `data-ref` = an ordinary link, which keeps the primary link colour.
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
/* Kind → accent. Keyed on the attribute ALONE so anything can adopt a
|
||||
reference's colour without also taking its inline-text layout (a completion
|
||||
row's icon column wants the hue, not the margin). Grouped by what a
|
||||
reference DOES, so the palette reads as meaning rather than decoration:
|
||||
· things you point at (paths) → neutral, they're the common case
|
||||
· things you fetch (links, media) → secondary
|
||||
· things that act (commands, tools) → accent
|
||||
· things that change code (skills, git)→ warm */
|
||||
[data-ref] {
|
||||
--ref-color: var(--dt-primary);
|
||||
}
|
||||
|
||||
[data-ref='file'],
|
||||
[data-ref='folder'],
|
||||
[data-ref='line'],
|
||||
[data-ref='terminal'] {
|
||||
--ref-color: var(--ui-text-secondary);
|
||||
}
|
||||
|
||||
[data-ref='url'],
|
||||
[data-ref='image'],
|
||||
[data-ref='session'],
|
||||
[data-ref='theme'] {
|
||||
--ref-color: color-mix(in srgb, var(--ui-accent-secondary) 82%, var(--foreground));
|
||||
}
|
||||
|
||||
[data-ref='command'],
|
||||
[data-ref='tool'] {
|
||||
--ref-color: color-mix(in srgb, var(--ui-accent) 82%, var(--foreground));
|
||||
}
|
||||
|
||||
[data-ref='skill'],
|
||||
[data-ref='git'],
|
||||
[data-ref='diff'],
|
||||
[data-ref='staged'] {
|
||||
--ref-color: color-mix(in srgb, var(--ui-warm) 82%, var(--foreground));
|
||||
}
|
||||
|
||||
.ref {
|
||||
--ref-color: var(--dt-primary);
|
||||
|
||||
/* Prose links inherit the surrounding text's weight. `@tailwindcss/typography`
|
||||
sets `prose a { font-weight: 500 }`, which outranks a utility class on the
|
||||
anchor — so the override belongs here, on the shared chip. */
|
||||
anchor — so the override belongs here, on the shared class. */
|
||||
font-weight: inherit;
|
||||
|
||||
/* `ch`/`em` so the chip tracks the text at any size. Block padding is
|
||||
asymmetric because an inline box's content area is ascent+descent — equal
|
||||
padding paints the glyphs high. Keep it small: `padding-block` doesn't grow
|
||||
the line box, so an over-padded chip creeps into the line above. */
|
||||
padding: 0.05ch 0.5ch 0.2ch;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--dt-primary);
|
||||
background: color-mix(in srgb, currentColor var(--link-chip-tint), transparent);
|
||||
color: var(--ref-color);
|
||||
/* Explicit: the base layer underlines every `a` (see the :where(a, …) reset). */
|
||||
text-decoration: none;
|
||||
/* A wrapped link gets a chip per line fragment, not one ragged box. */
|
||||
/* A wrapped reference breaks per line fragment, not as one ragged box. */
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.link-chip:hover {
|
||||
--link-chip-tint: 15%;
|
||||
/* Affordance without chrome: only the ones you can actually activate respond,
|
||||
and they do it with an underline rather than a background. */
|
||||
:where(a, button).ref:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
|
||||
/* The leading glyph. Sized in `em` so it tracks the text at any scale, and
|
||||
spaced with `margin` — not flex `gap` — so `.ref` stays an inline box whose
|
||||
label can wrap mid-word (a long URL has to break across lines like the prose
|
||||
around it, which a flex container would prevent).
|
||||
|
||||
The margin is unconditional. A `:not(:only-child)` guard looks right and is
|
||||
silently wrong: CSS counts ELEMENT siblings, so an icon followed by a bare
|
||||
text-node label still matches `:only-child` — which is exactly how every
|
||||
chip lost its spacer. Icon-only users take the hue via `[data-ref]` instead
|
||||
of this class. */
|
||||
.ref > :where(svg, i.codicon) {
|
||||
margin-inline-end: 0.25em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ref > svg {
|
||||
display: inline-block;
|
||||
width: 0.875em;
|
||||
height: 0.875em;
|
||||
vertical-align: -0.1em;
|
||||
}
|
||||
|
||||
.ref > i.codicon {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Hover-reveal suppression — the shared, declarative escape hatch.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue