feat(web): FUE + composer hint for session @-mentions (#1274)

* feat(web): FUE + placeholder for rich composer session @-mentions

Discover session @-mentions via composer-grounded FUE and always-on
placeholder copy when rich composer is active (#1273).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): notify onFocus after programmatic rich-composer autofocus

Playwright headless (and some engines) skip the DOM focus event for
element.focus(), so FUE engage never ran. Call the onFocus prop after
autofocus so discovery still works.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): engage rich-composer FUE on mount

DOM focus events are unreliable for programmatic autofocus (and in
Playwright). Treat the live rich composer as the affordance and open
the callout when the rich path mounts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): measure FueCallout height; ellipsis rich placeholder

Bot review on #1274: position from real panel height (ResizeObserver)
so multi-line FUE bodies clear the composer, and keep long mention
placeholders on one ellipsized line in the input row.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): Escape dismisses rich-composer FUE; keep expand flex chain

Escape while the mention FUE is engaging only dismisses the callout
(no abort/collapse). FUE anchor is a flex container so expanded
RichComposerInput still fills height. Mock resolveComposerPlaceholderKey
in sendError tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-08-04 11:18:21 +08:00
committed by GitHub
co-authored by Cursor
parent a0c676818f
commit f6da005b50
9 changed files with 176 additions and 28 deletions
@@ -83,7 +83,11 @@ vi.mock('@assistant-ui/react', async () => {
}
})
vi.mock('@/lib/composerSegments', () => ({ isRichComposerMentionsEnabled: () => false }))
vi.mock('@/lib/composerSegments', () => ({
isRichComposerMentionsEnabled: () => false,
resolveComposerPlaceholderKey: ({ showContinueHint }: { showContinueHint: boolean }) =>
showContinueHint ? 'misc.typeMessage' : 'misc.typeAMessage',
}))
vi.mock('@/hooks/useComposerDraft', () => ({
useComposerDraft: (sessionId: string | undefined) => ({ sessionId, complete: true, restoredAny: false }),
}))
@@ -19,12 +19,14 @@ import {
useRef,
useState
} from 'react'
import { isRichComposerMentionsEnabled } from '@/lib/composerSegments'
import { isRichComposerMentionsEnabled, resolveComposerPlaceholderKey } from '@/lib/composerSegments'
import type { SessionMentionResolveResult } from '@/components/AssistantChat/RichComposerInput'
import {
RichComposerInput,
type RichComposerInputHandle,
} from '@/components/AssistantChat/RichComposerInput'
import { useFue } from '@/lib/use-fue'
import { FueCallout, FueDot } from '@/components/Fue'
import type { AgentState, CodexCollaborationMode, PermissionMode, PiModelSummary } from '@/types/api'
import type { Suggestion } from '@/hooks/useActiveSuggestions'
import type { ConversationStatus } from '@/realtime/types'
@@ -522,6 +524,7 @@ export function HappyComposer(props: {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const richInputRef = useRef<RichComposerInputHandle>(null)
const richComposerFueAnchorRef = useRef<HTMLDivElement>(null)
// `composer.text === ''` alone is not enough to identify the empty state
// created by a send. A user can type and delete a fresh draft before the
// failed mutation reports back. Keep monotonic interaction generations so
@@ -538,8 +541,21 @@ export function HappyComposer(props: {
// Kill-switch only (?richMentions=0 / localStorage=0 / VITE=false). Mount-time
// read — hard reload required, so no per-keystroke localStorage/URL parse.
const [richMentionsEnabled] = useState(() => isRichComposerMentionsEnabled())
const {
status: richComposerFueStatus,
engage: engageRichComposerFue,
dismiss: dismissRichComposerFue,
} = useFue('rich-composer-mentions')
const prevControlledByUser = useRef(controlledByUser)
// Composer itself is the affordance: open the FUE callout once the rich
// path is live. Relying on DOM focus alone is flaky (programmatic
// autofocus / Playwright headless often skip the focus event).
useEffect(() => {
if (!richMentionsEnabled) return
engageRichComposerFue()
}, [richMentionsEnabled, engageRichComposerFue])
const recordUserEdit = useCallback(() => {
userEditGenerationRef.current += 1
}, [])
@@ -1178,6 +1194,14 @@ export function HappyComposer(props: {
}
if (key === 'Escape') {
// FUE callout also listens on window; dismiss it first so Escape
// does not also abort a running thread or collapse the editor.
if (richComposerFueStatus === 'engaging') {
e.preventDefault()
e.stopPropagation()
dismissRichComposerFue()
return
}
const action = getComposerEscapeAction({
hasSuggestions: suggestions.length > 0,
threadIsRunning,
@@ -1217,6 +1241,8 @@ export function HappyComposer(props: {
haptic,
composerEnterBehavior,
richMentionsEnabled,
richComposerFueStatus,
dismissRichComposerFue,
flushAndSend,
canQueueSend,
isExpanded,
@@ -2058,20 +2084,36 @@ export function HappyComposer(props: {
isExpanded ? 'min-h-0 flex-1 items-stretch' : 'items-center'
}`}>
{richMentionsEnabled ? (
<RichComposerInput
ref={richInputRef}
value={composerText}
autoFocus={!controlsDisabled && !isTouch}
placeholder={showContinueHint ? t('misc.typeMessage') : t('misc.typeAMessage')}
disabled={controlsDisabled}
onValueChange={handleRichValueChange}
onMirrorChange={handleRichMirrorChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
resolveSessionMentionTooltip={resolveSessionMentionTooltip}
onEdit={handleRichEdit}
className={editorClassName}
/>
<div
ref={richComposerFueAnchorRef}
className="relative flex min-w-0 flex-1"
data-testid="rich-composer-fue-anchor"
>
<RichComposerInput
ref={richInputRef}
value={composerText}
autoFocus={!controlsDisabled && !isTouch}
placeholder={t(resolveComposerPlaceholderKey({
richMentionsEnabled: true,
showContinueHint,
}))}
disabled={controlsDisabled}
onValueChange={handleRichValueChange}
onMirrorChange={handleRichMirrorChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => engageRichComposerFue()}
resolveSessionMentionTooltip={resolveSessionMentionTooltip}
onEdit={handleRichEdit}
className={editorClassName}
/>
{richComposerFueStatus !== 'acknowledged' ? (
<FueDot
pulsing={richComposerFueStatus === 'unseen'}
ariaLabel={t('fue.newFeatureDot')}
/>
) : null}
</div>
) : isExpanded ? (
<ComposerPrimitive.Input
asChild
@@ -2085,7 +2127,10 @@ export function HappyComposer(props: {
onPaste={handlePaste}
>
<textarea
placeholder={showContinueHint ? t('misc.typeMessage') : t('misc.typeAMessage')}
placeholder={t(resolveComposerPlaceholderKey({
richMentionsEnabled: false,
showContinueHint,
}))}
disabled={controlsDisabled}
className="h-full min-h-0 flex-1 resize-none overflow-y-auto bg-transparent text-base leading-snug text-[var(--app-fg)] placeholder-[var(--app-hint)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
@@ -2094,7 +2139,10 @@ export function HappyComposer(props: {
<ComposerPrimitive.Input
ref={textareaRef}
autoFocus={!controlsDisabled && !isTouch}
placeholder={showContinueHint ? t('misc.typeMessage') : t('misc.typeAMessage')}
placeholder={t(resolveComposerPlaceholderKey({
richMentionsEnabled: false,
showContinueHint,
}))}
disabled={controlsDisabled}
maxRows={5}
submitOnEnter={false}
@@ -2107,6 +2155,17 @@ export function HappyComposer(props: {
/>
)}
</div>
{richMentionsEnabled && richComposerFueStatus === 'engaging' ? (
<FueCallout
title={t('richComposer.fueTitle')}
body={t('richComposer.fueBody')}
onDismiss={dismissRichComposerFue}
dismissLabel={t('fue.gotIt')}
closeAriaLabel={t('fue.closeAriaLabel')}
anchorRef={richComposerFueAnchorRef}
width={288}
/>
) : null}
<ComposerButtons
canSend={canSend}
@@ -7,6 +7,7 @@ import {
useRef,
useState,
type ClipboardEvent as ReactClipboardEvent,
type FocusEvent as ReactFocusEvent,
type FormEvent as ReactFormEvent,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
@@ -72,6 +73,7 @@ type Props = {
onMirrorChange: (state: { text: string; selection: ComposerSelection }) => void
onKeyDown?: (e: ReactKeyboardEvent<HTMLDivElement>) => void
onPaste?: (e: ReactClipboardEvent<HTMLDivElement>) => void
onFocus?: (e: ReactFocusEvent<HTMLDivElement>) => void
onEdit?: () => void
/** Live session meta for chip hover / aria-label (from useSessions). */
resolveSessionMentionTooltip?: ResolveSessionMentionTooltip
@@ -619,6 +621,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
onMirrorChange,
onKeyDown,
onPaste,
onFocus,
onEdit,
resolveSessionMentionTooltip,
},
@@ -683,6 +686,9 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
syncFromValue(value)
}, [value, syncFromValue])
const onFocusRef = useRef(onFocus)
onFocusRef.current = onFocus
useEffect(() => {
if (!autoFocus || disabled) return
const root = rootRef.current
@@ -692,6 +698,18 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
} catch {
root.focus()
}
// Programmatic focus is not guaranteed to fire a DOM focus event in
// every engine (notably Playwright headless). Notify the parent so
// FUE / other first-focus hooks still run.
onFocusRef.current?.(
{
type: 'focus',
target: root,
currentTarget: root,
preventDefault() {},
stopPropagation() {},
} as ReactFocusEvent<HTMLDivElement>
)
}, [autoFocus, disabled])
useImperativeHandle(ref, () => ({
@@ -995,7 +1013,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
{domIsEmpty && placeholder ? (
<div
aria-hidden
className="pointer-events-none absolute inset-0 text-base leading-snug text-[var(--app-hint)]"
className="pointer-events-none absolute inset-0 overflow-hidden text-ellipsis whitespace-nowrap text-base leading-snug text-[var(--app-hint)]"
>
{placeholder}
</div>
@@ -1013,6 +1031,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
data-testid="rich-composer-input"
className={`${className ?? ''}${disabled ? ' cursor-not-allowed opacity-50' : ''}`}
onInput={handleInput}
onFocus={onFocus}
onKeyDown={handleKeyDown}
onPointerOver={handlePointerOver}
onPointerLeave={handlePointerLeave}
+13
View File
@@ -68,4 +68,17 @@ describe('computeFueCalloutPlacement', () => {
expect(result.placement).toBe('above')
expect(result.top).toBeGreaterThanOrEqual(8)
})
it('uses measured panelHeight so taller callouts clear the anchor', () => {
const result = computeFueCalloutPlacement({
anchor: { top: 700, left: 100, right: 132, bottom: 732 },
panelWidth: 288,
panelHeight: 180,
viewport: VIEWPORT,
gap: 8,
})
expect(result.placement).toBe('above')
// top = anchor.top - gap - panelHeight = 700 - 8 - 180 = 512
expect(result.top).toBe(512)
})
})
+19 -9
View File
@@ -1,5 +1,5 @@
import type { CSSProperties, ReactNode, RefObject } from 'react'
import { useEffect, useLayoutEffect, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
/**
@@ -112,6 +112,7 @@ export function FueCallout(props: {
style?: CSSProperties
}) {
const panelWidth = props.width ?? 256
const panelRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{
top: number
left: number
@@ -127,9 +128,11 @@ export function FueCallout(props: {
return () => window.removeEventListener('keydown', onKey)
}, [props])
// Position once the anchor is mounted; re-position on viewport changes.
// useLayoutEffect so first paint already has correct position
// (avoids a flash at 0,0).
// Position once the anchor is mounted; re-position on viewport / panel size
// changes. useLayoutEffect so first paint already has correct position
// (avoids a flash at 0,0). Measure the real panel height — multi-line FUE
// bodies are taller than the old 96px estimate and would overlap the
// composer if we kept the hard-code.
useLayoutEffect(() => {
const anchor = props.anchorRef.current
if (!anchor) return
@@ -139,9 +142,8 @@ export function FueCallout(props: {
if (!a) return
const rect = a.getBoundingClientRect()
const vp = window.visualViewport
// Estimate panel height before render — close enough for clamping;
// the actual layout adapts via Tailwind classes anyway.
const panelHeight = 96
const measured = panelRef.current?.getBoundingClientRect().height ?? 0
const panelHeight = measured > 0 ? measured : 96
setPos(
computeFueCalloutPlacement({
anchor: rect,
@@ -157,22 +159,30 @@ export function FueCallout(props: {
)
}
measure()
const panel = panelRef.current
const resizeObserver =
typeof ResizeObserver !== 'undefined' && panel
? new ResizeObserver(() => measure())
: null
if (panel && resizeObserver) resizeObserver.observe(panel)
window.addEventListener('resize', measure, { passive: true })
window.addEventListener('scroll', measure, { passive: true, capture: true })
window.visualViewport?.addEventListener('resize', measure, { passive: true })
window.visualViewport?.addEventListener('scroll', measure, { passive: true })
return () => {
resizeObserver?.disconnect()
window.removeEventListener('resize', measure)
window.removeEventListener('scroll', measure, true)
window.visualViewport?.removeEventListener('resize', measure)
window.visualViewport?.removeEventListener('scroll', measure)
}
}, [props.anchorRef, panelWidth])
}, [props.anchorRef, panelWidth, props.title, props.body])
if (typeof document === 'undefined') return null
const node = (
<div
ref={panelRef}
role="dialog"
aria-label={props.title}
style={
@@ -184,7 +194,7 @@ export function FueCallout(props: {
width: panelWidth,
...props.style,
}
: { position: 'fixed', visibility: 'hidden' }
: { position: 'fixed', visibility: 'hidden', width: panelWidth }
}
// Solid theme-aware bg + solid amber border. The badge-warning CSS
// vars are alpha 0.2 (designed to layer over chat content); using
+24
View File
@@ -7,6 +7,7 @@ import {
insertSegmentsInComposerSegments,
insertSessionMentionInComposerSegments,
isRichComposerMentionsEnabled,
resolveComposerPlaceholderKey,
mirrorComposerSegments,
parseComposerSegments,
serializeComposerSegments,
@@ -224,6 +225,29 @@ describe('isRichComposerMentionsEnabled', () => {
})
})
describe('resolveComposerPlaceholderKey', () => {
it('prefers continue hint over mention copy', () => {
expect(resolveComposerPlaceholderKey({
richMentionsEnabled: true,
showContinueHint: true,
})).toBe('misc.typeMessage')
})
it('uses mention-aware placeholder when rich composer is on', () => {
expect(resolveComposerPlaceholderKey({
richMentionsEnabled: true,
showContinueHint: false,
})).toBe('misc.typeAMessageWithMentions')
})
it('keeps generic placeholder when rich composer is killed', () => {
expect(resolveComposerPlaceholderKey({
richMentionsEnabled: false,
showContinueHint: false,
})).toBe('misc.typeAMessage')
})
})
describe('serializeComposerSelection', () => {
it('emits wire markdown with session ids for a chip selection', () => {
const segments = parseComposerSegments('see [Peer A](/sessions/aaa) please')
+13
View File
@@ -303,3 +303,16 @@ export function isRichComposerMentionsEnabled(): boolean {
if (import.meta.env.VITE_RICH_COMPOSER_MENTIONS === 'false') return false
return true
}
/**
* Composer empty-state placeholder i18n key.
* Continue-hint outranks mention hint; mention copy only when rich composer is on.
*/
export function resolveComposerPlaceholderKey(opts: {
richMentionsEnabled: boolean
showContinueHint: boolean
}): 'misc.typeMessage' | 'misc.typeAMessageWithMentions' | 'misc.typeAMessage' {
if (opts.showContinueHint) return 'misc.typeMessage'
if (opts.richMentionsEnabled) return 'misc.typeAMessageWithMentions'
return 'misc.typeAMessage'
}
+3
View File
@@ -565,6 +565,8 @@ export default {
'scratchlist.sendToScratchlist': 'Send to scratchlist',
'scratchlist.fueTitle': 'New: Scratchlist',
'scratchlist.fueBody': 'Park notes & drafts here without sending. The Send button glows amber while you stash; click the icon (or Ctrl/Cmd+Shift+S) again to leave.',
'richComposer.fueTitle': 'New: @mention another session',
'richComposer.fueBody': 'Type @ to mention another session. It becomes a chip (hover for sidebar-style details). On send, the message includes that session\'s full ID so the agent can inspect_peer or ping_peer.',
'scratchlist.addPlaceholder': 'Note, draft, or idea — Enter to add',
'scratchlist.addAriaLabel': 'Add scratchlist entry',
'scratchlist.add': 'Add',
@@ -964,6 +966,7 @@ export default {
'misc.releaseToLoadOlder': 'Release to load earlier messages',
'misc.typeMessage': "Type 'continue' to resume...",
'misc.typeAMessage': 'Type a message...',
'misc.typeAMessageWithMentions': 'Type what you want the agent to do, or @mention another session for context or handoff',
'misc.offline': 'offline',
'misc.permissionRequired': 'permission required',
'misc.online': 'online',
+3
View File
@@ -564,6 +564,8 @@ export default {
'scratchlist.sendToScratchlist': '存入暂存清单',
'scratchlist.fueTitle': '新功能:暂存清单',
'scratchlist.fueBody': '在此暂存笔记和草稿,不会被发送。暂存模式下发送按钮会显示琥珀色;再次点击图标(或 Ctrl/Cmd+Shift+S)可退出。',
'richComposer.fueTitle': '新功能:@ 提及其他会话',
'richComposer.fueBody': '输入 @ 可提及另一个会话,会变成芯片(悬停可查看与侧边栏一致的详情)。发送后消息会包含该会话的完整 ID,供智能体使用 inspect_peer 或 ping_peer。',
'scratchlist.addPlaceholder': '笔记、草稿或想法 — 回车键添加',
'scratchlist.addAriaLabel': '添加草稿夹条目',
'scratchlist.add': '添加',
@@ -963,6 +965,7 @@ export default {
'misc.releaseToLoadOlder': '松开加载',
'misc.typeMessage': "输入 'continue' 继续...",
'misc.typeAMessage': '输入消息...',
'misc.typeAMessageWithMentions': '输入想让智能体做的事,或用 @ 提及另一个会话以提供上下文或移交',
'misc.offline': 'offline',
'misc.permissionRequired': '需要权限',
'misc.online': 'online',