diff --git a/apps/desktop/src/app/chat/composer/directive-actions.test.tsx b/apps/desktop/src/app/chat/composer/directive-actions.test.tsx
new file mode 100644
index 0000000000000..4c827fe9a164a
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/directive-actions.test.tsx
@@ -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(
+
+
+
+ )
+
+ 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(
+
+
+
+ )
+
+ // Editor attached AFTER mount.
+ document.body.append(editor)
+ hover(editor.querySelector('[data-ref-kind="url"]')!)
+
+ expect(pillValue()).toBe('https://late.example')
+ })
+})
diff --git a/apps/desktop/src/app/chat/composer/directive-actions.tsx b/apps/desktop/src/app/chat/composer/directive-actions.tsx
new file mode 100644
index 0000000000000..b18ade2aac039
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/directive-actions.tsx
@@ -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('[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 }) {
+ const { t } = useI18n()
+ const [anchor, setAnchor] = useState(null)
+ const hideTimerRef = useRef(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(
+
+
+
,
+ document.body
+ )
+}
diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index fb6d86515b153..3767a4ad4e4e1 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -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
/>
+
{/* 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
diff --git a/apps/desktop/src/app/chat/composer/micro-actions.tsx b/apps/desktop/src/app/chat/composer/micro-actions.tsx
index 6f8cc77ad566c..65e3729eadad4 100644
--- a/apps/desktop/src/app/chat/composer/micro-actions.tsx
+++ b/apps/desktop/src/app/chat/composer/micro-actions.tsx
@@ -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'
)
diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx
index 6d149e2dd2d16..538f7770d734d 100644
--- a/apps/desktop/src/components/assistant-ui/directive-text.tsx
+++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx
@@ -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 = {
+ session: {
+ icon: 'link-external',
+ label: t => t.composer.openDirective,
+ run: openSessionRef
+ },
+ url: {
+ icon: 'link-external',
+ label: t => t.composer.openDirective,
+ run: openExternalLink
+ }
+}
+
/** A `@session:/` 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 }> = ({
)
-/** 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 = (
<>
@@ -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 ? (
-