Merge pull request #76401 from NousResearch/bb/composer-link-open

Act on composer directive chips from a hover pill
This commit is contained in:
brooklyn! 2026-08-01 20:29:14 -05:00 committed by GitHub
commit eca996aa33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 399 additions and 15 deletions

View File

@ -0,0 +1,150 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import { ComposerDirectiveActions } from './directive-actions'
import { refChipElement } from './rich-editor'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const openSession = vi.fn()
vi.mock('@/app/open-session', () => ({ openSession: (...args: unknown[]) => openSession(...args) }))
/** A live contenteditable holding real chips, with the watcher bound to it
* the same pair both composers mount. */
function mountEditor(chips: { kind: string; value: string }[]) {
const editor = document.createElement('div')
editor.contentEditable = 'true'
editor.append(...chips.map(chip => refChipElement(chip.kind, `\`${chip.value}\``)))
document.body.append(editor)
render(
<I18nProvider configClient={null} initialLocale="en">
<ComposerDirectiveActions editorRef={{ current: editor }} />
</I18nProvider>
)
return editor
}
function chips(editor: HTMLElement, kind: string) {
return Array.from(editor.querySelectorAll(`[data-ref-kind="${kind}"]`))
}
function hover(node: Element) {
fireEvent.pointerOver(node, { bubbles: true })
}
/** The reference the visible action pill points at, or null when there is none. */
function pillValue() {
return document.querySelector('[data-slot="composer-directive-action"]')?.getAttribute('data-value') ?? null
}
afterEach(() => {
cleanup()
document.body.replaceChildren()
delete desktopWindow.hermesDesktop
openSession.mockReset()
vi.useRealTimers()
})
describe('ComposerDirectiveActions', () => {
it('offers an action for a hovered actionable chip', () => {
const editor = mountEditor([{ kind: 'url', value: 'https://example.com/docs' }])
expect(pillValue()).toBeNull()
hover(chips(editor, 'url')[0]!)
expect(pillValue()).toBe('https://example.com/docs')
})
it('opens a url externally rather than navigating the app', () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
desktopWindow.hermesDesktop = { openExternal } as unknown as Window['hermesDesktop']
const editor = mountEditor([{ kind: 'url', value: 'https://example.com/docs' }])
hover(chips(editor, 'url')[0]!)
fireEvent.click(screen.getByRole('button'))
expect(openExternal).toHaveBeenCalledWith('https://example.com/docs')
expect(pillValue()).toBeNull()
})
it('runs the kind-specific action — a session chip opens the session', async () => {
const editor = mountEditor([{ kind: 'session', value: 'default/20260722_204335_d62c16' }])
hover(chips(editor, 'session')[0]!)
fireEvent.click(screen.getByRole('button'))
// openSessionRef lazy-imports the navigator, so the call lands a tick later.
await vi.waitFor(() =>
expect(openSession).toHaveBeenCalledWith('20260722_204335_d62c16', expect.any(Function), 'tab')
)
})
it('leaves kinds with no action alone', () => {
const editor = mountEditor([{ kind: 'file', value: 'src/main.tsx' }])
hover(chips(editor, 'file')[0]!)
expect(pillValue()).toBeNull()
})
it('follows the pointer from one chip to the next', () => {
const editor = mountEditor([
{ kind: 'url', value: 'https://one.example' },
{ kind: 'url', value: 'https://two.example' }
])
const [first, second] = chips(editor, 'url')
hover(first!)
expect(pillValue()).toBe('https://one.example')
hover(second!)
expect(pillValue()).toBe('https://two.example')
})
it('keeps the pill up while the pointer crosses onto it', () => {
vi.useFakeTimers()
const editor = mountEditor([{ kind: 'url', value: 'https://example.com' }])
const chip = chips(editor, 'url')[0]!
hover(chip)
fireEvent.pointerOut(chip, { relatedTarget: document.body })
fireEvent.mouseEnter(screen.getByRole('button').parentElement!)
vi.advanceTimersByTime(500)
expect(pillValue()).toBe('https://example.com')
})
it('binds to the document so a late-attached editor still gets the affordance', () => {
// The edit composer's editor isn't reliably in the DOM when the effect
// first runs; a document listener that reads the editor lazily works
// regardless — this is the whole reason it binds to document, not editor.
const editor = document.createElement('div')
editor.contentEditable = 'true'
editor.append(refChipElement('url', '`https://late.example`'))
render(
<I18nProvider configClient={null} initialLocale="en">
<ComposerDirectiveActions editorRef={{ current: editor }} />
</I18nProvider>
)
// Editor attached AFTER mount.
document.body.append(editor)
hover(editor.querySelector('[data-ref-kind="url"]')!)
expect(pillValue()).toBe('https://late.example')
})
})

