diff --git a/apps/desktop/src/app/chat/composer/chip-typography.test.ts b/apps/desktop/src/app/chat/composer/chip-typography.test.ts
deleted file mode 100644
index bdd6159f8e5..00000000000
--- a/apps/desktop/src/app/chat/composer/chip-typography.test.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { describe, expect, it } from 'vitest'
-
-import {
- DIRECTIVE_CHIP_CLASS,
- directiveChipClass,
- SLASH_CHIP_BASE_CLASS
-} from '@/components/assistant-ui/directive-text'
-import { REFERENCE_STYLES, referenceStyle } from '@/components/assistant-ui/reference-kinds'
-
-/**
- * A chip is inline text, not a badge: same size as the words around it, no
- * background or padding to step over, and a per-kind color + icon carrying the
- * "what kind of thing is this" signal.
- */
-describe('chip typography', () => {
- for (const [name, cls] of [
- ['directive chip', DIRECTIVE_CHIP_CLASS],
- ['slash chip', SLASH_CHIP_BASE_CLASS]
- ] as const) {
- it(`${name} inherits the surrounding font size`, () => {
- expect(cls).not.toMatch(/\btext-\[0\.\d+em\]/)
- })
-
- it(`${name} renders as text, with no badge chrome`, () => {
- for (const chrome of ['bg-', 'rounded', 'px-', 'py-', 'border']) {
- expect(cls).not.toContain(chrome)
- }
- })
-
- it(`${name} sits on the text baseline without a nudge`, () => {
- // With no vertical padding there's nothing to cancel, so the pill needs
- // no magic em offset to stop riding low.
- expect(cls).toContain('align-baseline')
- expect(cls).not.toMatch(/align-\[-/)
- })
- }
-
- it('directive and slash chips are literally the same shape', () => {
- expect(SLASH_CHIP_BASE_CLASS).toBe(DIRECTIVE_CHIP_CLASS)
- })
-
- it('resolves to the same font size as its container', () => {
- const host = document.createElement('div')
-
- host.style.fontSize = '16px'
- host.innerHTML = `apps/desktop/`
- document.body.append(host)
-
- const chip = host.querySelector('#chip') as HTMLElement
-
- expect(getComputedStyle(chip).fontSize).toBe(getComputedStyle(host).fontSize)
-
- host.remove()
- })
-})
-
-describe('the shared reference vocabulary', () => {
- it('gives every kind an icon, a color, and a label', () => {
- for (const [kind, style] of Object.entries(REFERENCE_STYLES)) {
- expect(style.codicon, `${kind} codicon`).toBeTruthy()
- expect(style.color, `${kind} color`).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('carries the kind colour into the chip class', () => {
- for (const kind of ['file', 'url', 'skill', 'command'] as const) {
- expect(directiveChipClass(kind)).toContain(referenceStyle(kind).color)
- }
- })
-
- it('falls back to a real style for an unknown kind', () => {
- const style = referenceStyle('something-new')
-
- expect(style).toBe(REFERENCE_STYLES.other)
- expect(style.codicon).toBeTruthy()
- })
-
- it('gives commands and skills distinct accents so a list reads at a glance', () => {
- expect(referenceStyle('skill').color).not.toBe(referenceStyle('command').color)
- })
-})
diff --git a/apps/desktop/src/app/chat/composer/inline-references.test.ts b/apps/desktop/src/app/chat/composer/inline-references.test.ts
new file mode 100644
index 00000000000..952f6bd85ad
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/inline-references.test.ts
@@ -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=""`.
+ * 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)
+ }
+ })
+})
diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts
index ba691b12db3..6285a4dcfd0 100644
--- a/apps/desktop/src/app/chat/composer/rich-editor.ts
+++ b/apps/desktop/src/app/chat/composer/rich-editor.ts
@@ -7,15 +7,15 @@
* plain-text round-trip.
*/
import {
- directiveChipClass,
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 `${directiveIconSvg(kind)}${escapeHtml(label)}`
+ return `${directiveIconSvg(kind)}${escapeHtml(label)}`
}
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 = directiveChipClass(kind)
- 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
}
diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx
index 6ba9a81a02f..32b40323034 100644
--- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx
+++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx
@@ -1,7 +1,7 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { Fragment } from 'react'
-import { referenceStyle } from '@/components/assistant-ui/reference-kinds'
+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'
@@ -137,6 +137,7 @@ export function ComposerTriggerPopover({
const isFirstHeader = lastGroup === undefined
lastGroup = group || lastGroup
const active = index === activeIndex
+ const refKind = referenceKind(rowKind(item, isSlash))
return (
@@ -154,10 +155,8 @@ export function ComposerTriggerPopover({
{display}
) : (
<>
-
-
+
+ {display}
{description && (
diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx
index de540822286..f400aba62ea 100644
--- a/apps/desktop/src/components/assistant-ui/directive-text.tsx
+++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx
@@ -13,7 +13,7 @@ import { useSessionLinkTitle } from '@/lib/session-link-title'
import { parseSessionRefValue, sessionRefFallbackLabel } from '@/lib/session-refs'
import { cn } from '@/lib/utils'
-import { referenceStyle } from './reference-kinds'
+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]
@@ -26,26 +26,23 @@ 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"'
/**
- * Shared chip styling — used by the rendered and by the raw
- * HTML composer chips in `rich-editor.ts`.
+ * 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.
*
- * A chip is inline TEXT, not a badge: no background, no padding, no border.
- * The icon says what kind of thing it is and the color reinforces it, which is
- * all the signal a reference needs when it's sitting in the middle of a
- * sentence — a filled pill turns every mention into a UI element the eye has
- * to step over. Per-kind color comes from REFERENCE_STYLES so a `@file:` reads
- * the same here as it does in the popover you picked it from.
- *
- * Font size is inherited rather than shrunk: a reference is content the user
- * chose, and rendering it smaller than the words around it reads as a
- * footnote. With no vertical padding the glyphs sit in the normal line box, so
- * `align-baseline` is enough — no nudge needed.
+ * 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 const DIRECTIVE_CHIP_CLASS = 'inline-flex max-w-56 items-baseline gap-1 align-baseline font-medium leading-none'
+export function refAttrs(kind?: string, extra?: string): { className: string; 'data-ref'?: string } {
+ const className = extra ? `ref ${extra}` : 'ref'
-/** Per-kind chip classes: the shared shape plus the kind's own color. */
-export function directiveChipClass(type: string): string {
- return `${DIRECTIVE_CHIP_CLASS} ${referenceStyle(type).color}`
+ 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"'
}
@@ -55,12 +52,11 @@ export function directiveIconSvg(type: string) {
.map(d => ``)
.join('')
- return ``
+ return ``
}
function iconElementFromPaths(paths: string[]) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
- svg.setAttribute('class', 'size-[0.875em] shrink-0 opacity-80')
svg.setAttribute('fill', 'none')
svg.setAttribute('stroke', 'currentColor')
svg.setAttribute('stroke-linecap', 'round')
@@ -82,25 +78,17 @@ export function directiveIconElement(type: string) {
return iconElementFromPaths(iconPathsFor(type))
}
-/** Slash pills are references too — commands, skills, and themes are just
- * three more kinds in the shared vocabulary, so they share the chip shape and
- * read their icon + accent from the same table. */
+/** 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'
-export const SLASH_CHIP_BASE_CLASS = DIRECTIVE_CHIP_CLASS
-
-export function slashChipClass(kind: SlashChipKind): string {
- return directiveChipClass(kind)
-}
-
export function slashIconElement(kind: SlashChipKind) {
return iconElementFromPaths(iconPathsFor(kind))
}
-const DirectiveIcon: FC<{ type: string; className?: string }> = ({
- type,
- className = 'size-[0.875em] 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 }) => (