From c999dc2e8ef402fda2365dc312ad2953f2bc8813 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 04:35:30 -0500 Subject: [PATCH] refactor(desktop): one `.ref` class, and the theme owns every accent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=""`, 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. --- .../app/chat/composer/chip-typography.test.ts | 87 -------------- .../chat/composer/inline-references.test.ts | 72 ++++++++++++ .../src/app/chat/composer/rich-editor.ts | 24 ++-- .../src/app/chat/composer/trigger-popover.tsx | 9 +- .../assistant-ui/directive-text.tsx | 67 +++++------ .../components/assistant-ui/markdown-text.tsx | 8 +- .../assistant-ui/reference-kinds.ts | 44 +++---- .../src/components/chat/compact-markdown.tsx | 2 +- .../chat/generated-image-result.tsx | 2 +- apps/desktop/src/lib/external-link.tsx | 2 +- apps/desktop/src/styles.css | 111 ++++++++++++++---- 11 files changed, 226 insertions(+), 202 deletions(-) delete mode 100644 apps/desktop/src/app/chat/composer/chip-typography.test.ts create mode 100644 apps/desktop/src/app/chat/composer/inline-references.test.ts 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 `${inner}` + return `${inner}` } 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 }) => ( { event.preventDefault() @@ -507,7 +495,7 @@ export const SessionRefLink: FC<{ }} title={value} > - + {resolved} ) @@ -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 }) => ( - + - {label} + {label} ) @@ -533,14 +521,13 @@ const DirectiveChip: FC<{ const body = ( <> - {label} + {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 } diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index f6a35b57647..172a96a99c5 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -127,7 +127,7 @@ function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string return (