View File

@ -0,0 +1,154 @@
/**
* Hover actions for directive chips in a composer.
*
* A directive chip (`@url:`, `@session:`, ) reads as the thing it points at
* and is coloured like one, but a composer is an editor a click inside the
* contenteditable only places the caret, so there's no way to *act* on the
* reference. Instead, hovering a chip whose kind has an action floats a small
* pill above it that runs it.
*
* The kind action table (`DIRECTIVE_ACTIONS`) lives in `directive-text`, so
* it is shared with the sent-message chip: one entry lights up both surfaces.
*/
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { DIRECTIVE_ACTIONS, type DirectiveAction } from '@/components/assistant-ui/directive-text'
import { composerFloatingPill } from '@/components/chat/composer-dock'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
/** Moving between the chip and the pill crosses a gap where neither is hovered.
* Short enough that it still reads as instant on the way out. */
const HIDE_DELAY_MS = 120
/** The actionable directive chip under `target` that also belongs to `editor`,
* if there is one. */
function actionableChipAt(target: EventTarget | null, editor: HTMLElement): HTMLElement | null {
const chip = target instanceof Element ? target.closest<HTMLElement>('[data-ref-kind]') : null
const kind = chip?.dataset.refKind
return chip && kind && chip.dataset.refId && editor.contains(chip) && DIRECTIVE_ACTIONS[kind] ? chip : null
}
interface Anchor {
action: DirectiveAction
chip: HTMLElement
left: number
top: number
value: string
}
function anchorFor(chip: HTMLElement): Anchor | null {
const value = chip.dataset.refId
const action = chip.dataset.refKind ? DIRECTIVE_ACTIONS[chip.dataset.refKind] : undefined
if (!value || !action || !chip.isConnected) {
return null
}
const rect = chip.getBoundingClientRect()
return { action, chip, left: rect.left, top: rect.top, value }
}
/**
* Renders the action pill for whichever actionable chip in `editorRef` is
* hovered.
*
* Listeners bind to `document`, not the editor, so mount timing can't strand
* them: the edit composer's contenteditable isn't reliably attached when this
* effect first runs, and a document listener that reads the editor lazily works
* regardless. Each instance filters to its own editor, so the docked and edit
* composers never show two pills for one chip.
*/
export function ComposerDirectiveActions({ editorRef }: { editorRef: RefObject<HTMLElement | null> }) {
const { t } = useI18n()
const [anchor, setAnchor] = useState<Anchor | null>(null)
const hideTimerRef = useRef<number | undefined>(undefined)
const cancelHide = useCallback(() => {
window.clearTimeout(hideTimerRef.current)
}, [])
const hideSoon = useCallback(() => {
cancelHide()
hideTimerRef.current = window.setTimeout(() => setAnchor(null), HIDE_DELAY_MS)
}, [cancelHide])
useEffect(() => {
const onPointerOver = (event: PointerEvent) => {
const editor = editorRef.current
const chip = editor && actionableChipAt(event.target, editor)
if (!chip) {
return
}
cancelHide()
setAnchor(current => (current?.chip === chip ? current : anchorFor(chip)))
}
const onPointerOut = (event: PointerEvent) => {
const editor = editorRef.current
const chip = editor && actionableChipAt(event.target, editor)
// A move within the same chip (its icon → its label) is not a leave.
if (chip && editor && chip === actionableChipAt(event.relatedTarget, editor)) {
return
}
hideSoon()
}
// The chip can move or vanish under a parked pointer: the editor scrolls,
// the window resizes, or the user deletes the reference the pill points at.
const reanchor = () => setAnchor(current => (current ? anchorFor(current.chip) : null))
document.addEventListener('pointerover', onPointerOver)
document.addEventListener('pointerout', onPointerOut)
window.addEventListener('scroll', reanchor, true)
window.addEventListener('resize', reanchor)
return () => {
document.removeEventListener('pointerover', onPointerOver)
document.removeEventListener('pointerout', onPointerOut)
window.removeEventListener('scroll', reanchor, true)
window.removeEventListener('resize', reanchor)
window.clearTimeout(hideTimerRef.current)
}
}, [cancelHide, editorRef, hideSoon])
if (!anchor) {
return null
}
return createPortal(
<div
className="fixed z-(--z-over-modal) -translate-y-full pb-1"
data-slot="composer-directive-action"
data-value={anchor.value}
onMouseEnter={cancelHide}
onMouseLeave={hideSoon}
style={{ left: anchor.left, top: anchor.top }}
>
<button
className={cn(composerFloatingPill, 'shadow-nous')}
onClick={() => {
anchor.action.run(anchor.value)
setAnchor(null)
}}
// Never let the press reach the editor: mousedown inside a
// contenteditable moves the caret and can collapse a selection the user
// still wants, and the edit composer treats a blur as "cancel".
onMouseDown={event => event.preventDefault()}
type="button"
>
<Codicon className="shrink-0 opacity-70" name={anchor.action.icon} size="0.75rem" />
{anchor.action.label(t)}
</button>
</div>,
document.body
)
}

