From a09ad04653f61fe224110b9dd304afea7b608f03 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 1 Aug 2026 15:45:42 -0500 Subject: [PATCH 1/2] refactor(desktop): share the composer's floating pill treatment The micro-action strip owned this skin inline: a full-radius hairline pill on the composer's own fill behind a blur, sized to `--composer-control-size`. It's the right look for anything that floats over the composer, so lift it into `composer-dock` next to the other shared composer surfaces and have the strip compose it with its own width cap and disabled state. --- .../src/app/chat/composer/micro-actions.tsx | 15 ++++++--------- .../src/components/chat/composer-dock.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) 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/chat/composer-dock.ts b/apps/desktop/src/components/chat/composer-dock.ts index 88db91d2dac6a..3d2a045b7129e 100644 --- a/apps/desktop/src/components/chat/composer-dock.ts +++ b/apps/desktop/src/components/chat/composer-dock.ts @@ -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 From 5ba2564ca0ef3aba4f8b024712f2ad018ddedf66 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 1 Aug 2026 15:45:42 -0500 Subject: [PATCH 2/2] feat(desktop): act on composer directive chips from a hover pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 was no way to actually act on the reference. Hovering a chip whose kind has an action now floats a pill above it that runs it: `@url:` opens in the browser, `@session:` opens the session as a tab. It's a small registry (`DIRECTIVE_ACTIONS`), so a new actionable kind is one entry, not another watcher. The pill portals to `` and anchors to the chip's rect, so it can't end up inside the submitted draft, and it re-anchors on scroll and resize rather than stranding itself over a reference that moved or was deleted. The press is swallowed before it reaches the editor — mousedown in a contenteditable moves the caret, and the edit composer reads a blur as "cancel". Listeners bind to `document`, not the editor: the edit composer's contenteditable isn't reliably attached when the effect first runs, so an editor-bound listener never fired there. A document listener that reads the editor lazily works in both composers, and each instance filters to its own editor so one chip never shows two pills. --- .../chat/composer/directive-actions.test.tsx | 150 +++++++++++++++++ .../app/chat/composer/directive-actions.tsx | 154 ++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 2 + .../assistant-ui/directive-text.tsx | 45 ++++- .../assistant-ui/session-ref-open.test.tsx | 23 +++ .../thread/user-edit-composer.tsx | 2 + apps/desktop/src/i18n/ar.ts | 1 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + 12 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/directive-actions.test.tsx create mode 100644 apps/desktop/src/app/chat/composer/directive-actions.tsx 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 747177592288a..e5f88145ee168 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' @@ -985,6 +986,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/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 ? ( - ) : ( diff --git a/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx index d8fe7136aacc4..f10d729d88f67 100644 --- a/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx +++ b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx @@ -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() + + const chip = screen.getByTitle('https://example.com/docs') + + expect(chip.tagName).toBe('BUTTON') + fireEvent.click(chip) + + expect(openExternal).toHaveBeenCalledWith('https://example.com/docs') + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 1e6ec792d016a..ee0f6bd164c59 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -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, @@ -795,6 +796,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess spellCheck={false} suppressContentEditableWarning /> +