Merge pull request #76401 from NousResearch/bb/composer-link-open
Act on composer directive chips from a hover pill
This commit is contained in:
commit
eca996aa33
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1660,6 +1660,7 @@ export const ar = defineLocale({
|
|||
newSessionPlaceholders: ['اسأل Hermes عن شيء...', 'اطلب من Hermes تنفيذ مهمة...', 'ابدأ محادثة جديدة...'],
|
||||
followUpPlaceholders: ['اكتب متابعة...', 'أضف توجيها...', 'اسأل سؤالا آخر...'],
|
||||
startVoice: 'بدء الصوت',
|
||||
openDirective: 'فتح',
|
||||
queueMessage: 'إضافة الرسالة للطابور',
|
||||
steer: 'توجيه',
|
||||
stop: 'إيقاف',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1817,6 +1817,7 @@ export const ja = defineLocale({
|
|||
'調整または続行'
|
||||
],
|
||||
startVoice: '音声会話を開始',
|
||||
openDirective: '開く',
|
||||
queueMessage: 'メッセージをキューに入れる',
|
||||
stop: '停止',
|
||||
send: '送信',
|
||||
|
|
|
|||
|
|
@ -1662,6 +1662,7 @@ export interface Translations {
|
|||
newSessionPlaceholders: readonly string[]
|
||||
followUpPlaceholders: readonly string[]
|
||||
startVoice: string
|
||||
openDirective: string
|
||||
queueMessage: string
|
||||
steer: string
|
||||
stop: string
|
||||
|
|
|
|||
|
|
@ -1759,6 +1759,7 @@ export const zhHant = defineLocale({
|
|||
'調整或繼續'
|
||||
],
|
||||
startVoice: '開始語音對話',
|
||||
openDirective: '開啟',
|
||||
queueMessage: '排隊訊息',
|
||||
stop: '停止',
|
||||
send: '傳送',
|
||||
|
|
|
|||
|
|
@ -2173,6 +2173,7 @@ export const zh: Translations = {
|
|||
'调整或继续'
|
||||
],
|
||||
startVoice: '开始语音对话',
|
||||
openDirective: '打开',
|
||||
queueMessage: '排队消息',
|
||||
steer: '引导当前运行',
|
||||
stop: '停止',
|
||||
|
|
|
|||
Loading…
Reference in New Issue