View File

@ -32,6 +32,7 @@ import {
import { ContextMenu } from './context-menu'
import { COMPOSER_AREAS, runComposerMiddleware } from './contrib'
import { ComposerControls } from './controls'
import { ComposerDirectiveActions } from './directive-actions'
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
import { markActiveComposer } from './focus'
import { HelpHint } from './help-hint'
@ -989,6 +990,7 @@ export function ChatBar({
spellCheck={false}
suppressContentEditableWarning
/>
<ComposerDirectiveActions editorRef={editorRef} />
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
so the composer-state binding (text + IME + paste + form-submit hookup)
wires up. We render the real input UI ourselves above via the

View File

@ -1,5 +1,6 @@
import { memo, useState } from 'react'
import { composerFloatingPill } from '@/components/chat/composer-dock'
import { Codicon } from '@/components/ui/codicon'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
@ -7,11 +8,9 @@ import { $composerActionsBySession, type ComposerAction } from '@/store/composer
import { notifyError } from '@/store/notifications'
/**
* Floating pill the treatment the thread's jump/approval button uses for a
* control that sits over scrolling content: full radius, hairline border, the
* shared composer fill behind a blur so thread text never bleeds through.
* Sized against the composer's own control height so a row of pills lines up
* with the chrome it floats above.
* Floating pill the shared treatment for a control that sits over the
* composer (`composerFloatingPill`), plus this strip's own width cap and
* disabled state.
*
* NEVER `pointer-events-none`, not even when disabled. The pop-out drag region
* is an `absolute` sibling behind these pills, so a pill that stops taking
@ -19,10 +18,8 @@ import { notifyError } from '@/store/notifications'
* becomes a grab handle that floats the composer.
*/
const PILL = cn(
'inline-flex h-(--composer-control-size) max-w-56 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5',
'border border-border/65 bg-(--composer-fill) backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
'text-xs font-normal text-(--ui-text-secondary) transition-colors',
'hover:bg-(--chrome-action-hover) hover:text-foreground',
composerFloatingPill,
'max-w-56',
'disabled:cursor-default disabled:opacity-50 disabled:hover:bg-(--composer-fill)',
'focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50'
)

View File

@ -6,7 +6,9 @@ import type { FC } from 'react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import type { I18nContextValue } from '@/i18n'
import { extractEmbeddedImages } from '@/lib/embedded-images'
import { openExternalLink } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics'
import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media'
import { useSessionLinkTitle } from '@/lib/session-link-title'
@ -442,7 +444,7 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
* it's already a tile/main, otherwise open a stacked tab (never steals main
* from under the chat you're reading). Lazy-imports so the composer's rich
* editor can pull this module in without booting the profile/REST stack. */
function openSessionRef(value: string) {
export function openSessionRef(value: string) {
const { sessionId } = parseSessionRefValue(value)
if (!sessionId) {
@ -454,6 +456,33 @@ function openSessionRef(value: string) {
void import('@/app/open-session').then(({ openSession }) => openSession(sessionId, () => undefined, 'tab'))
}
/** What activating a directive of a given kind does. The single source of truth
* for "you can act on this reference," shared by every surface that renders a
* chip: the composer's hover pill (`ComposerDirectiveActions`) and the sent
* message's clickable chip below. A kind with no entry is inert everywhere.
*
* Add a kind here and both surfaces light up that's the whole point of one
* table. `icon`/`label` are for the pill; the transcript chip carries its own
* glyph and only reads `run`. */
export interface DirectiveAction {
icon: string
label: (t: I18nContextValue['t']) => string
run: (value: string) => void
}
export const DIRECTIVE_ACTIONS: Record<string, DirectiveAction> = {
session: {
icon: 'link-external',
label: t => t.composer.openDirective,
run: openSessionRef
},
url: {
icon: 'link-external',
label: t => t.composer.openDirective,
run: openExternalLink
}
}
/** A `@session:<profile>/<id>` reference in the user transcript (directive
* segments), rendered as a chip like the other composer refs. Clicking it
* opens the session as a tab. */
@ -501,14 +530,18 @@ const SlashChip: FC<{ kind: SlashChipKind; label: string; value: string }> = ({
</span>
)
/** Inert by default; `onClick` promotes the chip to a real button (session
* refs, which open the session they name). */
/** A directive reference in a sent message. A kind with a `DIRECTIVE_ACTIONS`
* entry (a url, ) renders as a real button that runs it on click; everything
* else is inert text. `onClick` overrides for chips that resolve their target
* themselves (session, which needs the async navigator). */
const DirectiveChip: FC<{
type: string
label: string
id: string
onClick?: () => void
}> = ({ type, label, id, onClick }) => {
const activate = onClick ?? (DIRECTIVE_ACTIONS[type] ? () => DIRECTIVE_ACTIONS[type]!.run(id) : undefined)
const body = (
<>
<DirectiveIcon type={type} />
@ -517,14 +550,14 @@ const DirectiveChip: FC<{
)
const props = {
...refAttrs(type, cn('wrap-anywhere', onClick && 'cursor-pointer')),
...refAttrs(type, cn('wrap-anywhere', activate && 'cursor-pointer')),
'data-directive-id': id,
'data-slot': 'aui_directive-chip',
title: id
}
return onClick ? (
<button {...props} onClick={onClick} type="button">
return activate ? (
<button {...props} onClick={activate} type="button">
{body}
</button>
) : (

View File

@ -12,9 +12,12 @@ vi.mock('@/app/open-session', () => ({
openSession: (...args: unknown[]) => openSession(...args)
}))
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
afterEach(() => {
cleanup()
openSession.mockClear()
delete desktopWindow.hermesDesktop
__resetSessionLinkTitleCache()
})
@ -41,3 +44,23 @@ describe('session refs open the session', () => {
await vi.waitFor(() => expect(openSession).toHaveBeenCalledWith('20260101_abc123', expect.any(Function), 'tab'))
})
})
// A url the user sent renders as a chip too, and it opens in the browser — the
// same door the composer's hover pill uses, so a link behaves the same before
// and after send.
describe('url refs open externally', () => {
it('opens a url chip in the user transcript', () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
desktopWindow.hermesDesktop = { openExternal } as unknown as Window['hermesDesktop']
render(<DirectiveContent text="see @url:`https://example.com/docs` when you can" />)
const chip = screen.getByTitle('https://example.com/docs')
expect(chip.tagName).toBe('BUTTON')
fireEvent.click(chip)
expect(openExternal).toHaveBeenCalledWith('https://example.com/docs')
})
})

View File

@ -13,6 +13,7 @@ import {
useState
} from 'react'
import { ComposerDirectiveActions } from '@/app/chat/composer/directive-actions'
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance'
import {
type ComposerInsertMode,
@ -793,6 +794,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
spellCheck={false}
suppressContentEditableWarning
/>
<ComposerDirectiveActions editorRef={editorRef} />
<ComposerPrimitive.Input
asChild
className="sr-only"

View File

@ -34,6 +34,23 @@ export const composerPanelCard = cn(
composerSurfaceGlass
)
/**
* A quiet control floating over composer content the micro-action pills above
* the surface, the Open affordance on a hovered link inside it. Full radius,
* hairline border, the composer's own fill behind a blur so the text underneath
* never shows through. Sized against the composer's control height so a pill
* lines up with the chrome it floats above.
*
* Skin and size only; the call site owns position, width caps, and disabled
* state.
*/
export const composerFloatingPill = cn(
'inline-flex h-(--composer-control-size) shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5',
'border border-border/65 bg-(--composer-fill) backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
'text-xs font-normal text-(--ui-text-secondary) transition-colors',
'hover:bg-(--chrome-action-hover) hover:text-foreground'
)
/**
* Shared grid for the chrome-free floating strips that bracket the composer
* the micro-action pills above the surface and the `composer.underside` slot

View File

@ -1660,6 +1660,7 @@ export const ar = defineLocale({
newSessionPlaceholders: ['اسأل Hermes عن شيء...', 'اطلب من Hermes تنفيذ مهمة...', 'ابدأ محادثة جديدة...'],
followUpPlaceholders: ['اكتب متابعة...', 'أضف توجيها...', 'اسأل سؤالا آخر...'],
startVoice: 'بدء الصوت',
openDirective: 'فتح',
queueMessage: 'إضافة الرسالة للطابور',
steer: 'توجيه',
stop: 'إيقاف',

View File

@ -1980,6 +1980,7 @@ export const en: Translations = {
'Adjust or continue'
],
startVoice: 'Start voice conversation',
openDirective: 'Open',
queueMessage: 'Queue message',
steer: 'Steer the current run',
stop: 'Stop',

View File

@ -1817,6 +1817,7 @@ export const ja = defineLocale({
'調整または続行'
],
startVoice: '音声会話を開始',
openDirective: '開く',
queueMessage: 'メッセージをキューに入れる',
stop: '停止',
send: '送信',

View File

@ -1662,6 +1662,7 @@ export interface Translations {
newSessionPlaceholders: readonly string[]
followUpPlaceholders: readonly string[]
startVoice: string
openDirective: string
queueMessage: string
steer: string
stop: string

View File

@ -1759,6 +1759,7 @@ export const zhHant = defineLocale({
'調整或繼續'
],
startVoice: '開始語音對話',
openDirective: '開啟',
queueMessage: '排隊訊息',
stop: '停止',
send: '傳送',

View File

@ -2173,6 +2173,7 @@ export const zh: Translations = {
'调整或继续'
],
startVoice: '开始语音对话',
openDirective: '打开',
queueMessage: '排队消息',
steer: '引导当前运行',
stop: '停止',