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:
brooklyn! 2026-07-30 05:01:45 -05:00 committed by GitHub
commit 9650f555d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1283 additions and 303 deletions

View 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)
})
})

View 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()
})
})

View file

@ -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()
})
})

View file

@ -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). */

View file

@ -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)

View file

@ -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 && (

View 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)
}
})
})

View file

@ -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()

View file

@ -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', () => {

View file

@ -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

View file

@ -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()
})
})

View file

@ -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>
)}

View file

@ -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' }
])
})

View file

@ -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
}

View file

@ -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&apos;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"
>

View 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)]
}

View file

@ -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)
}

View file

@ -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>
)

View file

@ -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()

View file

@ -239,7 +239,7 @@ export function ExternalLink({
return (
<a
className={cn('link-chip', className)}
className={cn('ref', className)}
href={target}
onClick={event => {
event.stopPropagation()

View file

@ -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

View file

@ -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.