mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
refactor(desktop): one .ref class, and the theme owns every accent
A reference had two styling systems: a Tailwind class string assembled in TypeScript (`directiveChipClass`) and a separate `link-chip` for prose links, each carrying its own color-mix(). Same concept, three appearances. Now every inline reference — a composer chip, a sent message's mention, a markdown link, a completion row's glyph — is `class="ref"` plus `data-ref="<kind>"`, and styles.css owns the accent. No hex or color-mix() ships from a component, so a skin restyles all of them at once. Keying the accent on `[data-ref]` alone rather than `.ref[data-ref]` also lets the popover's icon column take a kind's hue without inheriting its inline-text layout.
This commit is contained in:
parent
d83d296473
commit
c999dc2e8e
11 changed files with 226 additions and 202 deletions
|
|
@ -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 = `<span id="chip" class="${DIRECTIVE_CHIP_CLASS}">apps/desktop/</span>`
|
||||
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)
|
||||
})
|
||||
})
|
||||
72
apps/desktop/src/app/chat/composer/inline-references.test.ts
Normal file
72
apps/desktop/src/app/chat/composer/inline-references.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { refAttrs, refAttrsHtml } from '@/components/assistant-ui/directive-text'
|
||||
import { REFERENCE_STYLES, referenceKind, referenceStyle } from '@/components/assistant-ui/reference-kinds'
|
||||
|
||||
/**
|
||||
* There is ONE inline-reference system: `class="ref"` + `data-ref="<kind>"`.
|
||||
* A pasted link, an `@file:` chip, a `/skill`, a `@session:` the agent wrote —
|
||||
* all the same markup, styled by the `.ref` rules in styles.css.
|
||||
*/
|
||||
describe('the inline reference contract', () => {
|
||||
it('marks any element as a reference of a given kind', () => {
|
||||
expect(refAttrs('file')).toEqual({ className: 'ref', 'data-ref': 'file' })
|
||||
expect(refAttrsHtml('skill')).toBe('class="ref" data-ref="skill"')
|
||||
})
|
||||
|
||||
it('an unkinded reference is a plain link, not a broken one', () => {
|
||||
// A bare external link has no kind — it keeps the default link colour
|
||||
// rather than being tagged with a wrong one.
|
||||
expect(refAttrs()).toEqual({ className: 'ref' })
|
||||
expect(refAttrsHtml()).toBe('class="ref"')
|
||||
})
|
||||
|
||||
it('normalises an unknown kind instead of emitting it raw', () => {
|
||||
// A kind CSS has no rule for would silently render unstyled; coercing to
|
||||
// `other` keeps it inside the system.
|
||||
expect(refAttrs('wat')['data-ref']).toBe('other')
|
||||
expect(referenceKind('wat')).toBe('other')
|
||||
})
|
||||
|
||||
it('ships no colour from TypeScript — the theme owns every accent', () => {
|
||||
// The whole point of keying on `data-ref`: a skin restyles all references
|
||||
// at once, and no hex or color-mix() is hardcoded in a component.
|
||||
for (const [kind, style] of Object.entries(REFERENCE_STYLES)) {
|
||||
expect(style, `${kind} must not carry a colour`).not.toHaveProperty('color')
|
||||
}
|
||||
|
||||
expect(JSON.stringify(refAttrs('url'))).not.toMatch(/color|#[0-9a-f]{3}/i)
|
||||
})
|
||||
|
||||
it('gives every kind a glyph and a label', () => {
|
||||
for (const [kind, style] of Object.entries(REFERENCE_STYLES)) {
|
||||
expect(style.codicon, `${kind} codicon`).toBeTruthy()
|
||||
expect(style.label, `${kind} label`).toBeTruthy()
|
||||
|
||||
// Emoji rows render the emoji itself instead of a glyph.
|
||||
if (kind !== 'emoji') {
|
||||
expect(style.paths.length, `${kind} paths`).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps commands and skills visually distinct', () => {
|
||||
// Different data-ref values, so the stylesheet can accent them apart.
|
||||
expect(refAttrs('skill')['data-ref']).not.toBe(refAttrs('command')['data-ref'])
|
||||
expect(referenceStyle('skill').codicon).not.toBe(referenceStyle('command').codicon)
|
||||
})
|
||||
})
|
||||
|
||||
describe('references are text, not badges', () => {
|
||||
it('carries no layout, padding, or background of its own', () => {
|
||||
// Everything visual lives in the stylesheet. If a component starts adding
|
||||
// its own chrome here, that's the drift this system exists to prevent.
|
||||
const { className } = refAttrs('file')
|
||||
|
||||
expect(className).toBe('ref')
|
||||
|
||||
for (const chrome of ['bg-', 'rounded', 'px-', 'py-', 'border', 'inline-flex', 'text-[']) {
|
||||
expect(className).not.toContain(chrome)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -7,15 +7,15 @@
|
|||
* plain-text round-trip.
|
||||
*/
|
||||
import {
|
||||
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 `<span contenteditable="false" title="${escapeHtml(id)}" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${directiveChipClass(kind)}">${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 = 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Fragment key={item.id}>
|
||||
|
|
@ -154,10 +155,8 @@ export function ComposerTriggerPopover({
|
|||
<span className="min-w-0 shrink truncate leading-5 text-foreground">{display}</span>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={cn('grid size-4 shrink-0 place-items-center', referenceStyle(rowKind(item, isSlash)).color)}
|
||||
>
|
||||
<Codicon name={referenceStyle(rowKind(item, isSlash)).codicon} size="0.875rem" />
|
||||
<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 && (
|
||||
|
|
|
|||
|
|
@ -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 <DirectiveChip> 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 => `<path d="${d}"/>`)
|
||||
.join('')
|
||||
|
||||
return `<svg ${SVG_ATTRS} class="size-[0.875em] 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-[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 }) => (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
|
|
@ -498,7 +486,7 @@ export const SessionRefLink: FC<{
|
|||
|
||||
return (
|
||||
<a
|
||||
className="link-chip wrap-anywhere"
|
||||
{...refAttrs('session', 'wrap-anywhere')}
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
@ -507,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>
|
||||
)
|
||||
|
|
@ -516,9 +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}>
|
||||
<span {...refAttrs(kind)} data-slot="aui_slash-chip" title={value}>
|
||||
<DirectiveIcon type={kind} />
|
||||
<span className="truncate">{label}</span>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
|
||||
|
|
@ -533,14 +521,13 @@ const DirectiveChip: FC<{
|
|||
const body = (
|
||||
<>
|
||||
<DirectiveIcon type={type} />
|
||||
<span className="truncate">{label}</span>
|
||||
{label}
|
||||
</>
|
||||
)
|
||||
|
||||
const props = {
|
||||
className: cn(directiveChipClass(type), onClick && 'cursor-pointer transition-colors hover:text-foreground'),
|
||||
...refAttrs(type, cn('wrap-anywhere', onClick && 'cursor-pointer')),
|
||||
'data-directive-id': id,
|
||||
'data-directive-type': type,
|
||||
'data-slot': 'aui_directive-chip',
|
||||
title: id
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string
|
|||
return (
|
||||
<span className="block">
|
||||
<button
|
||||
className="mt-2 link-chip bg-transparent text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
className="mt-2 ref text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={open}
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -223,7 +223,7 @@ function MediaAttachment({ path }: { path: string }) {
|
|||
return (
|
||||
<span className="wrap-anywhere">
|
||||
<a
|
||||
className="link-chip wrap-anywhere"
|
||||
className="ref wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
@ -273,7 +273,7 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
|
|||
if (!target || !/^https?:\/\//i.test(target)) {
|
||||
return (
|
||||
<a
|
||||
className={cn('link-chip wrap-anywhere', className)}
|
||||
className={cn('ref wrap-anywhere', className)}
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
|
|
@ -370,7 +370,7 @@ function MarkdownImageContent({ className, src, alt, ...props }: ComponentProps<
|
|||
<span className="my-2 block text-sm text-muted-foreground">
|
||||
Couldn't load {name}.{' '}
|
||||
<button
|
||||
className="link-chip bg-transparent font-medium text-foreground hover:text-foreground"
|
||||
className="ref font-medium text-foreground"
|
||||
onClick={open}
|
||||
type="button"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -35,23 +35,16 @@ export type ReferenceKind =
|
|||
interface ReferenceStyle {
|
||||
/** Codicon name — the popover row's leading glyph. */
|
||||
codicon: string
|
||||
/** Tabler outline path data — the chip's inline SVG. */
|
||||
/** Tabler outline path data — the inline SVG a rendered reference uses. */
|
||||
paths: string[]
|
||||
/** Chip text color. Chips are text-only now, so this carries the whole
|
||||
* signal for "what kind of thing is this". */
|
||||
color: string
|
||||
/** Section label when the popover groups by this kind. */
|
||||
/** Section label when a surface groups by this kind. */
|
||||
label: string
|
||||
}
|
||||
|
||||
// Accents map to the theme's own tokens rather than raw hues, so a reference
|
||||
// keeps its meaning across every skin. Files/folders/paths share the neutral
|
||||
// secondary text color — they're the common case and shouldn't shout; the
|
||||
// things that ACT (commands, skills) take the accent colors.
|
||||
const NEUTRAL = 'text-(--ui-text-secondary)'
|
||||
const ACCENT = 'text-[color-mix(in_srgb,var(--ui-accent)_82%,var(--foreground))]'
|
||||
const WARM = 'text-[color-mix(in_srgb,var(--ui-warm)_82%,var(--foreground))]'
|
||||
const SECONDARY = 'text-[color-mix(in_srgb,var(--ui-accent-secondary)_82%,var(--foreground))]'
|
||||
// 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',
|
||||
|
|
@ -64,13 +57,12 @@ const FILE_PATHS = [
|
|||
const TERMINAL_PATHS = ['M5 7l5 5l-5 5', 'M12 19l7 0']
|
||||
|
||||
export const REFERENCE_STYLES: Record<ReferenceKind, ReferenceStyle> = {
|
||||
file: { codicon: 'file', paths: FILE_PATHS, color: NEUTRAL, label: 'Files' },
|
||||
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'
|
||||
],
|
||||
color: NEUTRAL,
|
||||
label: 'Folders'
|
||||
},
|
||||
url: {
|
||||
|
|
@ -80,7 +72,6 @@ export const REFERENCE_STYLES: Record<ReferenceKind, ReferenceStyle> = {
|
|||
'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'
|
||||
],
|
||||
color: SECONDARY,
|
||||
label: 'Links'
|
||||
},
|
||||
image: {
|
||||
|
|
@ -91,33 +82,29 @@ export const REFERENCE_STYLES: Record<ReferenceKind, ReferenceStyle> = {
|
|||
'M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5',
|
||||
'M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3'
|
||||
],
|
||||
color: SECONDARY,
|
||||
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'],
|
||||
color: ACCENT,
|
||||
label: 'Tools'
|
||||
},
|
||||
line: {
|
||||
codicon: 'list-selection',
|
||||
paths: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'],
|
||||
color: NEUTRAL,
|
||||
label: 'Lines'
|
||||
},
|
||||
terminal: { codicon: 'terminal', paths: TERMINAL_PATHS, color: NEUTRAL, label: 'Terminal' },
|
||||
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'],
|
||||
color: SECONDARY,
|
||||
label: 'Sessions'
|
||||
},
|
||||
git: { codicon: 'git-branch', paths: ['M7 18l0 -12', 'M7 8a2 2 0 1 0 0 -4a2 2 0 0 0 0 4'], color: WARM, label: 'Git' },
|
||||
diff: { codicon: 'diff', paths: ['M12 5l0 14', 'M5 12l14 0'], color: WARM, label: 'Changes' },
|
||||
staged: { codicon: 'diff-added', paths: ['M12 5l0 14', 'M5 12l14 0'], color: WARM, label: 'Staged' },
|
||||
command: { codicon: 'terminal', paths: TERMINAL_PATHS, color: ACCENT, label: 'Commands' },
|
||||
skill: { codicon: 'zap', paths: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'], color: WARM, label: 'Skills' },
|
||||
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: [
|
||||
|
|
@ -126,11 +113,10 @@ export const REFERENCE_STYLES: Record<ReferenceKind, ReferenceStyle> = {
|
|||
'M21 3a16 16 0 0 1 -10.2 12.8',
|
||||
'M10.6 9a9 9 0 0 1 4.4 4.4'
|
||||
],
|
||||
color: SECONDARY,
|
||||
label: 'Themes'
|
||||
},
|
||||
emoji: { codicon: 'smiley', paths: [], color: NEUTRAL, label: 'Emoji' },
|
||||
other: { codicon: 'symbol-misc', paths: FILE_PATHS, color: NEUTRAL, label: 'Other' }
|
||||
emoji: { codicon: 'smiley', paths: [], label: 'Emoji' },
|
||||
other: { codicon: 'symbol-misc', paths: FILE_PATHS, label: 'Other' }
|
||||
}
|
||||
|
||||
const KNOWN = new Set(Object.keys(REFERENCE_STYLES))
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function tagged<T extends keyof typeof TAG_CLASSES>(Tag: T) {
|
|||
function MarkdownAnchor({ children, className, href, ...rest }: ComponentProps<'a'>) {
|
||||
if (!href || !/^https?:\/\//i.test(href)) {
|
||||
return (
|
||||
<a className={cn('link-chip', className)} href={href} {...rest}>
|
||||
<a className={cn('ref', className)} href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({
|
|||
if (failed && image) {
|
||||
return (
|
||||
<a
|
||||
className="mt-2 link-chip inline-block wrap-anywhere"
|
||||
className="mt-2 ref inline-block wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ export function ExternalLink({
|
|||
|
||||
return (
|
||||
<a
|
||||
className={cn('link-chip', className)}
|
||||
className={cn('ref', className)}
|
||||
href={target}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
|
|
|
|||
|
|
@ -574,36 +574,107 @@
|
|||
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 0.125rem 0.125rem;
|
||||
}
|
||||
|
||||
/* Inline content links: color at rest, a tinted chip on hover, never an
|
||||
underline. Tint is currentColor-relative (the old `decoration-current/20`
|
||||
idiom), so text and fill share a hue on every theme from one `color`. */
|
||||
.link-chip {
|
||||
/* The one knob: resting fill. 0% = color-only until hovered. */
|
||||
--link-chip-tint: 0%;
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
INLINE REFERENCES — the one system for anything that points at something
|
||||
from inside a run of text.
|
||||
|
||||
A pasted link, an `@file:` the user picked, a `/skill`, a `@session:` the
|
||||
agent wrote: all the same species. They render the same way in the composer
|
||||
and in a sent message, because they're the same thing before and after send.
|
||||
|
||||
A reference is TEXT, not a badge — colour and an optional icon, no fill, no
|
||||
padding, no border. A pill turns every mention into a widget the eye has to
|
||||
step over, and mid-sentence that's most of the sentence.
|
||||
|
||||
Usage: `class="ref"` plus `data-ref="<kind>"` for the accent. Kinds live in
|
||||
`components/assistant-ui/reference-kinds.ts` (icon + label); their colour
|
||||
lives here, so a theme restyles every reference at once and TS never ships a
|
||||
hex. No `data-ref` = an ordinary link, which keeps the primary link colour.
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
/* Kind → accent. Keyed on the attribute ALONE so anything can adopt a
|
||||
reference's colour without also taking its inline-text layout (a completion
|
||||
row's icon column wants the hue, not the margin). Grouped by what a
|
||||
reference DOES, so the palette reads as meaning rather than decoration:
|
||||
· things you point at (paths) → neutral, they're the common case
|
||||
· things you fetch (links, media) → secondary
|
||||
· things that act (commands, tools) → accent
|
||||
· things that change code (skills, git)→ warm */
|
||||
[data-ref] {
|
||||
--ref-color: var(--dt-primary);
|
||||
}
|
||||
|
||||
[data-ref='file'],
|
||||
[data-ref='folder'],
|
||||
[data-ref='line'],
|
||||
[data-ref='terminal'] {
|
||||
--ref-color: var(--ui-text-secondary);
|
||||
}
|
||||
|
||||
[data-ref='url'],
|
||||
[data-ref='image'],
|
||||
[data-ref='session'],
|
||||
[data-ref='theme'] {
|
||||
--ref-color: color-mix(in srgb, var(--ui-accent-secondary) 82%, var(--foreground));
|
||||
}
|
||||
|
||||
[data-ref='command'],
|
||||
[data-ref='tool'] {
|
||||
--ref-color: color-mix(in srgb, var(--ui-accent) 82%, var(--foreground));
|
||||
}
|
||||
|
||||
[data-ref='skill'],
|
||||
[data-ref='git'],
|
||||
[data-ref='diff'],
|
||||
[data-ref='staged'] {
|
||||
--ref-color: color-mix(in srgb, var(--ui-warm) 82%, var(--foreground));
|
||||
}
|
||||
|
||||
.ref {
|
||||
--ref-color: var(--dt-primary);
|
||||
|
||||
/* Prose links inherit the surrounding text's weight. `@tailwindcss/typography`
|
||||
sets `prose a { font-weight: 500 }`, which outranks a utility class on the
|
||||
anchor — so the override belongs here, on the shared chip. */
|
||||
anchor — so the override belongs here, on the shared class. */
|
||||
font-weight: inherit;
|
||||
|
||||
/* `ch`/`em` so the chip tracks the text at any size. Block padding is
|
||||
asymmetric because an inline box's content area is ascent+descent — equal
|
||||
padding paints the glyphs high. Keep it small: `padding-block` doesn't grow
|
||||
the line box, so an over-padded chip creeps into the line above. */
|
||||
padding: 0.05ch 0.5ch 0.2ch;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--dt-primary);
|
||||
background: color-mix(in srgb, currentColor var(--link-chip-tint), transparent);
|
||||
color: var(--ref-color);
|
||||
/* Explicit: the base layer underlines every `a` (see the :where(a, …) reset). */
|
||||
text-decoration: none;
|
||||
/* A wrapped link gets a chip per line fragment, not one ragged box. */
|
||||
/* A wrapped reference breaks per line fragment, not as one ragged box. */
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.link-chip:hover {
|
||||
--link-chip-tint: 15%;
|
||||
/* Affordance without chrome: only the ones you can actually activate respond,
|
||||
and they do it with an underline rather than a background. */
|
||||
:where(a, button).ref:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
|
||||
/* The leading glyph. Sized in `em` so it tracks the text at any scale, and
|
||||
spaced with `margin` — not flex `gap` — so `.ref` stays an inline box whose
|
||||
label can wrap mid-word (a long URL has to break across lines like the prose
|
||||
around it, which a flex container would prevent).
|
||||
|
||||
The margin is unconditional. A `:not(:only-child)` guard looks right and is
|
||||
silently wrong: CSS counts ELEMENT siblings, so an icon followed by a bare
|
||||
text-node label still matches `:only-child` — which is exactly how every
|
||||
chip lost its spacer. Icon-only users take the hue via `[data-ref]` instead
|
||||
of this class. */
|
||||
.ref > :where(svg, i.codicon) {
|
||||
margin-inline-end: 0.25em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ref > svg {
|
||||
display: inline-block;
|
||||
width: 0.875em;
|
||||
height: 0.875em;
|
||||
vertical-align: -0.1em;
|
||||
}
|
||||
|
||||
.ref > i.codicon {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Hover-reveal suppression — the shared, declarative escape hatch.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue