feat: rich composer session @-mentions + inspect_peer (#1228)

* feat(web): feature-flagged rich composer for inline session @ mentions

Custom segmented contenteditable (not TipTap) inserts caret-local session
atoms from the existing @ picker and serializes to markdown links on send.
Textarea path remains default until flag parity dogfood.

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

* fix(web): rich composer mention boundary + #1215 refs

Treat U+FFFC mirror atoms as word boundaries so @ after a session
token still opens autocomplete. Point comments at Fixes #1215.

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

* test(web): peer-stack e2e for rich composer session @ mentions (#1215)

Smoke: flag on, @ picker inserts inline session atom chip (not prose dump).
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): preserve newlines in rich composer Enter-newline mode

Chromium splits contenteditable on Enter into block divs; serialize those
as \\n and insert <br> when parent leaves Enter unhandled (Shift+Enter /
enter-inserts-newline).

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

* fix(web): show @ badge when rich composer mentions flag is on

Dogfood was invisible: flag-off looks like a normal textarea, and flag-on
had no chrome. Surface a small @ badge when enabled.

* fix(web): rich session composer on by default (not a user setting)

The plan dual-path was an engineering kill-switch, not an opt-in. Default
to the segmented composer; only richMentions=0 disables. Drop the flag
badge and record a peer-stack motion proof covering chips + baseline UX.

* fix(web): make rich composer Shift+Enter create a visible newline

Trailing <br>+empty text node was a silent no-op at EOL. Use
insertLineBreak (ZWSP pad fallback), assert real \\n in peer e2e.

* feat(web): hover tooltips on rich composer session chips

Show full title, status, short id, and path on chip hover via a portal
bubble fed by live useSessions lookup (drafts fall back to title + id).

* fix(web): dismiss rich composer chip tooltips on mouse leave

contenteditable pointerout/relatedTarget was flaky so tips stuck after
leaving the chip. Hit-test on pointermove, clear on prose/input/leave.

* fix(web): address cold-review Blocker/Majors on rich composer

Exclude peer e2e from default Playwright; force plain-text paste; restore
newline hard-stop in findActiveWord; fix root-anchored selection mapping
and nested-block serialize; cover with unit tests.

* chore: drop accidental .cursor files from rich-composer tip

* fix(web): close remaining cold-review gaps on rich composer

Drop absolute peer e2e tooling imports, prove chip→markdown send, and
harden paste/EOL/focus/tooltip/Enter edges before Meta rematerialize.

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

* chore: absorb soup playwright.config union for clean remat

Keep fork peer-stack timeouts/annotated-video wiring and add testIgnore
for e2e/peer so the next driver rematerialize does not conflict.

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

* revert: drop fork playwright tooling from upstreamable tip

Peer-stack annotated-video + HAPI_PEER wiring stay on fork main / soup.
Product tip only needs testIgnore for e2e/peer (see docs/tooling/peer-stack.md).

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

* fix(web): fix rich composer Shift+Enter double newline and paste space

Prefer manual newline+pad over execCommand insertLineBreak, and stop
applying autocomplete trailing-space on paste/drop paths.

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

* fix(web): pad EOL Shift+Enter after Range.insertNode split

insertNode always leaves an empty text sibling, so !nextSibling never
saw EOL; detect meaningful trailing content and cover with jsdom tests.

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

* fix(web): drop custom onDrop from rich composer

Intercepting drop without caretRangeFromPoint landed text at EOF or
no-oped in-editor moves. Native CE drop is enough for #1215; paste
still forces plain text.

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

* feat(web): sidebar-parity tooltips on rich composer session chips

Reuse SessionRowSummary (flavor, thinking/attention, schedule, todos,
relative ago, path) for chip hover so the tip matches the session list.

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

* chore: keep peer-stack e2e off the upstreamable tip

Peer specs and playwright.peer.config stay on fork main per
docs/tooling/peer-stack.md; default config still testIgnore's e2e/peer.

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

* feat: cite sessions with UUID wire + inspect_peer for agent/overseer

Rich composer chips already serialize to [title](/sessions/<id>); flush
before send so the agent prompt never gets title-only chip text. Add
inspect_peer (MCP + hapi inspect-peer) as the read twin of ping_peer so
that same id is immediately usable for overseer/agent peer lookup, with
system-prompt glue from citations to inspect/ping.

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

* fix(cli): gate inspect_peer behind permission approval

Cross-session history reads need the same prompt path as ping_peer:
keep inspect_peer off Claude --allowedTools and treat it as sensitive
in ACP/OpenCode read-only mode so prompt injection cannot silently
enumerate peer transcripts.

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

* chore: clarify playwright peer testIgnore is upstream-safe

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

* fix(web): keep session UUIDs on rich composer copy/cut/paste

Copy/cut write wire markdown so chips do not collapse to @title-only
clipboard text; paste reparses session links back into atoms.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-31 19:42:56 +08:00
committed by GitHub
co-authored by Cursor
parent c7afc50321
commit bc543dc494
35 changed files with 2937 additions and 273 deletions
@@ -12,6 +12,12 @@ import {
useRef,
useState
} from 'react'
import { isRichComposerMentionsEnabled } from '@/lib/composerSegments'
import type { SessionMentionResolveResult } from '@/components/AssistantChat/RichComposerInput'
import {
RichComposerInput,
type RichComposerInputHandle,
} from '@/components/AssistantChat/RichComposerInput'
import type { AgentState, CodexCollaborationMode, PermissionMode, PiModelSummary, ThreadGoal } from '@/types/api'
import type { Suggestion } from '@/hooks/useActiveSuggestions'
import type { ConversationStatus } from '@/realtime/types'
@@ -202,6 +208,8 @@ export function HappyComposer(props: {
// inline error affordance until the user dismisses or starts editing.
sendError?: ComposerSendError | null
onClearSendError?: () => void
/** Chip hover / aria-label resolver (SessionChat → useSessions). */
resolveSessionMentionTooltip?: (id: string, title: string) => SessionMentionResolveResult
}) {
const { t } = useTranslation()
const {
@@ -252,7 +260,8 @@ export function HappyComposer(props: {
onSchedule: onScheduleProp,
onClearSchedule: onClearScheduleProp,
sendError = null,
onClearSendError
onClearSendError,
resolveSessionMentionTooltip,
} = props
// Use ?? so missing values fall back to default (destructuring defaults only handle undefined)
@@ -304,6 +313,10 @@ export function HappyComposer(props: {
const setPendingSchedule = isControlled ? onScheduleProp : setPendingScheduleLocal
const textareaRef = useRef<HTMLTextAreaElement>(null)
const richInputRef = useRef<RichComposerInputHandle>(null)
// 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 prevControlledByUser = useRef(controlledByUser)
const attachmentDrafts = attachments.flatMap((attachment) => {
@@ -360,6 +373,10 @@ export function HappyComposer(props: {
}, [sendError, api, composerText, onScheduleProp])
useEffect(() => {
if (richMentionsEnabled) {
// Rich input owns mirror text + selection via onMirrorChange.
return
}
setInputState((prev) => {
if (prev.text === composerText) return prev
// When syncing from composerText, update selection to end of text
@@ -367,7 +384,7 @@ export function HappyComposer(props: {
const newPos = composerText.length
return { text: composerText, selection: { start: newPos, end: newPos } }
})
}, [composerText])
}, [composerText, richMentionsEnabled])
// Track one-time "continue" hint after switching from local to remote.
useEffect(() => {
@@ -403,11 +420,33 @@ export function HappyComposer(props: {
const handleSuggestionSelect = useCallback((index: number) => {
const suggestion = suggestions[index]
if (!suggestion || !textareaRef.current) return
if (!suggestion) return
if (suggestion.text.startsWith('$')) {
markSkillUsed(suggestion.text.slice(1))
}
if (richMentionsEnabled && richInputRef.current) {
// insert*/apply* emit mirror state via onMirrorChange (keep inputState in mirror space).
if (suggestion.sessionMention) {
richInputRef.current.insertSessionMention(
suggestion.sessionMention,
autocompletePrefixes
)
} else {
richInputRef.current.applyPlainSuggestion(
suggestion.text,
autocompletePrefixes
)
}
setTimeout(() => {
richInputRef.current?.focus()
}, 0)
haptic('light')
return
}
if (!textareaRef.current) return
const result = applySuggestion(
inputState.text,
inputState.selection,
@@ -434,7 +473,7 @@ export function HappyComposer(props: {
}, 0)
haptic('light')
}, [api, suggestions, inputState, autocompletePrefixes, haptic])
}, [api, suggestions, inputState, autocompletePrefixes, haptic, richMentionsEnabled])
const abortDisabled = controlsDisabled || isAborting || !threadIsRunning
const switchDisabled = controlsDisabled || isSwitching || !controlledByUser
@@ -543,7 +582,15 @@ export function HappyComposer(props: {
[permissionModeOptions]
)
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
/** Flush rich chips → `[title](/sessions/<id>)` into composer.text, then send. */
const flushAndSend = useCallback(() => {
if (richMentionsEnabled && richInputRef.current) {
richInputRef.current.flushSerializedText()
}
api.composer().send()
}, [api, richMentionsEnabled])
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLTextAreaElement | HTMLDivElement>) => {
const key = e.key
// Avoid intercepting IME composition keystrokes (Enter, arrows, etc.)
@@ -551,9 +598,9 @@ export function HappyComposer(props: {
return
}
// Shift+Enter inserts a newline (standard behavior)
// Shift+Enter inserts a newline (textarea default; rich path inserts <br>).
if (key === 'Enter' && e.shiftKey) {
return // let default textarea behavior handle newline
return
}
// Enter with suggestions visible: select the suggestion
@@ -569,14 +616,14 @@ export function HappyComposer(props: {
if (composerEnterBehavior === 'newline') {
if ((e.ctrlKey || e.metaKey) && !e.altKey && canSend) {
e.preventDefault()
api.composer().send()
flushAndSend()
setShowContinueHint(false)
}
return
}
e.preventDefault()
if (!e.ctrlKey && !e.altKey && !e.metaKey && canSend) {
api.composer().send()
flushAndSend()
setShowContinueHint(false)
}
return
@@ -635,7 +682,9 @@ export function HappyComposer(props: {
canSend,
api,
haptic,
composerEnterBehavior
composerEnterBehavior,
richMentionsEnabled,
flushAndSend,
])
useEffect(() => {
@@ -678,7 +727,7 @@ export function HappyComposer(props: {
}))
}, [])
const handlePaste = useCallback(async (e: ReactClipboardEvent<HTMLTextAreaElement>) => {
const handlePaste = useCallback(async (e: ReactClipboardEvent<HTMLTextAreaElement | HTMLDivElement>) => {
const files = Array.from(e.clipboardData?.files || [])
const imageFiles = files.filter(file => file.type.startsWith('image/'))
@@ -801,7 +850,7 @@ export function HappyComposer(props: {
const voiceEnabled = Boolean(onVoiceToggle)
const handleSend = useCallback(() => {
api.composer().send()
flushAndSend()
// SessionChat owns clearing the schedule — it clears only after awaiting
// the send hook's accepted result, which covers both pre-mutation guards
// and async inactive-session resume failure. Clearing here unconditionally
@@ -812,7 +861,7 @@ export function HappyComposer(props: {
// the route-level state (`onSuccess`/`onError` in router.tsx) replaces
// or clears it based on the actual mutation result, so the user keeps
// the error context while the new attempt is in flight.
}, [api])
}, [flushAndSend])
// Pi: selected model info for UI labels and thinking level filtering
const piModelLabel = agentFlavor === 'pi'
@@ -1326,20 +1375,39 @@ export function HappyComposer(props: {
) : null}
<div className="flex items-center px-4 py-3">
<ComposerPrimitive.Input
ref={textareaRef}
autoFocus={!controlsDisabled && !isTouch}
placeholder={showContinueHint ? t('misc.typeMessage') : t('misc.typeAMessage')}
disabled={controlsDisabled}
maxRows={5}
submitOnEnter={false}
cancelOnEscape={false}
onChange={handleChange}
onSelect={handleSelect}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className="flex-1 resize-none bg-transparent text-base leading-snug text-[var(--app-fg)] placeholder-[var(--app-hint)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
{richMentionsEnabled ? (
<RichComposerInput
ref={richInputRef}
value={composerText}
autoFocus={!controlsDisabled && !isTouch}
placeholder={showContinueHint ? t('misc.typeMessage') : t('misc.typeAMessage')}
disabled={controlsDisabled}
onValueChange={(text) => api.composer().setText(text)}
onMirrorChange={(state) => setInputState(state)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
resolveSessionMentionTooltip={resolveSessionMentionTooltip}
onEdit={() => {
if (sendError && onClearSendError) onClearSendError()
}}
className="max-h-[7.5rem] min-h-[1.5rem] flex-1 overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-base leading-snug text-[var(--app-fg)] focus:outline-none"
/>
) : (
<ComposerPrimitive.Input
ref={textareaRef}
autoFocus={!controlsDisabled && !isTouch}
placeholder={showContinueHint ? t('misc.typeMessage') : t('misc.typeAMessage')}
disabled={controlsDisabled}
maxRows={5}
submitOnEnter={false}
cancelOnEscape={false}
onChange={handleChange}
onSelect={handleSelect}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className="flex-1 resize-none bg-transparent text-base leading-snug text-[var(--app-fg)] placeholder-[var(--app-hint)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
)}
</div>
<ComposerButtons
@@ -0,0 +1,161 @@
import { afterEach, describe, expect, it } from 'vitest'
import { serializeComposerSegments } from '@/lib/composerSegments'
import {
insertLineBreakAtCaret,
mirrorOffsetFromPoint,
segmentsFromEditor,
} from './RichComposerInput'
const CARET_PAD = '\u200B'
function placeCaretAtEnd(root: HTMLElement, textNode: Text) {
const range = document.createRange()
range.setStart(textNode, textNode.textContent?.length ?? 0)
range.collapse(true)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
}
function placeCaretInText(textNode: Text, offset: number) {
const range = document.createRange()
range.setStart(textNode, offset)
range.collapse(true)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
}
describe('segmentsFromEditor', () => {
it('preserves newlines between Chromium block divs (Enter-inserts-newline)', () => {
const root = document.createElement('div')
root.innerHTML = '<div>line1</div><div>line2</div>'
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('line1\nline2')
})
it('maps br to newlines', () => {
const root = document.createElement('div')
root.innerHTML = 'a<br>b'
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('a\nb')
})
it('preserves blank-line endings from renderSegments (br+br+pad)', () => {
// renderSegmentsToEditor maps "...\n\n" → text + <br> + <br> + ZWSP.
// Must not strip a real trailing blank line on re-serialize.
const root = document.createElement('div')
root.appendChild(document.createTextNode('a'))
root.appendChild(document.createElement('br'))
root.appendChild(document.createElement('br'))
root.appendChild(document.createTextNode('\u200B'))
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('a\n\n')
})
it('serializes Chromium-style LF text nodes without inventing extras', () => {
// plaintext-only insertLineBreak used to leave hello + \\n + \\n text nodes.
const root = document.createElement('div')
root.appendChild(document.createTextNode('hello'))
root.appendChild(document.createTextNode('\n'))
root.appendChild(document.createTextNode('\n'))
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\n\n')
})
it('strips caret-pad ZWSP used for trailing linebreak line-boxes', () => {
const root = document.createElement('div')
root.appendChild(document.createTextNode('a'))
root.appendChild(document.createElement('br'))
root.appendChild(document.createTextNode('\u200B'))
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('a\n')
})
it('keeps session atoms across block breaks', () => {
const root = document.createElement('div')
root.innerHTML =
'<div>see <span contenteditable="false" data-composer-mention="session" data-session-id="aaa" data-session-title="Peer A">@Peer A</span></div><div>next</div>'
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe(
'see [Peer A](/sessions/aaa)\nnext'
)
})
it('serializes chip with full UUID — never chip-visible @title alone', () => {
const sessionId = '7d55ed21-8a9f-4309-b4f8-30069df36b4b'
const title = 'hub runner version governance'
const root = document.createElement('div')
root.innerHTML =
`see <span contenteditable="false" data-composer-mention="session" data-session-id="${sessionId}" data-session-title="${title}">@${title}</span>`
const wire = serializeComposerSegments(segmentsFromEditor(root))
expect(wire).toBe(`see [${title}](/sessions/${sessionId})`)
expect(wire).toContain(sessionId)
expect(wire.includes(`@${title}`)).toBe(false)
})
it('drops orphan session chips missing data-session-id (no title-only wire)', () => {
const root = document.createElement('div')
root.innerHTML =
'see <span contenteditable="false" data-composer-mention="session" data-session-title="orphan">@orphan</span> x'
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('see x')
})
it('preserves newlines inside pasted wrapper blocks (nested p/li)', () => {
const root = document.createElement('div')
root.innerHTML = '<div><p>a</p><p>b</p></div>'
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('a\nb')
const list = document.createElement('div')
list.innerHTML = '<ul><li>one</li><li>two</li></ul>'
expect(serializeComposerSegments(segmentsFromEditor(list))).toBe('one\ntwo')
})
})
describe('insertLineBreakAtCaret', () => {
afterEach(() => {
document.body.replaceChildren()
window.getSelection()?.removeAllRanges()
})
it('inserts CARET_PAD after EOL break even when insertNode leaves an empty sibling', () => {
const root = document.createElement('div')
document.body.appendChild(root)
const hello = document.createTextNode('hello')
root.appendChild(hello)
placeCaretAtEnd(root, hello)
insertLineBreakAtCaret(root)
const texts = Array.from(root.childNodes).map((n) => n.textContent ?? '')
expect(texts).toContain(CARET_PAD)
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\n')
})
it('does not pad when there is meaningful content after the caret', () => {
const root = document.createElement('div')
document.body.appendChild(root)
const text = document.createTextNode('helloworld')
root.appendChild(text)
placeCaretInText(text, 5) // between hello|world
insertLineBreakAtCaret(root)
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\nworld')
expect(Array.from(root.childNodes).some((n) => n.textContent === CARET_PAD)).toBe(false)
})
})
describe('mirrorOffsetFromPoint', () => {
it('maps root-anchored caret before a leading chip to offset 0', () => {
const root = document.createElement('div')
root.innerHTML =
'<span contenteditable="false" data-composer-mention="session" data-session-id="aaa" data-session-title="Peer A">@Peer A</span> after'
expect(mirrorOffsetFromPoint(root, root, 0)).toBe(0)
expect(mirrorOffsetFromPoint(root, root, 1)).toBe(1)
})
it('matches segmentsFromEditor length for br-separated lines', () => {
const root = document.createElement('div')
root.innerHTML = 'a<br>b'
const mirrorLen = serializeComposerSegments(segmentsFromEditor(root)).length
// caret after 'b' → end of second text node
const b = root.childNodes[2] as Text
expect(b.nodeType).toBe(Node.TEXT_NODE)
expect(mirrorOffsetFromPoint(root, b, b.textContent!.length)).toBe(mirrorLen)
})
})
@@ -0,0 +1,865 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
type ClipboardEvent as ReactClipboardEvent,
type FormEvent as ReactFormEvent,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from 'react'
import { createPortal } from 'react-dom'
import {
COMPOSER_MENTION_MIRROR_CHAR,
coalesceComposerSegments,
deleteBackwardInComposerSegments,
insertPlainTextInComposerSegments,
insertSegmentsInComposerSegments,
insertSessionMentionInComposerSegments,
mirrorComposerSegments,
parseComposerSegments,
serializeComposerSegments,
serializeComposerSelection,
type ComposerSegment,
type ComposerSelection,
} from '@/lib/composerSegments'
import {
formatSessionMentionTooltip,
type SessionMentionTooltipModel,
} from '@/lib/sessionReference'
import { SessionRowSummary } from '@/components/SessionRowSummary'
import type { SessionSummary } from '@/types/api'
export type RichComposerInputHandle = {
focus: () => void
/**
* Re-read the contenteditable → serialize session chips to
* `[title](/sessions/<id>)` and push into composer state. Call before
* send so the agent prompt never gets chip-visible `@title` alone.
*/
flushSerializedText: () => string
insertSessionMention: (
mention: { id: string; title: string },
prefixes?: string[]
) => { text: string; selection: ComposerSelection }
applyPlainSuggestion: (
suggestionText: string,
prefixes?: string[]
) => { text: string; selection: ComposerSelection }
}
export type SessionMentionResolveResult = {
model: SessionMentionTooltipModel
/** Live row for sidebar-parity chip tooltip; null → fallback text tip. */
session: SessionSummary | null
}
type ResolveSessionMentionTooltip = (
id: string,
title: string
) => SessionMentionResolveResult
type Props = {
value: string
disabled?: boolean
placeholder?: string
className?: string
autoFocus?: boolean
onValueChange: (value: string) => void
onMirrorChange: (state: { text: string; selection: ComposerSelection }) => void
onKeyDown?: (e: ReactKeyboardEvent<HTMLDivElement>) => void
onPaste?: (e: ReactClipboardEvent<HTMLDivElement>) => void
onEdit?: () => void
/** Live session meta for chip hover / aria-label (from useSessions). */
resolveSessionMentionTooltip?: ResolveSessionMentionTooltip
}
type MentionTooltipState = {
model: SessionMentionTooltipModel
session: SessionSummary | null
top: number
left: number
}
function createMentionSpan(
id: string,
title: string,
resolveTooltip?: ResolveSessionMentionTooltip
): HTMLSpanElement {
const span = document.createElement('span')
span.contentEditable = 'false'
span.dataset.sessionId = id
span.dataset.sessionTitle = title
span.dataset.composerMention = 'session'
span.className =
'mx-0.5 inline-flex max-w-[12rem] items-center truncate rounded-md bg-[var(--app-subtle-bg)] px-1.5 py-0.5 align-baseline text-[0.95em] font-medium text-[var(--app-link)]'
span.textContent = `@${title || id.slice(0, 8)}`
const tip = resolveTooltip?.(id, title)?.model
?? formatSessionMentionTooltip(null, title, id)
span.setAttribute('aria-label', tip.ariaLabel)
return span
}
const BLOCK_TAGS = new Set(['DIV', 'P', 'LI', 'TR', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'PRE'])
/** Zero-width pad so a trailing linebreak keeps a caret line-box (pre-wrap / br). */
const CARET_PAD = '\u200B'
function stripCaretPad(text: string): string {
return text.replaceAll(CARET_PAD, '')
}
/** Exported for unit tests — maps contenteditable DOM → composer segments. */
export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] {
const segments: ComposerSegment[] = []
let pendingBlockBreak = false
const pushText = (text: string) => {
const cleaned = stripCaretPad(text)
if (!cleaned) return
segments.push({ type: 'text', text: cleaned })
}
const pushNewlineIfNeeded = () => {
if (!pendingBlockBreak) return
if (segments.length === 0) {
pendingBlockBreak = false
return
}
pushText('\n')
pendingBlockBreak = false
}
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
pushNewlineIfNeeded()
pushText(node.textContent ?? '')
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node as HTMLElement
// Session chips are atomic. Never walk their visible `@title` text —
// that would strip the id from the agent prompt on send.
if (el.dataset.composerMention === 'session') {
pushNewlineIfNeeded()
const id = el.dataset.sessionId?.trim()
if (id) {
segments.push({
type: 'session',
id,
title: el.dataset.sessionTitle || id.slice(0, 8),
})
}
// Orphan chip (missing id): drop it rather than emit title-only.
return
}
if (el.tagName === 'BR') {
pushNewlineIfNeeded()
pushText('\n')
return
}
const isBlock = BLOCK_TAGS.has(el.tagName)
// Any block after existing content (Chrome Enter, pasted <p>/<li>, nested
// wrappers) → newline. Depth-agnostic so paste wrappers do not collapse.
if (isBlock && segments.length > 0) {
pendingBlockBreak = true
}
for (const child of Array.from(el.childNodes)) {
walk(child)
}
if (isBlock) {
pendingBlockBreak = true
}
}
for (const child of Array.from(root.childNodes)) {
walk(child)
}
return coalesceComposerSegments(segments)
}
/**
* True when some node after `from` carries mirror-visible content.
* Range.insertNode splits the caret's text node, so a bare `\n` at EOL always
* has an empty Text nextSibling — `!nextSibling` is the wrong at-end test.
*/
function hasMeaningfulTrailingAfter(from: Node): boolean {
for (let n: Node | null = from.nextSibling; n; n = n.nextSibling) {
if (n.nodeType === Node.TEXT_NODE) {
if (stripCaretPad(n.textContent ?? '')) return true
continue
}
return true
}
return false
}
/**
* Insert a single mirror newline at the caret. Prefer this over execCommand
* ('insertLineBreak'): in plaintext-only / pre-wrap Chromium inserts two `\n`
* text nodes (placeholder), which serializes as `\n\n` on the wire.
* Manual `\n` + CARET_PAD gives the same line-box height and serializes once.
* Exported for jsdom coverage of the EOL pad path.
*/
export function insertLineBreakAtCaret(root: HTMLElement): void {
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) return
root.focus()
const range = sel.getRangeAt(0)
range.deleteContents()
const nl = document.createTextNode('\n')
range.insertNode(nl)
if (!hasMeaningfulTrailingAfter(nl)) {
const pad = document.createTextNode(CARET_PAD)
nl.parentNode?.insertBefore(pad, nl.nextSibling)
range.setStart(pad, pad.length)
} else {
range.setStart(nl, nl.length)
}
range.collapse(true)
sel.removeAllRanges()
sel.addRange(range)
}
function renderSegmentsToEditor(
root: HTMLElement,
segments: readonly ComposerSegment[],
resolveTooltip?: ResolveSessionMentionTooltip
) {
root.replaceChildren()
for (const segment of segments) {
if (segment.type === 'text') {
const parts = segment.text.split('\n')
parts.forEach((part, index) => {
if (part) root.appendChild(document.createTextNode(part))
if (index < parts.length - 1) root.appendChild(document.createElement('br'))
})
// Trailing newline needs a caret target or the new line is invisible.
if (segment.text.endsWith('\n')) {
root.appendChild(document.createTextNode(CARET_PAD))
}
continue
}
root.appendChild(createMentionSpan(segment.id, segment.title, resolveTooltip))
}
if (root.childNodes.length === 0) {
root.appendChild(document.createTextNode(''))
}
}
/** Exported for unit tests — maps a DOM caret point into mirror-string offset. */
export function mirrorOffsetFromPoint(root: HTMLElement, endContainer: Node, endOffset: number): number {
let count = 0
const visit = (n: Node): boolean => {
if (n === endContainer && n.nodeType === Node.TEXT_NODE) {
const raw = n.textContent ?? ''
count += stripCaretPad(raw.slice(0, endOffset)).length
return true
}
if (n.nodeType === Node.TEXT_NODE) {
count += stripCaretPad(n.textContent ?? '').length
return false
}
if (n.nodeType !== Node.ELEMENT_NODE) return false
const el = n as HTMLElement
if (el.dataset.composerMention === 'session') {
if (n === endContainer) {
count += endOffset > 0 ? 1 : 0
return true
}
count += 1
return false
}
if (el.tagName === 'BR') {
if (n === endContainer) return true
count += 1
return false
}
if (n === endContainer) {
const children = Array.from(n.childNodes)
for (let i = 0; i < endOffset && i < children.length; i++) {
if (visit(children[i]!)) return true
}
return true
}
for (const child of Array.from(n.childNodes)) {
if (visit(child)) return true
}
return false
}
// Root-anchored ranges (caret before a leading chip, select-all) report
// endContainer === root; visit only children of that offset.
if (endContainer === root) {
const children = Array.from(root.childNodes)
for (let i = 0; i < endOffset && i < children.length; i++) {
visit(children[i]!)
}
return count
}
for (const child of Array.from(root.childNodes)) {
if (visit(child)) break
}
return count
}
function getMirrorSelection(root: HTMLElement): ComposerSelection {
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) {
const len = mirrorComposerSegments(segmentsFromEditor(root)).length
return { start: len, end: len }
}
const range = sel.getRangeAt(0)
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) {
const len = mirrorComposerSegments(segmentsFromEditor(root)).length
return { start: len, end: len }
}
const start = mirrorOffsetFromPoint(root, range.startContainer, range.startOffset)
const end = mirrorOffsetFromPoint(root, range.endContainer, range.endOffset)
return { start: Math.min(start, end), end: Math.max(start, end) }
}
function setMirrorSelection(root: HTMLElement, selection: ComposerSelection) {
const target = Math.max(0, selection.start)
let remaining = target
const sel = window.getSelection()
if (!sel) return
const place = (node: Node, offset: number) => {
const range = document.createRange()
range.setStart(node, offset)
range.collapse(true)
sel.removeAllRanges()
sel.addRange(range)
}
const walk = (n: Node): boolean => {
if (n.nodeType === Node.TEXT_NODE) {
const raw = n.textContent ?? ''
// Caret-pad ZWSP is not part of the mirror; still a valid caret target.
if (raw === CARET_PAD) {
if (remaining === 0) {
place(n, raw.length)
return true
}
return false
}
const cleaned = stripCaretPad(raw)
if (remaining <= cleaned.length) {
// Map cleaned offset back into raw (pads have mirror width 0).
let cleanedSeen = 0
let rawOffset = 0
while (rawOffset < raw.length && cleanedSeen < remaining) {
if (raw[rawOffset] !== CARET_PAD) cleanedSeen += 1
rawOffset += 1
}
place(n, rawOffset)
return true
}
remaining -= cleaned.length
return false
}
if (n.nodeType !== Node.ELEMENT_NODE) return false
const el = n as HTMLElement
if (el.dataset.composerMention === 'session') {
const parent = el.parentNode
if (!parent) return true
const index = Array.from(parent.childNodes).indexOf(el)
if (remaining === 0) {
place(parent, index)
return true
}
if (remaining === 1) {
place(parent, index + 1)
return true
}
remaining -= 1
return false
}
if (el.tagName === 'BR') {
const parent = el.parentNode
if (!parent) return true
if (remaining === 0) {
place(parent, Array.from(parent.childNodes).indexOf(el))
return true
}
remaining -= 1
return false
}
for (const child of Array.from(n.childNodes)) {
if (walk(child)) return true
}
return false
}
for (const child of Array.from(root.childNodes)) {
if (walk(child)) return
}
place(root, root.childNodes.length)
}
const MENTION_TOOLTIP_DELAY_MS = 300
/** Lazily probed once — Firefox <136 treats unknown values as inherit (not editable). */
let supportsPlaintextOnlyCached: boolean | null = null
function supportsPlaintextOnly(): boolean {
if (supportsPlaintextOnlyCached !== null) return supportsPlaintextOnlyCached
if (typeof document === 'undefined') {
supportsPlaintextOnlyCached = false
return false
}
try {
const probe = document.createElement('div')
probe.contentEditable = 'plaintext-only'
supportsPlaintextOnlyCached = probe.contentEditable === 'plaintext-only'
} catch {
supportsPlaintextOnlyCached = false
}
return supportsPlaintextOnlyCached
}
function contentEditableValue(disabled: boolean): boolean | 'plaintext-only' {
if (disabled) return false
return supportsPlaintextOnly() ? 'plaintext-only' : true
}
export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(function RichComposerInput(
{
value,
disabled = false,
placeholder,
className,
autoFocus = false,
onValueChange,
onMirrorChange,
onKeyDown,
onPaste,
onEdit,
resolveSessionMentionTooltip,
},
ref
) {
const rootRef = useRef<HTMLDivElement>(null)
// null until first sync/emit so mount-time `value` always paints into the DOM.
const lastEmittedRef = useRef<string | null>(null)
const composingRef = useRef(false)
const tooltipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hoveredChipRef = useRef<HTMLElement | null>(null)
const [mentionTooltip, setMentionTooltip] = useState<MentionTooltipState | null>(null)
const clearMentionTooltip = useCallback(() => {
if (tooltipTimerRef.current) {
clearTimeout(tooltipTimerRef.current)
tooltipTimerRef.current = null
}
hoveredChipRef.current = null
setMentionTooltip(null)
}, [])
const emitFromDom = useCallback(() => {
const root = rootRef.current
if (!root) return
const segments = segmentsFromEditor(root)
const serialized = serializeComposerSegments(segments)
const selection = getMirrorSelection(root)
const mirror = mirrorComposerSegments(segments)
lastEmittedRef.current = serialized
onValueChange(serialized)
onMirrorChange({ text: mirror, selection })
}, [onMirrorChange, onValueChange])
const syncFromValue = useCallback((next: string, selection?: ComposerSelection) => {
const root = rootRef.current
if (!root) return
const segments = parseComposerSegments(next)
renderSegmentsToEditor(root, segments, resolveSessionMentionTooltip)
lastEmittedRef.current = next
clearMentionTooltip()
const mirror = mirrorComposerSegments(segments)
const sel = selection ?? { start: mirror.length, end: mirror.length }
// Placing a Selection inside contenteditable focuses it in Blink/WebKit —
// skip when the editor is not already focused (draft restore / queue edit).
const hadFocus = root.contains(document.activeElement)
if (hadFocus || selection) {
setMirrorSelection(root, sel)
}
onMirrorChange({ text: mirror, selection: sel })
}, [clearMentionTooltip, onMirrorChange, resolveSessionMentionTooltip])
useLayoutEffect(() => {
if (value === lastEmittedRef.current) return
syncFromValue(value)
}, [value, syncFromValue])
useEffect(() => {
if (!autoFocus || disabled) return
const root = rootRef.current
if (!root) return
try {
root.focus({ preventScroll: true })
} catch {
root.focus()
}
}, [autoFocus, disabled])
useImperativeHandle(ref, () => ({
focus: () => {
rootRef.current?.focus()
},
flushSerializedText: () => {
const root = rootRef.current
if (!root) return value
const segments = segmentsFromEditor(root)
const serialized = serializeComposerSegments(segments)
lastEmittedRef.current = serialized
onValueChange(serialized)
onMirrorChange({
text: mirrorComposerSegments(segments),
selection: getMirrorSelection(root),
})
return serialized
},
insertSessionMention: (mention, prefixes = ['@', '/', '$']) => {
const root = rootRef.current
if (!root) {
return { text: value, selection: { start: value.length, end: value.length } }
}
const segments = segmentsFromEditor(root)
const selection = getMirrorSelection(root)
const result = insertSessionMentionInComposerSegments(segments, selection, mention, prefixes)
const serialized = serializeComposerSegments(result.segments)
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
lastEmittedRef.current = serialized
setMirrorSelection(root, result.selection)
onValueChange(serialized)
onMirrorChange({
text: mirrorComposerSegments(result.segments),
selection: result.selection,
})
return { text: serialized, selection: result.selection }
},
applyPlainSuggestion: (suggestionText, prefixes = ['@', '/', '$']) => {
const root = rootRef.current
if (!root) {
return { text: value, selection: { start: value.length, end: value.length } }
}
const segments = segmentsFromEditor(root)
const selection = getMirrorSelection(root)
const result = insertPlainTextInComposerSegments(segments, selection, suggestionText, prefixes)
const serialized = serializeComposerSegments(result.segments)
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
lastEmittedRef.current = serialized
setMirrorSelection(root, result.selection)
onValueChange(serialized)
onMirrorChange({
text: mirrorComposerSegments(result.segments),
selection: result.selection,
})
return { text: serialized, selection: result.selection }
},
}), [onMirrorChange, onValueChange, resolveSessionMentionTooltip, value])
useEffect(() => () => {
if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current)
}, [])
// While open, poll hit-test: contenteditable pointerout/relatedTarget is flaky
// (chip → prose / outside often never clears). elementFromPoint is the truth.
useEffect(() => {
if (!mentionTooltip) return
const onMove = (ev: PointerEvent) => {
if (ev.pointerType === 'touch') return
const chip = hoveredChipRef.current
if (!chip || !chip.isConnected) {
clearMentionTooltip()
return
}
const el = document.elementFromPoint(ev.clientX, ev.clientY)
if (!el || !chip.contains(el)) {
clearMentionTooltip()
}
}
const dismiss = () => clearMentionTooltip()
window.addEventListener('pointermove', onMove, { passive: true })
window.addEventListener('scroll', dismiss, { capture: true, passive: true })
window.addEventListener('resize', dismiss, { passive: true })
return () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('scroll', dismiss, true)
window.removeEventListener('resize', dismiss)
}
}, [mentionTooltip, clearMentionTooltip])
const showMentionTooltipForChip = useCallback((chip: HTMLElement) => {
const id = chip.dataset.sessionId
if (!id) return
const title = chip.dataset.sessionTitle || id.slice(0, 8)
const resolved = resolveSessionMentionTooltip?.(id, title)
const model = resolved?.model ?? formatSessionMentionTooltip(null, title, id)
chip.setAttribute('aria-label', model.ariaLabel)
hoveredChipRef.current = chip
if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current)
tooltipTimerRef.current = setTimeout(() => {
if (hoveredChipRef.current !== chip || !chip.isConnected) return
const rect = chip.getBoundingClientRect()
setMentionTooltip({
model,
session: resolved?.session ?? null,
top: rect.top - 8,
left: rect.left + rect.width / 2,
})
}, MENTION_TOOLTIP_DELAY_MS)
}, [resolveSessionMentionTooltip])
const handlePointerOver = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
// Touch: no bubble (matches HoverTooltip). Mouse/pen only.
if (e.pointerType === 'touch') return
const chip = (e.target as HTMLElement | null)?.closest?.(
'[data-composer-mention="session"]'
) as HTMLElement | null
if (!chip || !rootRef.current?.contains(chip)) {
// Over editor prose / empty space — dismiss any open chip tip.
if (hoveredChipRef.current) clearMentionTooltip()
return
}
if (hoveredChipRef.current === chip) return
showMentionTooltipForChip(chip)
}, [clearMentionTooltip, showMentionTooltipForChip])
const handlePointerLeave = useCallback(() => {
// Leaving the editor root entirely (does not fire for chip→prose moves).
clearMentionTooltip()
}, [clearMentionTooltip])
const handleInput = useCallback((_e: ReactFormEvent<HTMLDivElement>) => {
clearMentionTooltip()
if (composingRef.current) return
onEdit?.()
emitFromDom()
}, [clearMentionTooltip, emitFromDom, onEdit])
const insertPlainClipboardText = useCallback((text: string) => {
const root = rootRef.current
if (!root || !text) return
const segments = segmentsFromEditor(root)
const selection = getMirrorSelection(root)
// Parse wire markdown so `[title](/sessions/<id>)` paste restores chips
// (copy/cut put that format on the clipboard). Plain prose stays text.
const result = insertSegmentsInComposerSegments(
segments,
selection,
parseComposerSegments(text),
)
const serialized = serializeComposerSegments(result.segments)
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
lastEmittedRef.current = serialized
setMirrorSelection(root, result.selection)
onValueChange(serialized)
onMirrorChange({
text: mirrorComposerSegments(result.segments),
selection: result.selection,
})
onEdit?.()
}, [onEdit, onMirrorChange, onValueChange, resolveSessionMentionTooltip])
const handleCopyOrCut = useCallback((e: ReactClipboardEvent<HTMLDivElement>, cut: boolean) => {
const root = rootRef.current
if (!root) return
const segments = segmentsFromEditor(root)
const selection = getMirrorSelection(root)
const text = serializeComposerSelection(segments, selection)
if (text === null) return
e.preventDefault()
e.clipboardData.setData('text/plain', text)
if (!cut) return
clearMentionTooltip()
const result = deleteBackwardInComposerSegments(segments, selection)
const serialized = serializeComposerSegments(result.segments)
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
lastEmittedRef.current = serialized
setMirrorSelection(root, result.selection)
onValueChange(serialized)
onMirrorChange({
text: mirrorComposerSegments(result.segments),
selection: result.selection,
})
onEdit?.()
}, [
clearMentionTooltip,
onEdit,
onMirrorChange,
onValueChange,
resolveSessionMentionTooltip,
])
const handlePaste = useCallback((e: ReactClipboardEvent<HTMLDivElement>) => {
const files = Array.from(e.clipboardData?.files ?? [])
const hasImage = files.some((file) => file.type.startsWith('image/'))
if (hasImage) {
onPaste?.(e)
return
}
// Contenteditable default paste inserts HTML; nested blocks collapse in
// segmentsFromEditor without depth-aware breaks. Force plain text.
e.preventDefault()
insertPlainClipboardText(e.clipboardData?.getData('text/plain') ?? '')
}, [insertPlainClipboardText, onPaste])
// No onDrop: intercepting without caretRangeFromPoint appends at EOF / no-ops
// in-editor moves. Native CE drop + plaintext-only / paste path is enough for #1215.
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.nativeEvent.isComposing) {
onKeyDown?.(e)
return
}
if (e.key === 'Backspace' && !e.metaKey && !e.ctrlKey && !e.altKey) {
const root = rootRef.current
if (root) {
const segments = segmentsFromEditor(root)
const selection = getMirrorSelection(root)
const mirror = mirrorComposerSegments(segments)
const againstAtom =
selection.start === selection.end
&& selection.start > 0
&& mirror[selection.start - 1] === COMPOSER_MENTION_MIRROR_CHAR
if (againstAtom || selection.start !== selection.end) {
e.preventDefault()
clearMentionTooltip()
const result = deleteBackwardInComposerSegments(segments, selection)
const serialized = serializeComposerSegments(result.segments)
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
lastEmittedRef.current = serialized
setMirrorSelection(root, result.selection)
onValueChange(serialized)
onMirrorChange({
text: mirrorComposerSegments(result.segments),
selection: result.selection,
})
onEdit?.()
return
}
}
}
onKeyDown?.(e)
// Parent handles suggestion-select / send with preventDefault. If Enter
// was left alone (Shift+Enter, or Enter-inserts-newline mode), insert a
// <br> instead of letting Chromium split the editor into block <div>s
// that would collapse to "line1line2" on serialize.
// Any Enter the parent left unprevented (incl. Alt/Ctrl when !canSend) must
// become a <br> — never Chromium block <div>s (offset/serialize footguns).
if (!e.defaultPrevented && e.key === 'Enter') {
const root = rootRef.current
if (!root) return
e.preventDefault()
insertLineBreakAtCaret(root)
onEdit?.()
emitFromDom()
}
}, [
emitFromDom,
onEdit,
onKeyDown,
onMirrorChange,
onValueChange,
resolveSessionMentionTooltip,
clearMentionTooltip,
])
return (
<div className="relative min-w-0 flex-1">
{(!value || value.length === 0) && placeholder ? (
<div
aria-hidden
className="pointer-events-none absolute inset-0 text-base leading-snug text-[var(--app-hint)]"
>
{placeholder}
</div>
) : null}
<div
ref={rootRef}
role="textbox"
aria-multiline="true"
aria-label={placeholder}
aria-disabled={disabled || undefined}
// Prefer plaintext-only when the engine accepts it (Chrome/Safari/FF136+);
// handlePaste still forces text/plain for engines that keep HTML paste.
contentEditable={contentEditableValue(disabled)}
suppressContentEditableWarning
data-testid="rich-composer-input"
className={`${className ?? ''}${disabled ? ' cursor-not-allowed opacity-50' : ''}`}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPointerOver={handlePointerOver}
onPointerLeave={handlePointerLeave}
onCopy={(e) => handleCopyOrCut(e, false)}
onCut={(e) => handleCopyOrCut(e, true)}
onPaste={handlePaste}
onCompositionStart={() => {
composingRef.current = true
}}
onCompositionEnd={() => {
composingRef.current = false
onEdit?.()
emitFromDom()
}}
onKeyUp={() => {
const root = rootRef.current
if (!root || composingRef.current) return
const segments = segmentsFromEditor(root)
onMirrorChange({
text: mirrorComposerSegments(segments),
selection: getMirrorSelection(root),
})
}}
onMouseUp={() => {
const root = rootRef.current
if (!root) return
const segments = segmentsFromEditor(root)
onMirrorChange({
text: mirrorComposerSegments(segments),
selection: getMirrorSelection(root),
})
}}
/>
{mentionTooltip && typeof document !== 'undefined'
? createPortal(
<div
role="tooltip"
data-testid="rich-composer-mention-tooltip"
className="pointer-events-none fixed z-[80] w-[min(20rem,calc(100vw-1.5rem))] -translate-x-1/2 -translate-y-full rounded-lg border border-[var(--app-border)] bg-[var(--app-secondary-bg)] px-2.5 py-2 text-[var(--app-fg)] shadow-lg"
style={{ top: mentionTooltip.top, left: mentionTooltip.left }}
>
{mentionTooltip.session ? (
<SessionRowSummary
session={mentionTooltip.session}
showDetailedStatus
nestedTooltips={false}
/>
) : (
<>
<span className="block text-sm font-medium">
{mentionTooltip.model.title}
</span>
{mentionTooltip.model.lines.map((line) => (
<span
key={line}
className="mt-0.5 block break-words text-xs text-[var(--app-hint)]"
>
{line}
</span>
))}
</>
)}
</div>,
document.body
)
: null}
</div>
)
})
+42
View File
@@ -35,6 +35,12 @@ import { HappyThread } from '@/components/AssistantChat/HappyThread'
import { QueuedMessagesBar } from '@/components/AssistantChat/QueuedMessagesBar'
import { ScratchlistDrawer } from '@/components/AssistantChat/ScratchlistPanel'
import { useHubScratchlist } from '@/lib/use-hub-scratchlist'
import { useSessions } from '@/hooks/queries/useSessions'
import { getSessionTitle } from '@/lib/sessionTitle'
import { formatSessionMentionTooltip } from '@/lib/sessionReference'
import { classifySessionAttention, getSessionAttentionLabelKey } from '@/lib/sessionAttention'
import { getSessionLastSeenAt } from '@/lib/sessionLastSeen'
import { formatRelativeTime } from '@/lib/relativeTime'
import { ScratchlistMigrationBanner } from '@/components/AssistantChat/ScratchlistMigrationBanner'
import { useHappyRuntime } from '@/lib/assistant-runtime'
import type { OlderLoadOutcome } from '@/lib/message-window-store'
@@ -478,6 +484,41 @@ function SessionChatInner(props: SessionChatProps) {
const [cursorSelectedBase, setCursorSelectedBase] = useState('auto')
const lastSyncedCursorModelRef = useRef<string | null | undefined>(undefined)
const scratchlist = useHubScratchlist(props.session.id, props.api)
const { sessions: allSessions } = useSessions(props.api)
const resolveSessionMentionTooltip = useCallback((id: string, title: string) => {
const hit = allSessions.find((s) => s.id === id) ?? null
if (!hit) {
return {
model: formatSessionMentionTooltip(null, title, id),
session: null,
}
}
const attention = classifySessionAttention(hit, {
selected: false,
lastSeenAt: getSessionLastSeenAt(hit.id),
})
const attentionLabel = attention
? t(getSessionAttentionLabelKey(attention))
: null
return {
model: formatSessionMentionTooltip(
{
id: hit.id,
title: getSessionTitle(hit),
active: hit.active,
lifecycleState: hit.metadata?.lifecycleState ?? null,
path: hit.metadata?.path ?? null,
worktreePath: hit.metadata?.worktree?.worktreePath ?? null,
relativeTime: formatRelativeTime(hit.updatedAt, t),
thinking: hit.thinking,
attentionLabel,
},
title,
id
),
session: hit,
}
}, [allSessions, t])
const [scratchlistMode, setScratchlistMode] = useState(false)
// Mode resets across sessions implicitly: SessionChat is keyed by
// session.id at the public-export boundary, so a session switch
@@ -1369,6 +1410,7 @@ function SessionChatInner(props: SessionChatProps) {
<HappyComposer
key={`composer-${props.session.id}`}
sessionId={props.session.id}
resolveSessionMentionTooltip={resolveSessionMentionTooltip}
disabled={props.isSending}
pendingSchedule={pendingSchedule}
onSchedule={setPendingSchedule}
+16 -166
View File
@@ -8,27 +8,27 @@ import { SessionActionMenu } from '@/components/SessionActionMenu'
import { SessionExportDialog } from '@/components/SessionExportDialog'
import { RenameSessionDialog } from '@/components/RenameSessionDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { CopyIcon, CheckIcon, ScheduleIcon } from '@/components/icons'
import { CopyIcon, CheckIcon } from '@/components/icons'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation'
import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit'
import { AgentFlavorIcon } from '@/components/AgentFlavorIcon'
import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode'
import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly'
import { classifySessionAttention } from '@/lib/sessionAttention'
import { getSessionLastSeenAt } from '@/lib/sessionLastSeen'
import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator'
import { HoverTooltip, SESSION_ROW_TOOLTIP_FOCUS_CLASS, useSessionRowTooltipIds } from '@/components/HoverTooltip'
import { formatRelativeTime } from '@/lib/relativeTime'
import { formatScheduledTooltipDetail } from '@/lib/scheduledTime'
import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions'
import { useSessionRowTooltipIds } from '@/components/HoverTooltip'
import { subscribeCodexImportedSessions } from '@/lib/codexImportedSessions'
import { formatReopenError } from '@/lib/reopenError'
import { getSessionTitle } from '@/lib/sessionTitle'
import { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel'
import type { Machine } from '@/types/api'
import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth'
import { MachineFilterBar } from '@/components/MachineFilterBar'
import { useSessionListMachineFilter } from '@/hooks/useSessionListMachineFilter'
import { useCursorChatStoreStatus } from '@/hooks/queries/useCursorChatStoreStatus'
import { SessionRowSummary } from '@/components/SessionRowSummary'
export { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel'
type SessionGroup = {
key: string
@@ -416,42 +416,6 @@ function PlusIcon(props: { className?: string }) {
)
}
function LoaderIcon(props: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={props.className}>
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76" />
<line x1="16.24" y1="16.24" x2="19.07" y2="19.07" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24" />
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93" />
</svg>
)
}
function BulbIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12c.6.6 1 1.2 1 2h6c0-.8.4-1.4 1-2a7 7 0 0 0-4-12Z" />
</svg>
)
}
function ChevronIcon(props: { className?: string; collapsed?: boolean }) {
return (
<svg
@@ -495,28 +459,6 @@ function SessionPreviewArrowIcon(props: { direction: 'up' | 'down'; className?:
export { getSessionTitle } from '@/lib/sessionTitle'
export function getWorktreeSessionLabel(session: SessionSummary): string | null {
const worktree = session.metadata?.worktree
if (!worktree) {
return null
}
const name = worktree.name.trim()
if (name) {
return name
}
const path = (worktree.worktreePath ?? session.metadata?.path ?? '').replace(/[\\/]+$/, '')
const parts = path.split(/[\\/]+/).filter(Boolean)
return parts.at(-1) ?? null
}
function getTodoProgress(session: SessionSummary): { completed: number; total: number } | null {
if (!session.todoProgress) return null
if (session.todoProgress.completed === session.todoProgress.total) return null
return session.todoProgress
}
export function normalizeSearch(value: string | null | undefined): string {
return (value ?? '').trim().toLowerCase()
}
@@ -762,40 +704,6 @@ function SessionListSearch(props: {
)
}
function formatCodexImportedRelativeTime(
value: number,
t: (key: string, params?: Record<string, string | number>) => string
): string | null {
const ms = value < 1_000_000_000_000 ? value * 1000 : value
if (!Number.isFinite(ms)) return null
const delta = Date.now() - ms
if (delta < 60_000) return t('session.time.importedFromCodex.justNow')
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) return t('session.time.importedFromCodex.minutesAgo', { n: minutes })
const hours = Math.floor(minutes / 60)
if (hours < 24) return t('session.time.importedFromCodex.hoursAgo', { n: hours })
const days = Math.floor(hours / 24)
if (days < 7) return t('session.time.importedFromCodex.daysAgo', { n: days })
return formatRelativeTime(value, t)
}
function getSessionTimeLabel(
session: SessionSummary,
t: (key: string, params?: Record<string, string | number>) => string
): string | null {
const codexSessionId = session.metadata?.agentSessionId
const importedAt = session.metadata?.flavor === 'codex'
? getCodexImportedAt(codexSessionId)
: null
// 中文注释:导入标记存在时优先显示“xx 前从 Codex 客户端导入”;等用户在 Hapi 里继续发消息后,再由发送逻辑清除该标记。
if (importedAt !== null) {
return formatCodexImportedRelativeTime(importedAt, t)
}
return formatRelativeTime(session.updatedAt, t)
}
function SessionItem(props: {
session: SessionSummary
onSelect: (sessionId: string) => void
@@ -866,8 +774,6 @@ function SessionItem(props: {
})
const sessionName = getSessionTitle(s)
const worktreeLabel = getWorktreeSessionLabel(s)
const todoProgress = getTodoProgress(s)
const attention = useMemo(
() => showDetailedStatus
? classifySessionAttention(s, {
@@ -877,10 +783,6 @@ function SessionItem(props: {
: null,
[s, selected, showDetailedStatus]
)
const attentionLabel = attention ? getAttentionLabel(attention, t) : null
const scheduledLabel = s.futureScheduledMessageCount > 1
? t('session.item.scheduledMessages', { count: s.futureScheduledMessageCount })
: t('session.item.scheduledMessage')
const hasScheduleTooltip = showDetailedStatus && s.futureScheduledMessageCount > 0
const { attentionId, scheduleId, describedBy } = useSessionRowTooltipIds(
Boolean(attention),
@@ -896,67 +798,15 @@ function SessionItem(props: {
aria-current={selected ? 'page' : undefined}
aria-describedby={describedBy}
>
<div className={`flex items-center justify-between gap-3 ${!s.active ? 'opacity-50' : ''}`}>
<div className="flex items-center gap-2 min-w-0">
<AgentFlavorIcon flavor={s.metadata?.flavor} className="h-4 w-4 shrink-0 -translate-y-px" />
<div className={`truncate text-sm font-medium ${s.active ? 'text-[var(--app-fg)]' : 'text-[var(--app-hint)]'}`}>
{sessionName}
</div>
{s.active && s.thinking ? (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 text-[var(--app-hint)] animate-spin-slow" />
) : attention ? (
<SessionAttentionIndicator
attention={attention}
summary={s}
label={attentionLabel ?? ''}
tooltipId={attentionId!}
/>
) : null}
{hasScheduleTooltip ? (
<HoverTooltip
id={scheduleId!}
target={<ScheduleIcon className="h-3.5 w-3.5 text-[var(--app-hint)]" />}
side="bottom"
align="start"
className="shrink-0"
revealOnParentFocusClass={SESSION_ROW_TOOLTIP_FOCUS_CLASS}
>
<span className="block">
<span className="block font-medium">{scheduledLabel}</span>
<span className="mt-1 block text-[var(--app-hint)]">
{formatScheduledTooltipDetail(s, t)}
</span>
</span>
</HoverTooltip>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
{todoProgress ? (
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<BulbIcon className="h-3 w-3" />
{todoProgress.completed}/{todoProgress.total}
</span>
) : null}
{!attention && s.pendingRequestsCount > 0 ? (
<span className="text-[var(--app-badge-warning-text)]">
{t('session.item.pending')} {s.pendingRequestsCount}
</span>
) : null}
<span className="tabular-nums text-[var(--app-hint)]">
{getSessionTimeLabel(s, t)}
</span>
</div>
</div>
{showPath || worktreeLabel ? (
<div
className="truncate text-xs text-[var(--app-hint)]"
title={worktreeLabel
? s.metadata?.worktree?.worktreePath ?? s.metadata?.path
: undefined}
>
{worktreeLabel ?? s.metadata?.path ?? s.id}
</div>
) : null}
<SessionRowSummary
session={s}
showPath={showPath}
showDetailedStatus={showDetailedStatus}
selected={selected}
nestedTooltips
attentionTooltipId={attentionId}
scheduleTooltipId={scheduleId}
/>
</button>
<SessionActionMenu
+226
View File
@@ -0,0 +1,226 @@
import { useMemo } from 'react'
import type { SessionSummary } from '@/types/api'
import { AgentFlavorIcon } from '@/components/AgentFlavorIcon'
import { ScheduleIcon } from '@/components/icons'
import { HoverTooltip, SESSION_ROW_TOOLTIP_FOCUS_CLASS, useSessionRowTooltipIds } from '@/components/HoverTooltip'
import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator'
import { classifySessionAttention } from '@/lib/sessionAttention'
import { getSessionLastSeenAt } from '@/lib/sessionLastSeen'
import { formatRelativeTime } from '@/lib/relativeTime'
import { formatScheduledTooltipDetail } from '@/lib/scheduledTime'
import { getCodexImportedAt } from '@/lib/codexImportedSessions'
import { getSessionTitle } from '@/lib/sessionTitle'
import { useTranslation } from '@/lib/use-translation'
import { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel'
function LoaderIcon(props: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={props.className}>
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76" />
<line x1="16.24" y1="16.24" x2="19.07" y2="19.07" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24" />
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93" />
</svg>
)
}
function BulbIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12c.6.6 1 1.2 1 2h6c0-.8.4-1.4 1-2a7 7 0 0 0-4-12Z" />
</svg>
)
}
const ATTENTION_DOT_CLASS = {
permission: 'bg-amber-500 animate-pulse',
input: 'bg-blue-500',
background: 'bg-blue-400',
unread: 'bg-[var(--app-link)]',
} as const
function getTodoProgress(session: SessionSummary): { completed: number; total: number } | null {
if (!session.todoProgress) return null
if (session.todoProgress.completed === session.todoProgress.total) return null
return session.todoProgress
}
function formatCodexImportedRelativeTime(
value: number,
t: (key: string, params?: Record<string, string | number>) => string
): string | null {
const ms = value < 1_000_000_000_000 ? value * 1000 : value
if (!Number.isFinite(ms)) return null
const delta = Date.now() - ms
if (delta < 60_000) return t('session.time.importedFromCodex.justNow')
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) return t('session.time.importedFromCodex.minutesAgo', { n: minutes })
const hours = Math.floor(minutes / 60)
if (hours < 24) return t('session.time.importedFromCodex.hoursAgo', { n: hours })
const days = Math.floor(hours / 24)
if (days < 7) return t('session.time.importedFromCodex.daysAgo', { n: days })
return formatRelativeTime(value, t)
}
function getSessionTimeLabel(
session: SessionSummary,
t: (key: string, params?: Record<string, string | number>) => string
): string | null {
const importedAt = session.metadata?.flavor === 'codex'
? getCodexImportedAt(session.metadata?.agentSessionId)
: null
if (importedAt !== null) {
return formatCodexImportedRelativeTime(importedAt, t)
}
return formatRelativeTime(session.updatedAt, t)
}
/**
* Presentational session row — same chrome as the sidebar SessionItem body
* (flavor, title, thinking/attention, schedule, todos, relative time, path).
* Used by the session list and by rich-composer mention chip tooltips.
*/
export function SessionRowSummary(props: {
session: SessionSummary
showPath?: boolean
showDetailedStatus?: boolean
selected?: boolean
/**
* When false, attention is a bare colored dot (no nested HoverTooltip).
* Use false inside an already-open chip tooltip portal.
*/
nestedTooltips?: boolean
/** Pass from parent when the parent owns `aria-describedby` (session list). */
attentionTooltipId?: string
scheduleTooltipId?: string
className?: string
}) {
const {
session: s,
showPath = true,
showDetailedStatus = true,
selected = false,
nestedTooltips = true,
attentionTooltipId: attentionTooltipIdProp,
scheduleTooltipId: scheduleTooltipIdProp,
className,
} = props
const { t } = useTranslation()
const sessionName = getSessionTitle(s)
const worktreeLabel = getWorktreeSessionLabel(s)
const todoProgress = getTodoProgress(s)
const attention = useMemo(
() => showDetailedStatus
? classifySessionAttention(s, {
selected,
lastSeenAt: getSessionLastSeenAt(s.id),
})
: null,
[s, selected, showDetailedStatus]
)
const attentionLabel = attention ? getAttentionLabel(attention, t) : null
const scheduledLabel = s.futureScheduledMessageCount > 1
? t('session.item.scheduledMessages', { count: s.futureScheduledMessageCount })
: t('session.item.scheduledMessage')
const hasScheduleTooltip = showDetailedStatus && s.futureScheduledMessageCount > 0
const ownedIds = useSessionRowTooltipIds(
Boolean(attention) && nestedTooltips && !attentionTooltipIdProp,
hasScheduleTooltip && nestedTooltips && !scheduleTooltipIdProp
)
const attentionId = attentionTooltipIdProp ?? ownedIds.attentionId
const scheduleId = scheduleTooltipIdProp ?? ownedIds.scheduleId
const timeLabel = getSessionTimeLabel(s, t)
return (
<div className={`flex w-full min-w-0 flex-col gap-1 ${className ?? ''}`}>
<div className={`flex items-center justify-between gap-3 ${!s.active ? 'opacity-50' : ''}`}>
<div className="flex min-w-0 items-center gap-2">
<AgentFlavorIcon flavor={s.metadata?.flavor} className="h-4 w-4 shrink-0 -translate-y-px" />
<div className={`truncate text-sm font-medium ${s.active ? 'text-[var(--app-fg)]' : 'text-[var(--app-hint)]'}`}>
{sessionName}
</div>
{s.active && s.thinking ? (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin-slow text-[var(--app-hint)]" />
) : attention && nestedTooltips && attentionId ? (
<SessionAttentionIndicator
attention={attention}
summary={s}
label={attentionLabel ?? ''}
tooltipId={attentionId}
/>
) : attention ? (
<span
className={`inline-flex h-2 w-2 shrink-0 rounded-full ${ATTENTION_DOT_CLASS[attention.kind]}`}
title={attentionLabel ?? undefined}
aria-label={attentionLabel ?? undefined}
/>
) : null}
{hasScheduleTooltip && nestedTooltips && scheduleId ? (
<HoverTooltip
id={scheduleId}
target={<ScheduleIcon className="h-3.5 w-3.5 text-[var(--app-hint)]" />}
side="bottom"
align="start"
className="shrink-0"
revealOnParentFocusClass={SESSION_ROW_TOOLTIP_FOCUS_CLASS}
>
<span className="block">
<span className="block font-medium">{scheduledLabel}</span>
<span className="mt-1 block text-[var(--app-hint)]">
{formatScheduledTooltipDetail(s, t)}
</span>
</span>
</HoverTooltip>
) : hasScheduleTooltip ? (
<span className="shrink-0" aria-label={scheduledLabel} title={scheduledLabel}>
<ScheduleIcon className="h-3.5 w-3.5 text-[var(--app-hint)]" />
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2 text-xs">
{todoProgress ? (
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<BulbIcon className="h-3 w-3" />
{todoProgress.completed}/{todoProgress.total}
</span>
) : null}
{!attention && s.pendingRequestsCount > 0 ? (
<span className="text-[var(--app-badge-warning-text)]">
{t('session.item.pending')} {s.pendingRequestsCount}
</span>
) : null}
{timeLabel ? (
<span className="tabular-nums text-[var(--app-hint)]">{timeLabel}</span>
) : null}
</div>
</div>
{showPath || worktreeLabel ? (
<div
className="truncate text-xs text-[var(--app-hint)]"
title={worktreeLabel
? s.metadata?.worktree?.worktreePath ?? s.metadata?.path
: undefined}
>
{worktreeLabel ?? s.metadata?.path ?? s.id}
</div>
) : null}
</div>
)
}