mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
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:
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user