Native chat: Cmd/Ctrl+F finds in the chat (#26517)

* Native chat: Cmd/Ctrl+F opens find in window

Mod+F inside a focused native chat opens a find bar at the chat's top-right
corner. The bar is a separate sandboxed view stacked over the main window, so
its own input is never a match; main runs the window's find-in-page and relays
the match count back. Enter/Shift+Enter step, Escape closes and clears.

Also stops the terminal search bar from opening over a chat that covers a
terminal pane.

* Native chat: Cmd/Ctrl+F finds inside the chat transcript

Replaces the window-level find panel with a find bar inside the chat.
The panel searched the whole window, pasted into the composer, outlived
its chat, and let Escape interrupt the agent.

- Searches only the transcript text the reader can see and paints matches
  with CSS highlights, reusing the markdown preview's search, now shared
  as lib/dom-text-search-highlights.ts.
- Re-searches as the transcript streams or scrolls, keeping the current
  match; a step leaves "follow latest" like a reader scroll.
- Escape closes the bar first and returns focus; paste and editing keys
  stay in the bar's input.
- Terminal guard yields any chat.find chord inside a chat cover; a PDF
  no longer takes Mod+F from another surface.

* Native chat find: round-2 fixes

- Escape: the chat root's React capture handler closes the bar before the
  composer's own capture handler can interrupt the turn.
- One visibility rule for picking and revealing a match: typing no longer
  scrolls to a match that is already on screen, and the bar's real rect,
  not a full-width band, decides what it covers.
- Reveal scrolls nested scroll boxes (code lines, tool output, thinking);
  text cut off by a box that cannot scroll is not counted.
- PDF find opens from its own tab and the file explorer again, but not
  from another group, a chat, or a text field.
- Prompt cards no longer take focus from the find input; clicks on the
  bar keep typing in it; buttons keep their own Enter.
- A match whose row scrolled away restarts from the first visible match.
- Streamed text growth re-searches every 120 ms instead of every frame,
  and searches reuse their Range objects.
- Hover-only row chrome is marked and skipped instead of read by opacity.
- Chat find stays off in the web client, where the browser's find keeps
  Mod+F.
- The text matcher moves to lib/text-match-ranges.ts with neutral names.

* Native chat find: round-3 fixes

- Text that held a match re-searches before the next paint: React
  rewrites a growing text node whole, collapsing its ranges, so the
  streaming paragraph's highlights no longer blink. Other text growth
  keeps the 120 ms re-search.
- A disclosure's height animation ending re-searches, so an output
  opened while find is open is counted.
- A prompt card that arrived while the find bar held focus gets it when
  find closes.
- A reveal that cannot scroll no longer stops following the latest.
- PDF find follows the panel's chord owner (its tab is the active tab
  of the focused group, the rule the browser pane uses), so it no longer
  takes Mod+F from a chat, terminal or browser in another split, and it
  opens again after clicking its own editor header.

* Native chat find: PDF find answers only its own window surface

The floating workspace panel keeps its own focused group, so a PDF that
owns its panel's chords no longer takes Mod+F from a chat in the other
surface (main window vs floating panel), matching how the browser pane
scopes a floating workspace's chords.
This commit is contained in:
Brennan Benson
2026-10-08 19:51:44 -07:00
committed by GitHub
parent 820277c207
commit ebb192e3e5
37 changed files with 2376 additions and 225 deletions
@@ -9,10 +9,9 @@ import type {
} from 'pdfjs-dist/web/pdf_viewer.mjs'
import 'pdfjs-dist/web/pdf_viewer.css'
import PdfFind from './PdfFind'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { useAppStore } from '@/store'
import { keybindingMatchesAction } from '../../../../shared/keybindings'
import { usePdfViewerFindShortcut } from './use-pdf-viewer-find-shortcut'
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
import { translate } from '@/i18n/i18n'
@@ -60,6 +59,7 @@ function PdfDocumentViewer({
preferenceKey = null,
scrollCacheKey = null
}: PdfViewerProps): JSX.Element {
const rootRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const viewerDivRef = useRef<HTMLDivElement>(null)
const loaderRef = useRef<PdfDocumentLoader | null>(null)
@@ -184,23 +184,13 @@ function PdfDocumentViewer({
)
}, [isCommandOwner, stepZoom, zoomReset])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
const platform = getShortcutPlatform()
if (keybindingMatchesAction('editor.find', e, platform, keybindings)) {
e.preventDefault()
e.stopPropagation()
setFindOpen(true)
}
}
window.addEventListener('keydown', handleKeyDown, true)
return () => window.removeEventListener('keydown', handleKeyDown, true)
}, [keybindings])
const openFind = useCallback(() => setFindOpen(true), [])
usePdfViewerFindShortcut({ rootRef, ownsCommands: isCommandOwner, keybindings, openFind })
const zoomPercent = Math.round(scale * 100)
return (
<div className="flex h-full min-h-0 flex-col">
<div ref={rootRef} className="flex h-full min-h-0 flex-col">
<div className="relative flex flex-1 flex-col overflow-hidden">
<PdfFind isOpen={findOpen} onClose={closeFindBar} eventBusRef={eventBusRef} />
{/* Why: PDFViewer requires its container to be position:absolute.
@@ -1,11 +1,16 @@
import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../shared/keybindings'
import { findTextMatchRanges, isMarkdownPreviewSearchQueryTooLarge } from './markdown-text-matches'
import {
createDomTextSearchHighlights,
findDomTextSearchRanges,
type DomTextSearchInstance,
type DomTextSearchScope
} from '../../lib/dom-text-search-highlights'
export {
findTextMatchRanges,
isMarkdownPreviewSearchQueryTooLarge,
MARKDOWN_PREVIEW_SEARCH_QUERY_MAX_BYTES,
isTextSearchQueryTooLarge as isMarkdownPreviewSearchQueryTooLarge,
TEXT_SEARCH_QUERY_MAX_BYTES as MARKDOWN_PREVIEW_SEARCH_QUERY_MAX_BYTES,
type TextMatchOptions
} from './markdown-text-matches'
} from '../../lib/text-match-ranges'
export function isMarkdownPreviewFindShortcut(
event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>,
@@ -23,128 +28,29 @@ export function isMarkdownPreviewReplaceShortcut(
return keybindingMatchesAction('editor.replace', event, platform, keybindings)
}
// Why: react-markdown owns the preview DOM. Injecting <mark> by splitting its
// text nodes (and normalize()-merging them on clear) left react holding stale
// child pointers, so the next streamed-content commit threw NotFoundError
// ("insertBefore ... not a child of this node"; crash 237acef1). Paint matches
// with the CSS Custom Highlight API instead — it highlights Ranges without
// mutating the DOM react manages. The static names below must match the
// Why: react-markdown owns the preview DOM; matches are painted as CSS highlights
// (see dom-text-search-highlights.ts). The static names below must match the
// ::highlight() selectors in markdown-preview.css.
const SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-match'
const ACTIVE_SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-active-match'
type HighlightLike = { add(range: Range): void }
type HighlightRegistryLike = {
set(name: string, highlight: HighlightLike): void
delete(name: string): void
}
// Accessed via globalThis so the code degrades to a no-op where the API is
// absent (older Chromium, jsdom/happy-dom in tests) — match counting and
// navigation still work off the returned Ranges; only the paint is skipped.
function getHighlightApi(): {
registry: HighlightRegistryLike
create: (ranges: readonly Range[]) => HighlightLike
} | null {
const scope = globalThis as {
CSS?: { highlights?: HighlightRegistryLike }
Highlight?: new () => HighlightLike
}
const registry = scope.CSS?.highlights
const HighlightCtor = scope.Highlight
if (!registry || typeof HighlightCtor !== 'function') {
return null
}
return {
registry,
// Why: build with .add() rather than new Highlight(...ranges). A big doc +
// short query yields 100k+ ranges, and spreading that many constructor
// args overflows V8's argument stack (RangeError) — the same large-content
// regime as the bug this file fixes.
create: (ranges) => {
const highlight = new HighlightCtor()
for (const range of ranges) {
highlight.add(range)
}
return highlight
}
}
}
// Why: CSS.highlights is a document-global registry keyed by a static name, but
// several MarkdownPreview instances can be open at once (split panes, floating
// window). Track each instance's ranges by its own token and paint the UNION,
// so a second preview's Find does not clobber the first's highlights. Ranges
// live in each instance's own subtree, so the union paints every pane correctly.
declare const markdownPreviewSearchInstanceBrand: unique symbol
const previewHighlights = createDomTextSearchHighlights({
match: 'markdown-preview-search-match',
active: 'markdown-preview-search-active-match'
})
/** Per-preview identity for the highlight maps; only compared by reference. */
export type MarkdownPreviewSearchInstance = {
readonly [markdownPreviewSearchInstanceBrand]?: never
}
export type MarkdownPreviewSearchInstance = DomTextSearchInstance
const searchRangesByInstance = new Map<MarkdownPreviewSearchInstance, readonly Range[]>()
const activeRangeByInstance = new Map<MarkdownPreviewSearchInstance, Range>()
// Avoid array spread when collecting union ranges — a large doc can produce
// 100k+ ranges and create()/registry writes must not build variadic arg lists.
function paintMatchHighlight(api: NonNullable<ReturnType<typeof getHighlightApi>>): void {
const matchRanges: Range[] = []
for (const ranges of searchRangesByInstance.values()) {
for (const range of ranges) {
matchRanges.push(range)
}
}
if (matchRanges.length > 0) {
api.registry.set(SEARCH_HIGHLIGHT_NAME, api.create(matchRanges))
} else {
api.registry.delete(SEARCH_HIGHLIGHT_NAME)
}
}
function paintActiveHighlight(api: NonNullable<ReturnType<typeof getHighlightApi>>): void {
const activeRanges: Range[] = []
for (const range of activeRangeByInstance.values()) {
activeRanges.push(range)
}
if (activeRanges.length > 0) {
api.registry.set(ACTIVE_SEARCH_HIGHLIGHT_NAME, api.create(activeRanges))
} else {
api.registry.delete(ACTIVE_SEARCH_HIGHLIGHT_NAME)
}
const DOCUMENT_ONLY_SCOPE: DomTextSearchScope = {
rejectElement: (element) =>
element.closest(
'.markdown-annotation-controls,[data-orca-export-hide],.code-block-copy-btn,.mermaid-block'
) !== null,
joinedTextSelector: 'code'
}
export function clearMarkdownPreviewSearchHighlights(
instanceId: MarkdownPreviewSearchInstance
): void {
searchRangesByInstance.delete(instanceId)
activeRangeByInstance.delete(instanceId)
const api = getHighlightApi()
if (api) {
paintMatchHighlight(api)
paintActiveHighlight(api)
}
}
function appendTextSearchRanges(nodes: Text[], query: string, ranges: Range[]): void {
const text = nodes.map((node) => node.data).join('')
if (!text.trim()) {
return
}
let nodeIndex = 0
let offset = 0
for (const { start, end } of findTextMatchRanges(text, query)) {
while (nodeIndex < nodes.length - 1 && offset + nodes[nodeIndex].length <= start) {
offset += nodes[nodeIndex++].length
}
const range = document.createRange()
range.setStart(nodes[nodeIndex], start - offset)
while (nodeIndex < nodes.length - 1 && offset + nodes[nodeIndex].length < end) {
offset += nodes[nodeIndex++].length
}
range.setEnd(nodes[nodeIndex], end - offset)
ranges.push(range)
}
previewHighlights.clear(instanceId)
}
export function applyMarkdownPreviewSearchHighlights(
@@ -153,65 +59,12 @@ export function applyMarkdownPreviewSearchHighlights(
query: string,
options: { documentOnly?: boolean } = {}
): Range[] {
const ranges: Range[] = []
if (query && !isMarkdownPreviewSearchQueryTooLarge(query)) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!(node.parentElement instanceof HTMLElement)) {
return NodeFilter.FILTER_REJECT
}
if (
options.documentOnly &&
node.parentElement.closest(
'.markdown-annotation-controls,[data-orca-export-hide],.code-block-copy-btn,.mermaid-block'
)
) {
return NodeFilter.FILTER_REJECT
}
if (
!node.textContent?.trim() &&
!(options.documentOnly && node.parentElement.closest('code'))
) {
return NodeFilter.FILTER_REJECT
}
return NodeFilter.FILTER_ACCEPT
}
})
let currentNode = walker.nextNode()
while (currentNode) {
if (!(currentNode instanceof Text)) {
currentNode = walker.nextNode()
continue
}
let code = options.documentOnly ? currentNode.parentElement?.closest('code') : null
while (code) {
const parentCode = code.parentElement?.closest('code')
if (!parentCode || !root.contains(parentCode)) {
break
}
code = parentCode
}
const nodes = [currentNode]
let next = walker.nextNode()
while (code && next instanceof Text && code.contains(next)) {
nodes.push(next)
next = walker.nextNode()
}
appendTextSearchRanges(nodes, query, ranges)
currentNode = next
}
}
searchRangesByInstance.set(instanceId, ranges)
activeRangeByInstance.delete(instanceId)
const api = getHighlightApi()
if (api) {
paintMatchHighlight(api)
paintActiveHighlight(api)
}
const ranges = findDomTextSearchRanges(
root,
query,
options.documentOnly ? DOCUMENT_ONLY_SCOPE : {}
)
previewHighlights.setMatches(instanceId, ranges)
return ranges
}
@@ -222,19 +75,7 @@ export function setActiveMarkdownPreviewSearchMatch(
options: { scrollIntoView?: boolean } = {}
): void {
const active = activeIndex >= 0 ? matches[activeIndex] : undefined
if (active) {
activeRangeByInstance.set(instanceId, active)
} else {
activeRangeByInstance.delete(instanceId)
}
const api = getHighlightApi()
if (api) {
// Only the active range changed — don't rebuild the (potentially 100k-range)
// match highlight on every Next/Prev navigation.
paintActiveHighlight(api)
}
previewHighlights.setActive(instanceId, active)
if (active && options.scrollIntoView !== false) {
// The Range's start container is a Text node; scroll its element into view.
@@ -0,0 +1,183 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { useContext, useRef } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { EditorCommandOwnerContext } from './editor-command-owner-context'
import { usePdfViewerFindShortcut } from './use-pdf-viewer-find-shortcut'
import { useNativeChatFind } from '../native-chat/use-native-chat-find'
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ keybindings: undefined }) } }))
vi.mock('../../store', () => ({ useAppStore: { getState: () => ({ keybindings: undefined }) } }))
vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => false }))
afterEach(() => {
cleanup()
document.body.replaceChildren()
})
function pressFind(target: EventTarget): KeyboardEvent {
const isMac = navigator.userAgent.includes('Mac')
const event = new KeyboardEvent('keydown', {
key: 'f',
code: 'KeyF',
bubbles: true,
cancelable: true,
metaKey: isMac,
ctrlKey: !isMac
})
target.dispatchEvent(event)
return event
}
function PdfViewerStandIn({
name,
openFind
}: {
name: string
openFind: () => void
}): React.JSX.Element {
const rootRef = useRef<HTMLDivElement>(null)
const ownsCommands = useContext(EditorCommandOwnerContext)
usePdfViewerFindShortcut({ rootRef, ownsCommands, keybindings: undefined, openFind })
return (
<div ref={rootRef} data-testid={`${name}-pdf`}>
<canvas />
</div>
)
}
/** The real split shape: each group's strip and body (where editors render), and the retained
* overlay host for terminals, chats and browsers as a sibling of the layout, outside every body. */
function Workspace({
focusedGroup,
openFind
}: {
focusedGroup: 'pdf' | 'chat' | 'second-pdf' | null
openFind: Record<'pdf' | 'second-pdf', () => void>
}): React.JSX.Element {
return (
<div>
<div data-tab-group-strip-id="pdf">
<div tabIndex={0} data-testid="pdf-tab" />
</div>
<div data-tab-group-body-id="pdf">
<EditorCommandOwnerContext value={focusedGroup === 'pdf'}>
<button type="button" data-testid="pdf-header-copy-path" />
<PdfViewerStandIn name="first" openFind={openFind.pdf} />
</EditorCommandOwnerContext>
</div>
<div data-tab-group-body-id="second-pdf">
<EditorCommandOwnerContext value={focusedGroup === 'second-pdf'}>
<PdfViewerStandIn name="second" openFind={openFind['second-pdf']} />
</EditorCommandOwnerContext>
</div>
<div data-tab-group-strip-id="chat" />
<div data-tab-group-body-id="chat" />
<div data-testid="retained-overlays">
<div data-native-chat-root="true">
<button type="button" data-testid="chat-tool-disclosure" />
</div>
<textarea className="xterm-helper-textarea" data-testid="xterm" />
<button type="button" data-testid="browser-chrome" />
</div>
<button type="button" data-testid="explorer-row" />
<input data-testid="sidebar-search" />
</div>
)
}
function setup(focusedGroup: 'pdf' | 'chat' | 'second-pdf' | null) {
const openFind = { pdf: vi.fn(), 'second-pdf': vi.fn() }
const view = render(<Workspace focusedGroup={focusedGroup} openFind={openFind} />)
return { openFind, view }
}
describe('PDF viewer find shortcut', () => {
it('leaves Mod+F to a chat, terminal or browser whose split is focused', () => {
const { openFind, view } = setup('chat')
for (const id of ['chat-tool-disclosure', 'xterm', 'browser-chrome', 'explorer-row']) {
expect(pressFind(view.getByTestId(id)).defaultPrevented).toBe(false)
}
expect(openFind.pdf).not.toHaveBeenCalled()
expect(openFind['second-pdf']).not.toHaveBeenCalled()
})
it('opens from its own tab, its editor header and the file explorer while its group is focused', () => {
const { openFind, view } = setup('pdf')
for (const id of ['pdf-tab', 'pdf-header-copy-path', 'explorer-row']) {
expect(pressFind(view.getByTestId(id)).defaultPrevented).toBe(true)
}
expect(pressFind(document.body).defaultPrevented).toBe(true)
expect(openFind.pdf).toHaveBeenCalledTimes(4)
// With two PDFs on screen only the focused group's opens.
expect(openFind['second-pdf']).not.toHaveBeenCalled()
})
it('leaves a text field its keys, and always opens from inside the PDF', () => {
const { openFind, view } = setup('chat')
expect(pressFind(view.getByTestId('sidebar-search')).defaultPrevented).toBe(false)
expect(pressFind(view.getByTestId('second-pdf').firstElementChild!).defaultPrevented).toBe(true)
expect(openFind['second-pdf']).toHaveBeenCalledTimes(1)
expect(openFind.pdf).not.toHaveBeenCalled()
})
it('does not open for a PDF kept mounted in a hidden workspace', () => {
// A hidden workspace's groups are never the focused group.
const { openFind } = setup(null)
expect(pressFind(document.body).defaultPrevented).toBe(false)
expect(openFind.pdf).not.toHaveBeenCalled()
})
it('answers only keys from its own window surface, not the floating workspace panel', () => {
function Chat({ testId }: { testId: string }): React.JSX.Element {
const rootRef = useRef<HTMLDivElement>(null)
const composerRef = useRef(null)
const listRef = useRef(null)
const find = useNativeChatFind(true, rootRef, composerRef, listRef)
return (
<div ref={rootRef} tabIndex={-1} data-native-chat-root="true" data-testid={testId}>
{find.isOpen ? <div data-testid={`${testId}-find`} /> : null}
</div>
)
}
const mainPdf = vi.fn()
const floatingPdf = vi.fn()
// Each surface keeps its own focused group: `owns` names the surfaces whose PDF group is focused.
const surfaces = (owns: 'main' | 'floating'): React.JSX.Element => (
<div>
<div data-tab-group-body-id="main">
<EditorCommandOwnerContext value={owns === 'main'}>
<PdfViewerStandIn name="main" openFind={mainPdf} />
</EditorCommandOwnerContext>
</div>
<Chat testId="main-chat" />
<div data-floating-terminal-panel="">
<div data-tab-group-body-id="floating">
<EditorCommandOwnerContext value={owns === 'floating'}>
<PdfViewerStandIn name="floating" openFind={floatingPdf} />
</EditorCommandOwnerContext>
</div>
<Chat testId="floating-chat" />
</div>
</div>
)
// The main window's PDF group is focused; the panel's focused group is its chat.
const view = render(surfaces('main'))
const floatingChat = view.getByTestId('floating-chat')
floatingChat.focus()
act(() => {
pressFind(floatingChat)
})
expect(mainPdf).not.toHaveBeenCalled()
expect(view.queryByTestId('floating-chat-find')).not.toBeNull()
// And the reverse: the panel's PDF group is focused; the main window's focused group is its chat.
view.rerender(surfaces('floating'))
const mainChat = view.getByTestId('main-chat')
mainChat.focus()
act(() => {
pressFind(mainChat)
})
expect(floatingPdf).not.toHaveBeenCalled()
expect(view.queryByTestId('main-chat-find')).not.toBeNull()
})
})
@@ -0,0 +1,61 @@
import { useEffect, type RefObject } from 'react'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { isEditableTarget } from '@/lib/editable-target'
import { isEventTargetInsideFloatingWorkspacePanel } from '@/lib/floating-workspace-terminal-actions'
import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../shared/keybindings'
/**
* Find is this PDF's when the key comes from inside it, or when it owns the panel's commands (the
* active tab of the focused group, as the browser pane decides) and the key is not typed into a
* text field. Its own tab, header and the file explorer keep the group focused; a chat, terminal
* or browser in another split focuses its own group.
*/
export function pdfViewerOwnsFind(
root: HTMLElement | null,
target: EventTarget | null,
ownsCommands: boolean
): boolean {
if (!root || !(target instanceof Node)) {
return false
}
if (root.contains(target)) {
return true
}
// The floating panel has its own focused group: each window surface answers only its own keys.
return (
ownsCommands &&
!isEditableTarget(target instanceof Element ? target : null) &&
isEventTargetInsideFloatingWorkspacePanel(target) ===
isEventTargetInsideFloatingWorkspacePanel(root)
)
}
export function usePdfViewerFindShortcut({
rootRef,
ownsCommands,
keybindings,
openFind
}: {
rootRef: RefObject<HTMLElement | null>
ownsCommands: boolean
keybindings: KeybindingOverrides | undefined
openFind: () => void
}): void {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (!keybindingMatchesAction('editor.find', e, getShortcutPlatform(), keybindings)) {
return
}
// Why: a PDF stays mounted in a hidden worktree or another split; it must not take
// Mod+F from the surface the user is in (a chat, a preview).
if (!pdfViewerOwnsFind(rootRef.current, e.target, ownsCommands)) {
return
}
e.preventDefault()
e.stopPropagation()
openFind()
}
window.addEventListener('keydown', handleKeyDown, true)
return () => window.removeEventListener('keydown', handleKeyDown, true)
}, [keybindings, openFind, ownsCommands, rootRef])
}
@@ -64,6 +64,7 @@ export function NativeChatApprovalCard({
<div className="mx-auto flex h-full min-h-0 max-h-full w-full max-w-(--chat-content-max-width) px-3 pt-2 pb-1 sm:px-4">
<div
ref={cardRef}
data-native-chat-prompt-card-focus={shouldFocus || undefined}
data-native-chat-approval-card="true"
role="group"
aria-label={approval.title}
@@ -0,0 +1,117 @@
import { useEffect } from 'react'
import './native-chat-find.css'
import { ChevronDown, ChevronUp, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ImeInput } from '@/lib/ime-text-field'
import { translate } from '@/i18n/i18n'
import type { NativeChatFind } from './use-native-chat-find'
import { useNativeChatFindMatches } from './use-native-chat-find-matches'
/** Find over the chat transcript, at the top-right of the transcript area like the terminal's search. */
export function NativeChatFindBar({
find,
isVisible
}: {
find: NativeChatFind
isVisible: boolean
}): React.JSX.Element {
const { query, setQuery, close, rootRef, barRef, inputRef, revealMatch } = find
const { matchCount, activeIndex, step } = useNativeChatFindMatches({
rootRef,
barRef,
query,
isVisible,
revealMatch
})
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [inputRef])
const status =
query && matchCount === 0
? translate('components.native-chat.find.noResults', 'No results')
: `${activeIndex + 1}/${matchCount}`
const previousLabel = translate('components.native-chat.find.previousMatch', 'Previous match')
const nextLabel = translate('components.native-chat.find.nextMatch', 'Next match')
const closeLabel = translate('components.native-chat.find.close', 'Close')
return (
<div
ref={barRef}
role="search"
// The chat sends clicks on its plain surface to the composer; the bar's are its own.
data-native-chat-typing-redirect-ignore="true"
data-native-chat-find-bar="true"
className="absolute top-2 right-4 z-20 flex w-85 max-w-[calc(100%-2rem)] items-center gap-1 rounded-lg border border-border bg-popover/95 px-2 py-1 text-popover-foreground shadow-floating backdrop-blur-sm"
onMouseDown={(event) => {
// A press on the count or padding keeps typing in the find input.
if (!(event.target instanceof Element) || !event.target.closest('button, input')) {
event.preventDefault()
inputRef.current?.focus()
}
}}
onKeyDown={(event) => {
// Buttons keep their own Enter (Previous, Close); only the input steps.
if (event.key === 'Enter' && event.target === inputRef.current) {
event.preventDefault()
step(event.shiftKey ? -1 : 1)
}
}}
>
<ImeInput
ref={inputRef}
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={translate('components.native-chat.find.label', 'Find in chat')}
aria-label={translate('components.native-chat.find.label', 'Find in chat')}
className="min-w-0 flex-1 border-none bg-transparent text-sm text-popover-foreground outline-none placeholder:text-muted-foreground"
/>
<span
aria-live="polite"
className="shrink-0 whitespace-nowrap px-1 text-xs tabular-nums text-muted-foreground"
>
{status}
</span>
<div className="mx-0.5 h-4 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => step(-1)}
disabled={matchCount === 0}
className="shrink-0"
title={previousLabel}
aria-label={previousLabel}
>
<ChevronUp size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => step(1)}
disabled={matchCount === 0}
className="shrink-0"
title={nextLabel}
aria-label={nextLabel}
>
<ChevronDown size={14} />
</Button>
<div className="mx-0.5 h-4 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={close}
className="shrink-0"
title={closeLabel}
aria-label={closeLabel}
>
<X size={14} />
</Button>
</div>
)
}
@@ -189,6 +189,68 @@ describe('reader navigation', () => {
expect(offersJumpToLatest()).toBe(true)
})
it('stops following when find steps to a match above the end', () => {
const handle = createRef<NativeChatMessageListHandle>()
const view = (messages: NativeChatMessage[]) => (
<NativeChatMessageList
ref={handle}
session={session(messages)}
isWorking
expandSignal={false}
/>
)
const { container, rerender } = render(view(transcript))
paint(container)
const scroller = scrollRoot(container)
expect(distanceFromBottom(container)).toBe(0)
const match = document.createRange()
match.selectNodeContents(scroller)
const viewTop = scroller.getBoundingClientRect().top
Object.defineProperty(match, 'getBoundingClientRect', {
value: () => DOMRect.fromRect({ y: viewTop - 2000, height: 20 })
})
act(() => handle.current?.revealFindMatch(match, null))
fireEvent.scroll(scroller)
const foundAt = scroller.scrollTop
expect(distanceFromBottom(container)).toBeGreaterThan(1000)
rerender(view([...transcript, marker(TRANSCRIPT_LENGTH)]))
paint(container)
expect(scroller.scrollTop).toBe(foundAt)
expect(offersJumpToLatest()).toBe(true)
})
it('keeps following when a find step cannot move the transcript', () => {
const handle = createRef<NativeChatMessageListHandle>()
const view = (messages: NativeChatMessage[]) => (
<NativeChatMessageList
ref={handle}
session={session(messages)}
isWorking
expandSignal={false}
/>
)
const { container, rerender } = render(view(transcript))
paint(container)
const scroller = scrollRoot(container)
expect(distanceFromBottom(container)).toBe(0)
const match = document.createRange()
match.selectNodeContents(scroller)
const viewBottom = scroller.getBoundingClientRect().bottom
// Below the end: the clamped scroll is a no-op, so no scroll event would re-arm following.
Object.defineProperty(match, 'getBoundingClientRect', {
value: () => DOMRect.fromRect({ y: viewBottom + 2000, height: 20 })
})
act(() => handle.current?.revealFindMatch(match, null))
rerender(view([...transcript, marker(TRANSCRIPT_LENGTH)]))
paint(container)
expect(distanceFromBottom(container)).toBe(0)
expect(offersJumpToLatest()).toBe(false)
})
it('leaves a reader who scrolled up in place when a message arrives from another device', () => {
const { container, rerender } = render(liveList(transcript))
paint(container)
@@ -310,12 +310,12 @@ export function NativeChatMessageList({
beginNavigation()
scrollToBottom()
}, [beginNavigation, scrollToBottom])
useNativeChatMessageListHandle(ref, jumpToLatest)
const readerScrollInput = useNativeChatReaderScrollInput(scrollRef, {
onReaderScroll: beginNavigation,
onTakeScroll: transcriptWindow.cancelAlign,
onLeaveEnd: readerLeavesEnd
})
useNativeChatMessageListHandle(ref, jumpToLatest, readerScrollInput.revealFindMatch)
useNativeChatRailJumpLanding({
railJump,
slots,
@@ -214,6 +214,7 @@ export function NativeChatQuestionCard({
// above the text input. Its free-text row is the answer input.
<div
ref={cardRef}
data-native-chat-prompt-card-focus={shouldFocus || undefined}
role="group"
aria-label={q.question}
tabIndex={-1}
@@ -24,7 +24,10 @@ vi.mock('./use-native-chat-retained-session', () => ({
}))
vi.mock('./NativeChatMessageList', () => ({
NativeChatMessageList: (props: { ref?: React.Ref<NativeChatMessageListHandle> }) => {
useImperativeHandle(props.ref, () => ({ revealLatest: stubs.revealLatest }))
useImperativeHandle(props.ref, () => ({
revealLatest: stubs.revealLatest,
revealFindMatch: () => {}
}))
return null
}
}))
@@ -16,6 +16,8 @@ import {
import { useNativeChatLaunchPromptDeliveryNotice } from './use-native-chat-launch-prompt-delivery-notice'
import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer'
import { useNativeChatFontSize } from './use-native-chat-font-size'
import { useNativeChatFind } from './use-native-chat-find'
import { NativeChatFindBar } from './NativeChatFindBar'
import { useNativeChatCanSend } from './use-native-chat-can-send'
import { NativeChatInteractiveCard } from './NativeChatInteractiveCard'
import { useNativeChatInteractivePromptCard } from './use-native-chat-interactive-prompt-card'
@@ -328,6 +330,7 @@ export function NativeChatResolvedView({
// Only the focused conversation accepts chat text-size shortcuts.
useNativeChatFontSize(isConversation && isVisible && isFocusedGroup, rootRef)
const find = useNativeChatFind(isVisible && isFocusedGroup, rootRef, composerRef, messageListRef)
const appearanceStyle = useNativeChatStoreAppearanceStyle()
return (
@@ -347,6 +350,7 @@ export function NativeChatResolvedView({
}
}}
onKeyDownCapture={(event) => {
find.onKeyDownCapture(event)
const splitDirection = event.repeat
? null
: matchNativeChatSplitShortcut(event, getShortcutPlatform(), keybindings)
@@ -370,7 +374,8 @@ export function NativeChatResolvedView({
style={appearanceStyle}
data-native-chat-scheme={appearanceStyle.colorScheme}
>
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative flex min-h-0 flex-1 flex-col">
{find.isOpen ? <NativeChatFindBar find={find} isVisible={isVisible} /> : null}
{viewState.kind === 'loading' ? (
<NativeChatEmptyState kind="loading" />
) : viewState.kind === 'error' ? (
@@ -335,7 +335,10 @@ export function createStructuredSessionMocks() {
nativeChatMessageList: () => ({
NativeChatMessageList: (props: typeof mocks.messageListProps) => {
mocks.messageListProps = props
useImperativeHandle(props?.ref, () => ({ revealLatest: mocks.revealLatest }))
useImperativeHandle(props?.ref, () => ({
revealLatest: mocks.revealLatest,
revealFindMatch: () => {}
}))
return <DeliveryNoticesMock notices={props?.deliveryNotices} />
}
}),
@@ -18,6 +18,8 @@ import { NativeChatStructuredQuestionCard } from './NativeChatStructuredQuestion
import { selectNativeChatViewState, structuredChatHistoryPhase } from './native-chat-view-state'
import { useNativeChatComposerRevealFocus } from './use-native-chat-composer-reveal-focus'
import { useNativeChatFontSize } from './use-native-chat-font-size'
import { useNativeChatFind } from './use-native-chat-find'
import { NativeChatFindBar } from './NativeChatFindBar'
import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover'
import { useNativeChatLinkActions } from './use-native-chat-link-actions'
import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context'
@@ -165,6 +167,12 @@ export function NativeChatStructuredSession(
viewState.kind === 'ready' && props.isVisible && props.isFocusedGroup,
rootRef
)
const find = useNativeChatFind(
props.isVisible && props.isFocusedGroup,
rootRef,
composerRef,
submits.messageListRef
)
const appearanceStyle = useNativeChatStoreAppearanceStyle()
const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId)
const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions(
@@ -236,7 +244,10 @@ export function NativeChatStructuredSession(
data-native-chat-working={controller.isWorking ? 'true' : 'false'}
tabIndex={-1}
onPointerDownCapture={paneCommands.onPointerDownCapture}
onKeyDownCapture={paneCommands.onKeyDownCapture}
onKeyDownCapture={(event) => {
find.onKeyDownCapture(event)
paneCommands.onKeyDownCapture(event)
}}
onContextMenuCapture={paneCommands.onContextMenuCapture}
className={cn(
NATIVE_CHAT_APPEARANCE_ROOT_CLASS,
@@ -245,7 +256,8 @@ export function NativeChatStructuredSession(
style={appearanceStyle}
data-native-chat-scheme={appearanceStyle.colorScheme}
>
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative flex min-h-0 flex-1 flex-col">
{find.isOpen ? <NativeChatFindBar find={find} isVisible={props.isVisible} /> : null}
{viewState.kind === 'loading' || (viewState.kind === 'error' && !readFailure) ? (
loadingPane
) : viewState.kind === 'error' ? (
@@ -292,7 +292,8 @@ export function NativeChatAgentControls({
className?: string
}): React.JSX.Element {
return (
<div className={cn('flex items-center gap-1', className)}>
// Hover-only row chrome: find skips it rather than counting what the mouse happens to show.
<div data-native-chat-find-skip className={cn('flex items-center gap-1', className)}>
{/* A visual line means nothing pasted outside Orca, so the copy leaves it out. */}
<NativeChatCopyButton text={withoutNativeChatVisualDirectiveLines(markdown)} />
<button
@@ -38,7 +38,11 @@ function UserMessageMeta({
return null
}
return (
<div className={cn('flex select-none items-center gap-1', !sending && USER_META_REVEAL)}>
<div
// Hover-only once sent, so find skips it; "Sending…" stays on screen and stays findable.
data-native-chat-find-skip={sending ? undefined : true}
className={cn('flex select-none items-center gap-1', !sending && USER_META_REVEAL)}
>
{markdown ? (
<NativeChatCopyButton text={markdown} className={sending ? USER_META_REVEAL : undefined} />
) : null}
@@ -0,0 +1,92 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from 'vitest'
import { nativeChatFindGeometry, nativeChatFindScrollDelta } from './native-chat-find-visibility'
type Box = { top: number; bottom: number; left: number; right: number }
function placeAt(target: Element | Range, box: Box): void {
Object.defineProperty(target, 'getBoundingClientRect', {
configurable: true,
value: () =>
DOMRect.fromRect({
x: box.left,
y: box.top,
width: box.right - box.left,
height: box.bottom - box.top
})
})
}
const VIEW = { top: 100, bottom: 700, left: 0, right: 800 }
const BAR = { top: 108, bottom: 144, left: 444, right: 784 }
/** A transcript holding `text` inside `boxStyle`d boxes, outermost first; returns its match. */
function transcriptWith(...boxStyles: Partial<CSSStyleDeclaration>[]): {
transcript: HTMLElement
boxes: HTMLElement[]
match: Range
} {
const transcript = document.createElement('div')
placeAt(transcript, VIEW)
let parent: HTMLElement = transcript
const boxes = boxStyles.map((style) => {
const box = document.createElement('div')
Object.assign(box.style, style)
parent.append(box)
parent = box
return box
})
const text = document.createTextNode('alpha')
parent.append(text)
document.body.append(transcript)
const match = document.createRange()
match.selectNodeContents(text)
return { transcript, boxes, match }
}
afterEach(() => {
document.body.replaceChildren()
})
describe('native chat find visibility', () => {
it('counts a match in the top band as visible when the bar is not over it', () => {
const { transcript, match } = transcriptWith()
placeAt(match, { top: 112, bottom: 130, left: 20, right: 60 })
expect(nativeChatFindGeometry(transcript).inView(match, BAR)).toBe(true)
placeAt(match, { top: 112, bottom: 130, left: 500, right: 540 })
expect(nativeChatFindGeometry(transcript).inView(match, BAR)).toBe(false)
placeAt(match, { top: 60, bottom: 78, left: 20, right: 60 })
expect(nativeChatFindGeometry(transcript).inView(match, BAR)).toBe(false)
})
it('scrolls a code line sideways to a match past its right edge', () => {
const { transcript, boxes, match } = transcriptWith({ overflowX: 'auto' })
placeAt(boxes[0], { top: 200, bottom: 260, left: 40, right: 640 })
placeAt(match, { top: 220, bottom: 238, left: 900, right: 940 })
const geometry = nativeChatFindGeometry(transcript)
expect(geometry.inView(match, BAR)).toBe(false)
expect(geometry.clippedAway(match)).toBe(false)
geometry.scrollIntoBoxes(match)
expect(boxes[0].scrollLeft).toBe(580)
})
it('drops text a box that cannot scroll cuts off, such as a folded prompt', () => {
const { transcript, boxes, match } = transcriptWith({ overflow: 'hidden' })
placeAt(boxes[0], { top: 200, bottom: 376, left: 40, right: 640 })
Object.defineProperty(boxes[0], 'scrollHeight', { value: 600 })
Object.defineProperty(boxes[0], 'clientHeight', { value: 176 })
placeAt(match, { top: 500, bottom: 518, left: 60, right: 100 })
expect(nativeChatFindGeometry(transcript).clippedAway(match)).toBe(true)
placeAt(match, { top: 300, bottom: 318, left: 60, right: 100 })
expect(nativeChatFindGeometry(transcript).clippedAway(match)).toBe(false)
})
it('centres a revealed match below the bar only when the bar shares its column', () => {
expect(
nativeChatFindScrollDelta({ top: 900, bottom: 920, left: 500, right: 540 }, VIEW, BAR)
).toBe(910 - (144 + 700) / 2)
expect(
nativeChatFindScrollDelta({ top: 900, bottom: 920, left: 20, right: 60 }, VIEW, BAR)
).toBe(910 - (100 + 700) / 2)
})
})
@@ -0,0 +1,106 @@
// One rule for "the reader can see this match", shared by picking a match and revealing it, so
// a match chosen as visible is never scrolled.
type Box = Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'>
type Clip = { element: Element; scrolls: boolean }
function within(rect: Box, box: Box): boolean {
return (
rect.top >= box.top &&
rect.bottom <= box.bottom &&
rect.left >= box.left &&
rect.right <= box.right
)
}
function overlaps(rect: Box, box: Box): boolean {
return (
rect.left < box.right && rect.right > box.left && rect.top < box.bottom && rect.bottom > box.top
)
}
function outside(rect: Box, box: Box): boolean {
return (
rect.bottom < box.top || rect.top > box.bottom || rect.right < box.left || rect.left > box.right
)
}
export type NativeChatFindGeometry = {
/** Fully inside the transcript's view and every scroll box around it, and not under the bar. */
inView: (match: Range, bar: Box | null) => boolean
/** Cut off by a box that cannot scroll (a folded prompt, a truncated label): never shown. */
clippedAway: (match: Range) => boolean
/** Scrolls the nested scroll boxes (code lines, tool output, thinking) to the match. */
scrollIntoBoxes: (match: Range) => void
}
/** Layout reads for one pass over the matches; style lookups are cached for the pass. */
export function nativeChatFindGeometry(transcript: Element): NativeChatFindGeometry {
const clipsByElement = new Map<Element, readonly Clip[]>()
const clipsOf = (element: Element | null): readonly Clip[] => {
if (!element || element === transcript || !transcript.contains(element)) {
return []
}
const cached = clipsByElement.get(element)
if (cached) {
return cached
}
const style = getComputedStyle(element)
const overflow = `${style.overflow} ${style.overflowX} ${style.overflowY}`
const outer = clipsOf(element.parentElement)
const scrolls = /auto|scroll/.test(overflow)
// A box that clips but holds all its content cuts nothing off; skipping it saves a rect read per match.
const cuts =
scrolls ||
(/hidden|clip/.test(overflow) &&
(element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth))
const clips = cuts ? [{ element, scrolls }, ...outer] : outer
clipsByElement.set(element, clips)
return clips
}
const clipsOfMatch = (match: Range): readonly Clip[] =>
clipsOf(match.startContainer.parentElement)
return {
inView: (match, bar) => {
const rect = match.getBoundingClientRect()
return (
within(rect, transcript.getBoundingClientRect()) &&
!(bar && overlaps(rect, bar)) &&
clipsOfMatch(match).every(
(clip) => !clip.scrolls || within(rect, clip.element.getBoundingClientRect())
)
)
},
clippedAway: (match) => {
const fixed = clipsOfMatch(match).filter((clip) => !clip.scrolls)
if (fixed.length === 0) {
return false
}
const rect = match.getBoundingClientRect()
return fixed.some((clip) => outside(rect, clip.element.getBoundingClientRect()))
},
scrollIntoBoxes: (match) => {
for (const clip of clipsOfMatch(match)) {
if (!clip.scrolls) {
continue
}
const rect = match.getBoundingClientRect()
const box = clip.element.getBoundingClientRect()
if (rect.left < box.left || rect.right > box.right) {
clip.element.scrollLeft += (rect.left + rect.right) / 2 - (box.left + box.right) / 2
}
if (rect.top < box.top || rect.bottom > box.bottom) {
clip.element.scrollTop += (rect.top + rect.bottom) / 2 - (box.top + box.bottom) / 2
}
}
}
}
}
/** How far to scroll the transcript so a match sits mid-view, below the bar when the bar is over it. */
export function nativeChatFindScrollDelta(match: Box, view: Box, bar: Box | null): number {
const underBar = bar !== null && match.left < bar.right && match.right > bar.left
const top = underBar ? Math.max(view.top, bar.bottom) : view.top
return (match.top + match.bottom) / 2 - (top + view.bottom) / 2
}
@@ -0,0 +1,12 @@
/* Why: find matches are painted as CSS highlights (Ranges, no DOM mutation), in the
markdown preview's colors; see use-native-chat-find-matches.ts.
::highlight() supports color/background only. */
::highlight(native-chat-find-match) {
background-color: color-mix(in srgb, var(--markdown-search-match) 50%, transparent);
color: inherit;
}
::highlight(native-chat-find-active-match) {
background-color: color-mix(in srgb, var(--markdown-search-match-active) 60%, transparent);
color: inherit;
}
@@ -6,6 +6,7 @@ import {
readerGestureLeavesEnd,
type ReaderGesture
} from './native-chat-autoscroll'
import { nativeChatFindGeometry, nativeChatFindScrollDelta } from './native-chat-find-visibility'
const READER_SCROLL_KEYS = new Set([
'ArrowUp',
@@ -134,11 +135,16 @@ export function nativeChatReaderScrollInputHandlers({
}
}
/** The transcript scroller's input props, and the wheel the rail overlaying it forwards. */
/** The transcript scroller's input props, the wheel the rail overlaying it forwards, and find steps. */
export function useNativeChatReaderScrollInput(
scrollRef: React.RefObject<HTMLElement | null>,
{ onReaderScroll: onScrollInput, onTakeScroll, onLeaveEnd }: ReaderScrollCallbacks
): { scrollerProps: NativeChatReaderScrollInputHandlers; railWheel: (deltaY: number) => void } {
): {
scrollerProps: NativeChatReaderScrollInputHandlers
railWheel: (deltaY: number) => void
/** A find step moves the transcript for the reader, so it ends following like a gesture. */
revealFindMatch: (match: Range, bar: DOMRectReadOnly | null) => void
} {
const onReaderScroll = useCallback(() => {
onScrollInput()
onTakeScroll?.()
@@ -161,5 +167,33 @@ export function useNativeChatReaderScrollInput(
},
[onLeaveEnd, onReaderScroll, scrollRef]
)
return { scrollerProps, railWheel }
const revealFindMatch = useCallback(
(match: Range, bar: DOMRectReadOnly | null) => {
const transcript = scrollRef.current
if (!transcript) {
return
}
const geometry = nativeChatFindGeometry(transcript)
geometry.scrollIntoBoxes(match)
if (geometry.inView(match, bar)) {
return
}
const delta = nativeChatFindScrollDelta(
match.getBoundingClientRect(),
transcript.getBoundingClientRect(),
bar
)
const maxScrollTop = transcript.scrollHeight - transcript.clientHeight
const target = Math.min(Math.max(transcript.scrollTop + delta, 0), maxScrollTop)
// A scroll that cannot move fires no scroll event, so nothing would re-arm following.
if (Math.abs(target - transcript.scrollTop) < 1) {
return
}
onReaderScroll()
onLeaveEnd()
transcript.scrollTop = target
},
[onLeaveEnd, onReaderScroll, scrollRef]
)
return { scrollerProps, railWheel, revealFindMatch }
}
@@ -0,0 +1,286 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
createDomTextSearchHighlights,
findDomTextSearchRanges,
type DomTextSearchInstance,
type DomTextSearchScope
} from '@/lib/dom-text-search-highlights'
import { nativeChatFindGeometry } from './native-chat-find-visibility'
// Must match the ::highlight() selectors in native-chat-find.css.
const chatFindHighlights = createDomTextSearchHighlights({
match: 'native-chat-find-match',
active: 'native-chat-find-active-match'
})
const TRANSCRIPT_COLUMN_SELECTOR = '[data-native-chat-transcript-column]'
const TRANSCRIPT_SCROLL_SELECTOR = '[data-native-chat-scroll]'
/** Row chrome that only shows on hover (timestamps, copy): marked, so counts never follow the mouse. */
const SKIPPED_TEXT_SELECTOR = '.sr-only, [hidden], [data-native-chat-find-skip]'
/** A streamed reply's text node while its words fade in: each new word is its own element. */
const WORD_GROUP_SELECTOR = '[data-word-group]'
/** Streamed text with no match in it grows every frame; re-searching it this often is plenty. */
const TEXT_GROWTH_SEARCH_DELAY_MS = 120
/** Text growing in place: a text node rewritten, or words drawn into or settled in a word group. */
function isTextGrowth(record: MutationRecord): boolean {
return (
record.type === 'characterData' ||
(record.type === 'childList' &&
record.target instanceof Element &&
record.target.matches(WORD_GROUP_SELECTOR))
)
}
function holdsMatch(node: Node, matchNodes: ReadonlySet<Node>): boolean {
return (
matchNodes.has(node) ||
Array.from(node.childNodes).some((child) => holdsMatch(child, matchNodes))
)
}
function visibleTextScope(reuse: Range[]): DomTextSearchScope {
const rejected = new Map<HTMLElement, boolean>()
return {
rejectElement: (element) => {
let reject = rejected.get(element)
if (reject === undefined) {
reject =
element.closest(SKIPPED_TEXT_SELECTOR) !== null ||
(typeof element.checkVisibility === 'function' &&
!element.checkVisibility({ visibilityProperty: true }))
rejected.set(element, reject)
}
return reject
},
// Highlighted code and fading reply words split text into spans; match across them.
joinedTextSelector: `code, ${WORD_GROUP_SELECTOR}`,
reuse
}
}
type MatchAnchor = { node: Node; offset: number }
function anchorOf(range: Range | undefined): MatchAnchor | null {
return range ? { node: range.startContainer, offset: range.startOffset } : null
}
/** The first match the reader can see; else the first below the view; else the last. */
function firstVisibleMatch(
matches: readonly Range[],
scroller: Element | null,
bar: DOMRectReadOnly | null
): { index: number; inView: boolean } {
if (matches.length === 0) {
return { index: -1, inView: false }
}
if (!scroller) {
return { index: 0, inView: false }
}
const geometry = nativeChatFindGeometry(scroller)
const viewTop = scroller.getBoundingClientRect().top
let below = -1
for (const [index, match] of matches.entries()) {
const rect = match.getBoundingClientRect()
if (rect.bottom < viewTop) {
continue
}
if (geometry.inView(match, bar)) {
return { index, inView: true }
}
if (below === -1 && rect.top >= viewTop) {
below = index
}
}
return { index: below === -1 ? matches.length - 1 : below, inView: false }
}
/** After the transcript changed: the same match if it survived, else the next one after it. */
function survivingMatchIndex(matches: readonly Range[], anchor: MatchAnchor): number {
const exact = matches.findIndex(
(match) => match.startContainer === anchor.node && match.startOffset === anchor.offset
)
if (exact !== -1) {
return exact
}
const point = document.createRange()
point.setStart(anchor.node, Math.min(anchor.offset, anchor.node.textContent?.length ?? 0))
const after = matches.findIndex(
(match) => match.compareBoundaryPoints(Range.START_TO_START, point) >= 0
)
return after === -1 ? matches.length - 1 : after
}
export type NativeChatFindMatches = {
matchCount: number
/** -1 when there is no match. */
activeIndex: number
step: (direction: 1 | -1) => void
}
/** Searches the chat transcript's DOM and keeps the matches current while it streams and scrolls. */
export function useNativeChatFindMatches({
rootRef,
barRef,
query,
isVisible,
revealMatch
}: {
rootRef: React.RefObject<HTMLDivElement | null>
barRef: React.RefObject<HTMLElement | null>
query: string
isVisible: boolean
revealMatch: (match: Range) => void
}): NativeChatFindMatches {
const [instance] = useState<DomTextSearchInstance>(() => ({}))
const [matchCount, setMatchCount] = useState(0)
const [activeIndex, setActiveIndex] = useState(-1)
const matchesRef = useRef<readonly Range[]>([])
const activeIndexRef = useRef(-1)
const anchorRef = useRef<MatchAnchor | null>(null)
const searchedQueryRef = useRef<string | null>(null)
// Text nodes holding a match: React rewrites a growing node whole, which collapses its ranges.
const matchNodesRef = useRef<ReadonlySet<Node>>(new Set())
const activate = useCallback(
(matches: readonly Range[], index: number) => {
activeIndexRef.current = index
anchorRef.current = anchorOf(matches[index])
chatFindHighlights.setActive(instance, matches[index])
setActiveIndex(index)
},
[instance]
)
const search = useCallback(() => {
const root = rootRef.current
const column = root?.querySelector<HTMLElement>(TRANSCRIPT_COLUMN_SELECTOR)
const scroller = root?.querySelector(TRANSCRIPT_SCROLL_SELECTOR) ?? null
const found = column
? findDomTextSearchRanges(column, query, visibleTextScope([...matchesRef.current]))
: []
const geometry = scroller ? nativeChatFindGeometry(scroller) : null
const matches = geometry ? found.filter((match) => !geometry.clippedAway(match)) : found
const newQuery = searchedQueryRef.current !== query
searchedQueryRef.current = query
matchesRef.current = matches
matchNodesRef.current = new Set(
matches.flatMap((match) => [match.startContainer, match.endContainer])
)
chatFindHighlights.setMatches(instance, matches)
setMatchCount(matches.length)
const anchor = anchorRef.current
if (!newQuery && matches.length > 0 && anchor?.node.isConnected) {
activate(matches, survivingMatchIndex(matches, anchor))
return
}
// A new query, or the active match's row scrolled out of the window: start from what is on screen.
const bar = barRef.current?.getBoundingClientRect() ?? null
const first = firstVisibleMatch(matches, scroller, bar)
activate(matches, first.index)
if (newQuery && first.index !== -1 && !first.inView) {
revealMatch(matches[first.index])
}
}, [activate, barRef, instance, query, revealMatch, rootRef])
useEffect(() => {
const root = rootRef.current
if (!isVisible || !root) {
return
}
search()
if (!query || typeof MutationObserver === 'undefined') {
return
}
let frame: number | null = null
let timer: ReturnType<typeof setTimeout> | null = null
const runSearch = (): void => {
if (frame !== null) {
cancelAnimationFrame(frame)
frame = null
}
if (timer !== null) {
clearTimeout(timer)
timer = null
}
search()
}
let column = root.querySelector(TRANSCRIPT_COLUMN_SELECTOR)
const observer = new MutationObserver((records) => {
const current = root.querySelector(TRANSCRIPT_COLUMN_SELECTOR)
const changed = records.filter((record) => current?.contains(record.target))
const replaced = current !== column
column = current
if (!replaced && changed.length === 0) {
return
}
// Rows mounting or opening, and text that held a match (its ranges just collapsed), are
// re-searched before the next paint; other text growing in place can wait.
const now =
replaced ||
changed.some(
(record) =>
!isTextGrowth(record) ||
matchNodesRef.current.has(record.target) ||
Array.from(record.removedNodes).some((node) => holdsMatch(node, matchNodesRef.current))
)
if (now) {
frame ??= requestAnimationFrame(runSearch)
} else {
timer ??= setTimeout(runSearch, TEXT_GROWTH_SEARCH_DELAY_MS)
}
})
// A disclosure opens with a height animation (not a mutation): what it cut off at first shows at its end.
const onAnimationEnd = (event: Event): void => {
if (event.target instanceof Node && column?.contains(event.target)) {
frame ??= requestAnimationFrame(runSearch)
}
}
root.addEventListener('animationend', onAnimationEnd)
observer.observe(root, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
// Disclosures; row positioning writes `style` on every scroll and is not a content change.
attributeFilter: ['hidden', 'open', 'data-state']
})
return () => {
observer.disconnect()
root.removeEventListener('animationend', onAnimationEnd)
if (frame !== null) {
cancelAnimationFrame(frame)
}
if (timer !== null) {
clearTimeout(timer)
}
}
}, [isVisible, query, rootRef, search])
useEffect(() => () => chatFindHighlights.clear(instance), [instance])
const step = useCallback(
(direction: 1 | -1) => {
// A row that unmounted since the last search leaves collapsed ranges: refresh first.
if (matchesRef.current.some((match) => match.collapsed)) {
search()
}
const matches = matchesRef.current
if (matches.length === 0) {
return
}
const current = activeIndexRef.current
const index =
current < 0
? direction > 0
? 0
: matches.length - 1
: (current + direction + matches.length) % matches.length
activate(matches, index)
revealMatch(matches[index])
},
[activate, revealMatch, search]
)
return { matchCount, activeIndex, step }
}
@@ -0,0 +1,719 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { useRef, useState, type ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { KeybindingOverrides } from '../../../../shared/keybindings'
import { dispatchAppMenuPasteEvent } from '@/lib/app-menu-paste'
import { findOwnedTextControlPasteTarget } from '@/lib/text-control-paste-ownership'
import { isMacPlatform } from './native-chat-shortcut'
import { NativeChatFindBar } from './NativeChatFindBar'
import { useNativeChatFind } from './use-native-chat-find'
import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge'
import { routeNativeChatRootKeyToInput } from './native-chat-root-key-routing'
import { shouldFocusNativeChatPaneFromPointerTarget } from './native-chat-typing-redirect'
import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown'
import { useNativeChatPromptCardFocus } from './use-native-chat-prompt-card-focus'
import type { NativeChatComposerHandle } from './NativeChatComposer'
const mocks = vi.hoisted(() => {
const bindings: { current?: KeybindingOverrides } = {}
return { bindings, web: false }
})
vi.mock('../../store', () => ({
useAppStore: { getState: () => ({ keybindings: mocks.bindings.current }) }
}))
vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
/** `element` is the rendered composer field, set when it mounts. */
type Composer = NativeChatComposerHandle & { element: HTMLElement }
function composerHandle(): Composer {
const handle: Composer = {
element: document.createElement('div'),
focus: vi.fn(() => {
if (!handle.element.isConnected) {
return false
}
handle.element.focus()
return true
}),
insertTypedText: vi.fn(() => true),
appendText: vi.fn(),
acceptsText: () => true,
handlePasteEvent: vi.fn(),
pasteFromClipboard: vi.fn(),
contains: (node) => handle.element.contains(node)
}
return handle
}
/** The real composer key handling, wired as the composer field wires it (onKeyDownCapture). */
function ComposerField({
composer,
interrupt,
suggestionsOpen
}: {
composer: Composer
interrupt: () => void
suggestionsOpen: boolean
}): React.JSX.Element {
const dismissPicker = vi.fn()
const onKeyDown = useNativeChatComposerKeyDown({
autocomplete: suggestionsOpen
? { mode: 'mention', query: '', triggerKey: '@' }
: { mode: 'none' },
mentionFiles: { files: [], loading: false, failed: false },
completeMention: vi.fn(),
activeSuggestion: 0,
draft: '',
isComposing: () => false,
completePickerItem: vi.fn(),
dispatchPickerCommand: vi.fn(),
dismissPicker,
interrupt,
send: vi.fn(),
setActiveSuggestion: vi.fn(),
setDraft: vi.fn(),
setCaret: vi.fn()
})
return (
<div
ref={(node) => {
if (node) {
composer.element = node
}
}}
role="textbox"
aria-label="Message"
aria-expanded={suggestionsOpen}
contentEditable
tabIndex={0}
onKeyDownCapture={onKeyDown}
/>
)
}
type ChatProps = {
composer: Composer
transcript: ReactNode
enabled?: boolean
reveal?: (match: Range, bar: DOMRectReadOnly | null) => void
interrupt?: () => void
suggestionsOpen?: boolean
card?: boolean
}
function PromptCard(): React.JSX.Element {
const ref = useRef<HTMLDivElement>(null)
useNativeChatPromptCardFocus(ref, true)
return <div ref={ref} data-testid="card" tabIndex={-1} data-native-chat-prompt-card-focus />
}
/** Mirrors a chat root: pointer focus, root key routing, the find bar over the transcript. */
function Chat({
composer,
transcript,
enabled = true,
reveal = vi.fn(),
interrupt = vi.fn(),
suggestionsOpen = false,
card = false
}: ChatProps): React.JSX.Element {
const rootRef = useRef<HTMLDivElement>(null)
// As in ResolvedView: a card hides the composer and detaches its handle.
const composerRef = { current: card ? null : composer }
const messageListRef = useRef({ revealFindMatch: reveal })
const find = useNativeChatFind(enabled, rootRef, composerRef, messageListRef)
useNativeChatPasteBridge({ rootRef, composerRef })
return (
<div
ref={rootRef}
data-native-chat-root="true"
tabIndex={-1}
onPointerDownCapture={(event) => {
if (event.button === 0 && shouldFocusNativeChatPaneFromPointerTarget(event.target)) {
rootRef.current?.focus({ preventScroll: true })
}
}}
onKeyDownCapture={(event) => {
find.onKeyDownCapture(event)
routeNativeChatRootKeyToInput(event, composerRef.current, null)
}}
>
<div className="relative">
{find.isOpen ? <NativeChatFindBar find={find} isVisible /> : null}
<div data-native-chat-scroll>
<div data-native-chat-transcript-column>{transcript}</div>
</div>
</div>
{card ? <PromptCard /> : null}
<div hidden={card}>
<ComposerField
composer={composer}
interrupt={interrupt}
suggestionsOpen={suggestionsOpen}
/>
</div>
</div>
)
}
function pressModF(target: EventTarget, init: KeyboardEventInit = {}): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
key: 'f',
code: 'KeyF',
bubbles: true,
cancelable: true,
metaKey: isMacPlatform(),
ctrlKey: !isMacPlatform(),
...init
})
act(() => {
target.dispatchEvent(event)
})
return event
}
function press(target: EventTarget, key: string, init: KeyboardEventInit = {}): KeyboardEvent {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
act(() => {
target.dispatchEvent(event)
})
return event
}
/** The focused find input when two chats have one open, else the only one. */
function findInput(): HTMLInputElement | null {
const inputs = screen
.queryAllByRole('textbox', { name: 'Find in chat' })
.filter((element) => element instanceof HTMLInputElement)
return inputs.find((input) => input === document.activeElement) ?? inputs[0] ?? null
}
function typeQuery(value: string): void {
const input = findInput()
if (!input) {
throw new Error('find bar is not open')
}
fireEvent.change(input, { target: { value } })
}
function status(): string | null | undefined {
return findInput()?.parentElement?.querySelector('[aria-live]')?.textContent
}
async function nextFrame(): Promise<void> {
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
})
}
const TRANSCRIPT = (
<>
<p>alpha one</p>
<p>
two <strong>alpha</strong>
</p>
<span className="sr-only">alpha label</span>
<div hidden>alpha collapsed</div>
<span style={{ display: 'none' }}>alpha undisplayed</span>
<div data-native-chat-find-skip>alpha hover-only timestamp</div>
</>
)
beforeEach(() => {
delete mocks.bindings.current
mocks.web = false
})
afterEach(() => {
cleanup()
document.body.replaceChildren()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('native chat find', () => {
it('opens from the composer and counts only what the transcript shows', () => {
const composer = composerHandle()
render(<Chat composer={composer} transcript={TRANSCRIPT} />)
composer.element.textContent = 'alpha in the draft'
composer.element.focus()
expect(pressModF(composer.element).defaultPrevented).toBe(true)
expect(document.activeElement).toBe(findInput())
typeQuery('ALPHA')
expect(status()).toBe('1/2')
typeQuery('nothing here')
expect(status()).toBe('No results')
})
it('leaves Mod+F alone outside the chat and while the chat is not focused', () => {
const composer = composerHandle()
const outside = document.createElement('input')
document.body.append(outside)
const view = render(<Chat composer={composer} transcript={TRANSCRIPT} />)
expect(pressModF(outside).defaultPrevented).toBe(false)
view.rerender(<Chat composer={composer} transcript={TRANSCRIPT} enabled={false} />)
expect(pressModF(composer.element).defaultPrevented).toBe(false)
expect(findInput()).toBeNull()
})
it('steps with Enter and Shift+Enter, revealing each match through the transcript', () => {
const reveal = vi.fn()
const composer = composerHandle()
render(<Chat composer={composer} transcript={TRANSCRIPT} reveal={reveal} />)
pressModF(composer.element)
typeQuery('alpha')
reveal.mockClear()
press(findInput()!, 'Enter')
expect(status()).toBe('2/2')
expect(reveal).toHaveBeenLastCalledWith(expect.any(Range), expect.any(DOMRect))
expect(reveal.mock.lastCall?.[0].toString()).toBe('alpha')
press(findInput()!, 'Enter', { shiftKey: true })
expect(status()).toBe('1/2')
press(findInput()!, 'Enter', { shiftKey: true })
expect(status()).toBe('2/2')
expect(reveal).toHaveBeenCalledTimes(3)
})
it('refocuses on Mod+F without moving the match, and keeps the query on reopen', () => {
const composer = composerHandle()
render(<Chat composer={composer} transcript={TRANSCRIPT} />)
pressModF(composer.element)
typeQuery('alpha')
press(findInput()!, 'Enter')
composer.element.focus()
expect(pressModF(composer.element).defaultPrevented).toBe(true)
const input = findInput()!
expect(document.activeElement).toBe(input)
expect([input.selectionStart, input.selectionEnd]).toEqual([0, 5])
expect(status()).toBe('2/2')
press(input, 'Escape')
expect(findInput()).toBeNull()
pressModF(composer.element)
expect(findInput()?.value).toBe('alpha')
})
it('Escape in the bar closes it and returns focus to where find was opened from', () => {
const composer = composerHandle()
render(
<Chat
composer={composer}
transcript={
<button type="button" data-testid="row-action">
alpha
</button>
}
/>
)
const rowAction = screen.getByTestId('row-action')
rowAction.focus()
pressModF(rowAction)
expect(press(findInput()!, 'Escape').defaultPrevented).toBe(true)
expect(findInput()).toBeNull()
expect(document.activeElement).toBe(rowAction)
})
it('falls back to the composer when the element find was opened from is gone', () => {
const composer = composerHandle()
const view = render(
<Chat composer={composer} transcript={<button type="button">alpha</button>} />
)
screen.getByRole('button', { name: 'alpha' }).focus()
pressModF(screen.getByRole('button', { name: 'alpha' }))
view.rerender(<Chat composer={composer} transcript={<p>alpha</p>} />)
press(findInput()!, 'Escape')
expect(document.activeElement).toBe(composer.element)
})
it('Escape in the composer closes the bar first; the next Escape interrupts the turn', () => {
const interrupt = vi.fn()
const composer = composerHandle()
render(<Chat composer={composer} transcript={TRANSCRIPT} interrupt={interrupt} />)
pressModF(composer.element)
composer.element.focus()
expect(press(composer.element, 'Escape').defaultPrevented).toBe(true)
expect(findInput()).toBeNull()
expect(interrupt).not.toHaveBeenCalled()
expect(document.activeElement).toBe(composer.element)
press(composer.element, 'Escape')
expect(interrupt).toHaveBeenCalledTimes(1)
})
it('leaves Escape to an open suggestion list or a layer that already used it', () => {
const interrupt = vi.fn()
const composer = composerHandle()
const view = render(
<Chat composer={composer} transcript={TRANSCRIPT} interrupt={interrupt} suggestionsOpen />
)
pressModF(composer.element)
press(composer.element, 'Escape')
expect(findInput()).not.toBeNull()
expect(interrupt).not.toHaveBeenCalled()
view.rerender(<Chat composer={composer} transcript={TRANSCRIPT} interrupt={interrupt} />)
const claimed = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
claimed.preventDefault()
act(() => {
composer.element.dispatchEvent(claimed)
})
expect(findInput()).not.toBeNull()
})
it('re-searches when the transcript changes, keeping the active match', async () => {
function Streaming({ composer }: { composer: Composer }): React.JSX.Element {
const [rows, setRows] = useState(['alpha one', 'alpha two'])
return (
<>
<button type="button" onClick={() => setRows((r) => [...r, 'alpha three'])}>
append
</button>
<button type="button" onClick={() => setRows((r) => ['alpha zero', ...r])}>
prepend
</button>
<Chat
composer={composer}
transcript={rows.map((row) => (
<p key={row}>{row}</p>
))}
/>
</>
)
}
const composer = composerHandle()
render(<Streaming composer={composer} />)
pressModF(composer.element)
typeQuery('alpha')
press(findInput()!, 'Enter')
expect(status()).toBe('2/2')
fireEvent.click(screen.getByRole('button', { name: 'append' }))
await nextFrame()
expect(status()).toBe('2/3')
fireEvent.click(screen.getByRole('button', { name: 'prepend' }))
await nextFrame()
expect(status()).toBe('3/4')
})
it('paints into a shared registry as a union with another open find, and clears on close', () => {
class StubHighlight {
readonly ranges = new Set<Range>()
add(range: Range): void {
this.ranges.add(range)
}
}
const registry = new Map<string, StubHighlight>()
vi.stubGlobal('Highlight', StubHighlight)
vi.stubGlobal('CSS', { highlights: registry })
const first = composerHandle()
const second = composerHandle()
render(
<>
<Chat composer={first} transcript={<p>alpha alpha</p>} />
<Chat composer={second} transcript={<p>alpha</p>} />
</>
)
pressModF(first.element)
typeQuery('alpha')
pressModF(second.element)
typeQuery('alpha')
expect(registry.get('native-chat-find-match')?.ranges.size).toBe(3)
expect(registry.get('native-chat-find-active-match')?.ranges.size).toBe(2)
press(findInput()!, 'Escape')
expect(registry.get('native-chat-find-match')?.ranges.size).toBe(2)
press(findInput()!, 'Escape')
expect(registry.has('native-chat-find-match')).toBe(false)
expect(registry.has('native-chat-find-active-match')).toBe(false)
})
it('keeps paste, typing and editing keys in the find input', () => {
const composer = composerHandle()
render(<Chat composer={composer} transcript={TRANSCRIPT} />)
pressModF(composer.element)
const input = findInput()!
// Unclaimed by the chat, the app-menu paste goes to the focused text control: the find input.
expect(dispatchAppMenuPasteEvent()).toBe(false)
expect(composer.pasteFromClipboard).not.toHaveBeenCalled()
expect(findOwnedTextControlPasteTarget(document.activeElement)).toBe(input)
for (const key of ['a', 'Backspace', 'v']) {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
metaKey: key === 'v' && isMacPlatform(),
ctrlKey: key === 'v' && !isMacPlatform()
})
Object.defineProperty(event, 'target', { value: input })
routeNativeChatRootKeyToInput(event, composer, null)
expect(event.defaultPrevented).toBe(false)
}
expect(composer.insertTypedText).not.toHaveBeenCalled()
expect(composer.focus).not.toHaveBeenCalled()
})
it('keeps the find input focused when a prompt card arrives mid-query', () => {
const composer = composerHandle()
const view = render(<Chat composer={composer} transcript={TRANSCRIPT} />)
pressModF(composer.element)
const input = findInput()
view.rerender(<Chat composer={composer} transcript={TRANSCRIPT} card />)
expect(document.activeElement).toBe(input)
})
it('keeps typing in the find input after a press on the count or padding', () => {
const composer = composerHandle()
render(<Chat composer={composer} transcript={TRANSCRIPT} />)
pressModF(composer.element)
typeQuery('alpha')
const input = findInput()!
const count = input.parentElement!.querySelector('[aria-live]')!
fireEvent.pointerDown(count, { button: 0 })
expect(fireEvent.mouseDown(count, { button: 0 })).toBe(false)
expect(document.activeElement).toBe(input)
press(document.activeElement!, 'x')
expect(composer.insertTypedText).not.toHaveBeenCalled()
})
it('steps only on Enter from the input; the buttons keep their own Enter', () => {
const composer = composerHandle()
render(<Chat composer={composer} transcript={TRANSCRIPT} />)
pressModF(composer.element)
typeQuery('alpha')
for (const name of ['Previous match', 'Close']) {
const button = screen.getByRole('button', { name })
button.focus()
expect(press(button, 'Enter').defaultPrevented).toBe(false)
expect(status()).toBe('1/2')
}
})
it('starts from the first visible match when the active match scrolled out of the window', async () => {
function Window({ rows }: { rows: string[] }): React.JSX.Element {
return (
<>
{rows.map((row) => (
<p key={row}>alpha {row}</p>
))}
</>
)
}
const composer = composerHandle()
const view = render(
<Chat composer={composer} transcript={<Window rows={['r1', 'r2', 'r3', 'r4', 'r5']} />} />
)
pressModF(composer.element)
typeQuery('alpha')
for (let step = 0; step < 3; step += 1) {
press(findInput()!, 'Enter')
}
expect(status()).toBe('4/5')
view.rerender(
<Chat
composer={composer}
transcript={<Window rows={['p1', 'p2', 'p3', 'p4', 'r1', 'r2']} />}
/>
)
await nextFrame()
// Not "4/6": that index now points at an unrelated row.
expect(status()).toBe('1/6')
})
it('re-searches growing text that holds a match before the next paint, other text later', async () => {
const composer = composerHandle()
render(
<Chat
composer={composer}
transcript={
<>
<p data-testid="quiet">beta</p>
<p data-testid="streaming">alpha</p>
</>
}
/>
)
pressModF(composer.element)
typeQuery('alpha')
expect(status()).toBe('1/1')
const textOf = (id: string): Text => {
const text = screen.getByTestId(id).firstChild
if (!(text instanceof Text)) {
throw new Error(`no text node in ${id}`)
}
return text
}
// React rewrites a growing node whole, collapsing its ranges: repaint them this frame.
textOf('streaming').data = 'alpha alpha'
await nextFrame()
expect(status()).toBe('1/2')
// Growth with no match in it waits for the short timer.
textOf('quiet').data = 'beta alpha'
await nextFrame()
expect(status()).toBe('1/2')
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200))
})
// The new match sits above the active one, which stays active.
expect(status()).toBe('2/3')
})
it('matches across the words of a reply that is still fading in', () => {
const composer = composerHandle()
render(
<Chat
composer={composer}
transcript={
<p>
<span data-word-group="">
The <span data-word="">quick</span> <span data-word="">brown</span>
</span>
</p>
}
/>
)
pressModF(composer.element)
typeQuery('the quick brown')
expect(status()).toBe('1/1')
})
it('waits on words drawn into a reply; a settling word that held a match repaints at once', async () => {
const composer = composerHandle()
render(
<Chat
composer={composer}
transcript={
<>
<p>
<span data-word-group="" data-testid="streaming">
settled <span data-word="">alpha</span>
</span>
</p>
<p>
<span data-word-group="" data-testid="quiet">
beta
</span>
</p>
</>
}
/>
)
pressModF(composer.element)
typeQuery('alpha')
expect(status()).toBe('1/1')
const searches = vi.spyOn(document, 'createTreeWalker')
// A word drawn each frame with no match near it waits for the short timer.
const word = document.createElement('span')
word.dataset.word = ''
word.textContent = 'alpha'
screen.getByTestId('quiet').append(' ', word)
await nextFrame()
expect(searches).not.toHaveBeenCalled()
expect(status()).toBe('1/1')
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200))
})
expect(status()).toBe('1/2')
// The word holding the active match settles into the plain text before it: its range is gone.
searches.mockClear()
const streaming = screen.getByTestId('streaming')
const prefix = streaming.firstChild
if (!(prefix instanceof Text)) {
throw new Error('no settled text')
}
streaming.querySelector('[data-word]')?.remove()
prefix.data = 'settled alpha'
await nextFrame()
expect(searches).toHaveBeenCalled()
expect(status()).toBe('1/2')
})
it('counts an opening disclosure once its height animation ends', async () => {
const composer = composerHandle()
const { container } = render(
<Chat
composer={composer}
transcript={
<div data-testid="disclosure" style={{ overflow: 'hidden' }}>
<p>alpha in the output</p>
</div>
}
/>
)
const place = (element: Element, y: number, height: number): void => {
Object.defineProperty(element, 'getBoundingClientRect', {
configurable: true,
value: () => DOMRect.fromRect({ x: 0, y, width: 600, height })
})
}
place(container.querySelector('[data-native-chat-scroll]')!, 0, 600)
const disclosure = screen.getByTestId('disclosure')
place(disclosure, 0, 0)
Object.defineProperty(disclosure, 'scrollHeight', { configurable: true, value: 200 })
Object.defineProperty(disclosure, 'clientHeight', { configurable: true, value: 0 })
vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(
DOMRect.fromRect({ x: 20, y: 10, width: 40, height: 18 })
)
pressModF(composer.element)
typeQuery('alpha')
// First frame of the animation: the box is still 0 px tall.
expect(status()).toBe('No results')
place(disclosure, 0, 200)
Object.defineProperty(disclosure, 'clientHeight', { configurable: true, value: 200 })
act(() => {
disclosure.dispatchEvent(new Event('animationend', { bubbles: true }))
})
await nextFrame()
expect(status()).toBe('1/1')
})
it('hands focus to a prompt card that arrived while find was open when find closes', () => {
const composer = composerHandle()
const view = render(<Chat composer={composer} transcript={TRANSCRIPT} />)
pressModF(composer.element)
view.rerender(<Chat composer={composer} transcript={TRANSCRIPT} card />)
expect(document.activeElement).toBe(findInput())
press(findInput()!, 'Escape')
expect(document.activeElement).toBe(screen.getByTestId('card'))
})
it('leaves Mod+F to the browser in the web client', () => {
mocks.web = true
const composer = composerHandle()
render(<Chat composer={composer} transcript={TRANSCRIPT} />)
expect(pressModF(composer.element).defaultPrevented).toBe(false)
expect(findInput()).toBeNull()
})
it('picks the first visible match while typing without scrolling or leaving the end', () => {
const reveal = vi.fn()
const composer = composerHandle()
const { container } = render(
<Chat composer={composer} transcript={TRANSCRIPT} reveal={reveal} />
)
const scroller = container.querySelector('[data-native-chat-scroll]')!
Object.defineProperty(scroller, 'getBoundingClientRect', {
value: () => DOMRect.fromRect({ x: 0, y: 100, width: 800, height: 600 })
})
// The first match sits above the view; the second in the top band, left of the bar.
vi.spyOn(Range.prototype, 'getBoundingClientRect').mockImplementation(function (this: Range) {
const above = this.startContainer.textContent === 'alpha one'
return DOMRect.fromRect({ x: 20, y: above ? -200 : 110, width: 40, height: 18 })
})
pressModF(composer.element)
typeQuery('alpha')
expect(status()).toBe('2/2')
expect(reveal).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,164 @@
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type RefObject
} from 'react'
import { keybindingMatchesAction } from '../../../../shared/keybindings'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { isEditableTarget } from '@/lib/editable-target'
import { isWebClientLocation } from '@/lib/web-client-location'
import { useAppStore } from '../../store'
import type { NativeChatComposerHandle } from './native-chat-composer-types'
import type { NativeChatMessageListHandle } from './use-native-chat-reveal-latest'
import { focusNativeChatPromptCard } from './use-native-chat-prompt-card-focus'
/** The chat's find: open state and query live with the chat, so a reopen keeps the last query. */
export type NativeChatFind = {
isOpen: boolean
query: string
setQuery: (query: string) => void
close: () => void
/** For the chat root's onKeyDownCapture: an Escape while open closes the bar. */
onKeyDownCapture: (event: ReactKeyboardEvent) => void
rootRef: RefObject<HTMLDivElement | null>
barRef: RefObject<HTMLDivElement | null>
inputRef: RefObject<HTMLInputElement | null>
/** Brings a match into the transcript's view, clear of the bar, as a reader step would. */
revealMatch: (match: Range) => void
}
/** A composer whose suggestion list is open spends its own Escape closing that list. */
function ownsEscape(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement &&
isEditableTarget(target) &&
target.getAttribute('aria-expanded') === 'true'
)
}
function focusInput(input: HTMLInputElement): void {
input.focus()
input.select()
}
/** Mod+F inside the focused chat opens find over its transcript. */
export function useNativeChatFind(
enabled: boolean,
rootRef: RefObject<HTMLDivElement | null>,
composerRef: RefObject<Pick<NativeChatComposerHandle, 'focus'> | null>,
messageListRef: RefObject<Pick<NativeChatMessageListHandle, 'revealFindMatch'> | null>
): NativeChatFind {
const [isOpen, setIsOpen] = useState(false)
const [query, setQuery] = useState('')
const barRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const focusBeforeOpenRef = useRef<HTMLElement | null>(null)
const close = useCallback(() => {
const root = rootRef.current
const active = root?.ownerDocument.activeElement ?? null
// Focus the user moved elsewhere stays there; only focus the bar held goes back.
const focusInBar =
active === null || active === root?.ownerDocument.body || barRef.current?.contains(active)
setIsOpen(false)
if (!focusInBar || !root) {
return
}
const previous = focusBeforeOpenRef.current
focusBeforeOpenRef.current = null
const usable =
previous?.isConnected && root.contains(previous) && !previous.closest('[hidden], [inert]')
? previous
: null
// A shown prompt card that wants focus takes it, as it would have with the bar closed (one that
// arrived while the bar held focus included), unless focus came from a control inside it.
if (focusNativeChatPromptCard(root, usable)) {
return
}
if (usable) {
usable.focus({ preventScroll: true })
return
}
if (!composerRef.current?.focus()) {
root.focus({ preventScroll: true })
}
}, [composerRef, rootRef])
useEffect(() => {
// Out of scope in the web client: the browser's own find keeps Mod+F there.
if (!enabled || isWebClientLocation()) {
return
}
const platform = getShortcutPlatform()
const onKeyDown = (e: KeyboardEvent): void => {
const root = rootRef.current
if (e.defaultPrevented || !root || !(e.target instanceof Node) || !root.contains(e.target)) {
return
}
if (!keybindingMatchesAction('chat.find', e, platform, useAppStore.getState().keybindings)) {
return
}
e.preventDefault()
e.stopPropagation()
if (e.repeat) {
return
}
// A mounted input means the bar is open: refocus it, keeping the current match.
if (inputRef.current) {
focusInput(inputRef.current)
return
}
const active = root.ownerDocument.activeElement
focusBeforeOpenRef.current = active instanceof HTMLElement ? active : null
setIsOpen(true)
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [enabled, rootRef])
// Why React capture on the root: it runs before the composer's own onKeyDownCapture (outer
// first), so the composer never interrupts the turn; layers that close on Escape at the
// document (Radix popovers, the context card) still claim it earlier.
const onKeyDownCapture = useCallback(
(event: ReactKeyboardEvent) => {
if (
!isOpen ||
event.key !== 'Escape' ||
event.defaultPrevented ||
event.nativeEvent.isComposing ||
event.keyCode === 229 ||
ownsEscape(event.target)
) {
return
}
event.preventDefault()
event.stopPropagation()
close()
},
[close, isOpen]
)
const revealMatch = useCallback(
(match: Range) =>
messageListRef.current?.revealFindMatch(
match,
barRef.current?.getBoundingClientRect() ?? null
),
[messageListRef]
)
return {
isOpen,
query,
setQuery,
close,
onKeyDownCapture,
rootRef,
barRef,
inputRef,
revealMatch
}
}
@@ -1,6 +1,22 @@
import { useLayoutEffect, type RefObject } from 'react'
import { NATIVE_CHAT_ROOT_SELECTOR } from '@/lib/native-chat-paste-request'
/** A prompt card marks itself with this while it wants focus (`shouldFocus`). */
const PROMPT_CARD_WANTS_FOCUS_SELECTOR = '[data-native-chat-prompt-card-focus]'
/** Hands focus to the pane's shown prompt card that wants it, as the card would have taken it
* had focus been free; false when there is none or `keep` is already a control inside it. */
export function focusNativeChatPromptCard(root: Element, keep: Element | null): boolean {
const card = Array.from(
root.querySelectorAll<HTMLElement>(PROMPT_CARD_WANTS_FOCUS_SELECTOR)
).find((candidate) => !candidate.closest('[hidden], [inert]'))
if (!card || card.contains(keep)) {
return false
}
card.focus()
return true
}
/** Whether `element` is nothing in particular (the body) or inside the chat pane holding `card`. */
export function isInNativeChatPaneOf(card: HTMLElement | null, element: Element | null): boolean {
return (
@@ -24,7 +40,13 @@ export function useNativeChatPromptCardFocus(
useLayoutEffect(() => {
const card = cardRef.current
const active = document.activeElement
if (shouldFocus && isInNativeChatPaneOf(card, active) && !card?.contains(active)) {
// The pane's find bar is a surface of its own: a card arriving mid-query does not take its keys.
if (
shouldFocus &&
isInNativeChatPaneOf(card, active) &&
!card?.contains(active) &&
!active?.closest('[data-native-chat-find-bar]')
) {
card?.focus()
}
}, [cardRef, shouldFocus, step])
@@ -53,6 +53,8 @@ export function useNativeChatInteractiveSendReveal(
export type NativeChatMessageListHandle = {
/** Bring the latest into view and follow it, wherever the reader had scrolled. */
revealLatest: () => void
/** Bring a find match into view, clear of the find bar; the reader stops following the latest. */
revealFindMatch: (match: Range, bar: DOMRectReadOnly | null) => void
}
/** This pane's own transcript; an unmounted pane's reveal reaches nothing. */
@@ -72,10 +74,16 @@ export function useNativeChatRevealLatest(): {
return { messageListRef, revealLatest }
}
function ignoreFindMatch(): void {}
/** The transcript's side: what a pane's reveal does to this list. */
export function useNativeChatMessageListHandle(
ref: React.Ref<NativeChatMessageListHandle> | undefined,
revealLatest: () => void
revealLatest: () => void,
revealFindMatch: NativeChatMessageListHandle['revealFindMatch'] = ignoreFindMatch
): void {
useImperativeHandle(ref, () => ({ revealLatest }), [revealLatest])
useImperativeHandle(ref, () => ({ revealLatest, revealFindMatch }), [
revealLatest,
revealFindMatch
])
}
@@ -26,7 +26,10 @@ it('reveals before launch Retry, but waits for a successful queue Resume', async
}
const retryLaunch = (): number => order.push('launch')
const { result } = renderHook(() => useStructuredNativeChatSubmitReveal(controller, retryLaunch))
result.current.messageListRef.current = { revealLatest: () => order.push('reveal') }
result.current.messageListRef.current = {
revealLatest: () => order.push('reveal'),
revealFindMatch: () => {}
}
act(() => result.current.retryLaunch())
await act(() => result.current.queuedMessages.resume())
@@ -1,4 +1,4 @@
import type { KeybindingPlatform } from '../../../../shared/keybindings'
import { keybindingMatchesAction, type KeybindingPlatform } from '../../../../shared/keybindings'
import type { KeyboardHandlersDeps } from './terminal-keyboard-dependencies'
import type { createTerminalKeyboardRuntime } from './terminal-keyboard-runtime'
import { normalizeSelectedTextForFileSearch } from '@/lib/file-search-selection'
@@ -20,6 +20,7 @@ import { dispatchTerminalShortcutAction } from './terminal-keyboard-action-dispa
import { getLayoutCharacterForCode } from '@/lib/keyboard-layout/layout-base-character'
import { createTerminalKeyboardReleaseHandlers } from './terminal-keyboard-release-handlers'
import { synchronizeTerminalKeyboardPane } from './terminal-keyboard-pane-resolution'
import { isInsideNativeChatCover } from './native-chat-covered-pane'
const MAX_OBSERVED_ENTER_KEYDOWNS_PER_CODE = 8
@@ -222,6 +223,15 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) {
if (!action) {
return
}
// The chat covering this pane owns find, on whatever chord it is bound to; the hidden
// terminal buffer is not what the user sees.
if (
isInsideNativeChatCover(e.target) &&
(action.type === 'toggleSearch' ||
keybindingMatchesAction('chat.find', e, shortcutPlatform, keybindings))
) {
return
}
if (action.type === 'switchInputSource') {
// Why: the OS must receive its default action, while xterm must receive
@@ -0,0 +1,142 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import type { KeybindingOverrides } from '../../../../shared/keybindings'
import { createTerminalKeyboardEventHandlers } from './terminal-keyboard-event-handlers'
import type { TerminalShortcutAction } from './terminal-shortcut-policy'
function createHandlers(
scope: HTMLElement,
setSearchOpen: (open: boolean) => void,
{
action = { type: 'toggleSearch' },
keybindings,
onClearPaneScrollback = vi.fn()
}: {
action?: TerminalShortcutAction
keybindings?: KeybindingOverrides
onClearPaneScrollback?: () => void
} = {}
) {
const pane = { id: 1, leafId: 'leaf-1', terminal: { element: scope } }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the complete find path; unused runtime dependencies intentionally remain absent.
return createTerminalKeyboardEventHandlers({
isMac: false,
isWindows: false,
shortcutPlatform: 'linux',
keyboardScopeRef: { current: scope },
resolveShortcutEvent: () => action,
createCapturedInputSender: () => vi.fn(),
nativeOnlyShortcutTracker: {
prepareKeyDown: vi.fn(),
armKeyDown: vi.fn()
},
observedEnterKeydownTimeStamps: new Map(),
modifiedEnterChordOwner: {
ownsRedispatchedEnter: () => false,
absorb: () => false,
claim: () => true
},
deferredNewlineSender: {
absorbRedispatchedEnter: () => false,
defer: vi.fn()
},
deferredChordSender: { defer: vi.fn() },
getModifiedEnterChord: () => null,
reconcileHeldImeEnterModifiers: vi.fn(),
optionKittyReleases: { arm: vi.fn(), armNativeDeadKey: vi.fn() },
terminalImeEnterModifierKeydowns: new Set(),
paneKittyKeyboardModesRef: { current: new Map() },
managerRef: {
current: {
getActivePane: () => pane,
getPanes: () => [pane],
setActivePane: vi.fn()
}
},
paneTransportsRef: { current: new Map() },
panePtyBindingsRef: { current: new Map() },
paneCwdRef: { current: new Map() },
tabId: 'tab-1',
worktreeId: 'worktree-1',
fallbackCwd: '',
expandedPaneIdRef: { current: null },
setExpandedPane: vi.fn(),
restoreExpandedLayout: vi.fn(),
refreshPaneSizes: vi.fn(),
persistLayoutSnapshot: vi.fn(),
toggleExpandPane: vi.fn(),
setSearchOpen,
focusSearchInput: vi.fn(),
onSearchSelectedText: vi.fn(),
onRequestClosePane: vi.fn(),
onClearPaneScrollback,
onSetTitle: vi.fn(),
onClearPaneTitle: vi.fn(),
searchOpenRef: { current: false },
searchStateRef: {
current: { query: '', caseSensitive: false, regex: false }
},
keybindings,
terminalShortcutPolicy: 'orca-first',
getKeyboardSplitTelemetrySource: () => 'keyboard'
} as never)
}
function pressFind(
target: HTMLElement,
handlers: ReturnType<typeof createHandlers>,
key = 'f'
): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key,
code: `Key${key.toUpperCase()}`,
ctrlKey: true
})
target.dispatchEvent(event)
handlers.onKeyDown(event)
return event
}
describe('terminal find under a native chat cover', () => {
it('leaves Mod+F to the chat instead of opening search over the hidden terminal', () => {
const scope = document.createElement('div')
const cover = document.createElement('div')
cover.className = 'native-chat-pane-shell'
const transcript = document.createElement('div')
cover.append(transcript)
scope.append(cover)
document.body.append(scope)
const setSearchOpen = vi.fn()
const handlers = createHandlers(scope, setSearchOpen)
expect(pressFind(transcript, handlers).defaultPrevented).toBe(false)
expect(setSearchOpen).not.toHaveBeenCalled()
pressFind(scope, handlers)
expect(setSearchOpen).toHaveBeenCalledWith(true)
})
it('leaves a chat.find rebound onto another terminal chord to the chat', () => {
const scope = document.createElement('div')
const cover = document.createElement('div')
cover.className = 'native-chat-pane-shell'
const transcript = document.createElement('div')
cover.append(transcript)
scope.append(cover)
document.body.append(scope)
const onClearPaneScrollback = vi.fn()
const handlers = createHandlers(scope, vi.fn(), {
action: { type: 'clearActivePane' },
keybindings: { 'chat.find': ['Ctrl+K'] },
onClearPaneScrollback
})
expect(pressFind(transcript, handlers, 'k').defaultPrevented).toBe(false)
expect(onClearPaneScrollback).not.toHaveBeenCalled()
pressFind(scope, handlers, 'k')
expect(onClearPaneScrollback).toHaveBeenCalledTimes(1)
})
})
+7
View File
@@ -18444,6 +18444,13 @@
"groupTasks": "Tasks"
},
"jumpToLatest": "Jump to latest",
"find": {
"label": "Find in chat",
"noResults": "No results",
"previousMatch": "Previous match",
"nextMatch": "Next match",
"close": "Close"
},
"transcriptLabel": "Conversation",
"toggle": {
"showTerminal": "Show terminal",
+7
View File
@@ -18129,6 +18129,13 @@
"toggleDetails": "Alternar detalles del turno"
},
"jumpToLatest": "Saltar a lo último",
"find": {
"label": "Buscar en el chat",
"noResults": "Sin resultados",
"previousMatch": "Coincidencia anterior",
"nextMatch": "Siguiente coincidencia",
"close": "Cerrar"
},
"transcriptLabel": "Conversación",
"toggle": {
"showTerminal": "Mostrar terminal",
+7
View File
@@ -18230,6 +18230,13 @@
"toggleDetails": "Basculer les détails du tour"
},
"jumpToLatest": "Aller au dernier message",
"find": {
"label": "Rechercher dans le chat",
"noResults": "Aucun résultat",
"previousMatch": "Correspondance précédente",
"nextMatch": "Correspondance suivante",
"close": "Fermer"
},
"transcriptLabel": "Conversation",
"toggle": {
"showTerminal": "Afficher le terminal",
+7
View File
@@ -18164,6 +18164,13 @@
"toggleDetails": "ターンの詳細を切り替えます"
},
"jumpToLatest": "最新にジャンプ",
"find": {
"label": "チャット内を検索",
"noResults": "結果はありません",
"previousMatch": "前の一致",
"nextMatch": "次の一致",
"close": "閉じる"
},
"transcriptLabel": "会話",
"toggle": {
"showTerminal": "ターミナルを表示",
+7
View File
@@ -18164,6 +18164,13 @@
"toggleDetails": "턴 세부정보 전환"
},
"jumpToLatest": "최신으로 이동",
"find": {
"label": "채팅에서 찾기",
"noResults": "결과 없음",
"previousMatch": "이전 일치 항목",
"nextMatch": "다음 일치 항목",
"close": "닫기"
},
"transcriptLabel": "대화",
"toggle": {
"showTerminal": "terminal 표시",
+7
View File
@@ -18129,6 +18129,13 @@
"toggleDetails": "切换回合详情"
},
"jumpToLatest": "跳到最新消息",
"find": {
"label": "在聊天中查找",
"noResults": "没有结果",
"previousMatch": "上一个结果",
"nextMatch": "下一个结果",
"close": "关闭"
},
"transcriptLabel": "对话",
"toggle": {
"showTerminal": "显示终端",
@@ -0,0 +1,217 @@
import { findTextMatchRanges, isTextSearchQueryTooLarge } from './text-match-ranges'
// Why: React owns the searched DOM. Injecting <mark> by splitting its text nodes
// left React holding stale child pointers (NotFoundError on the next commit), so
// matches are painted with the CSS Custom Highlight API, which highlights Ranges
// without mutating the DOM.
type HighlightApi = {
registry: HighlightRegistry
create: (ranges: readonly Range[]) => Highlight
}
// Degrades to a no-op where the API is absent (older Chromium, jsdom/happy-dom in
// tests) — match counting and navigation still work off the returned Ranges; only
// the paint is skipped.
function getHighlightApi(): HighlightApi | null {
if (
typeof Highlight !== 'function' ||
typeof CSS === 'undefined' ||
!('highlights' in CSS) ||
!CSS.highlights
) {
return null
}
return {
registry: CSS.highlights,
// Why: build with .add() rather than new Highlight(...ranges). A big doc +
// short query yields 100k+ ranges, and spreading that many constructor
// args overflows V8's argument stack (RangeError).
create: (ranges) => {
const highlight = new Highlight()
for (const range of ranges) {
highlight.add(range)
}
return highlight
}
}
}
declare const domTextSearchInstanceBrand: unique symbol
/** Per-surface identity for the highlight maps; only compared by reference. */
export type DomTextSearchInstance = {
readonly [domTextSearchInstanceBrand]?: never
}
/** The ::highlight() names one kind of search surface paints into. */
export type DomTextSearchHighlightNames = {
readonly match: string
readonly active: string
}
export type DomTextSearchHighlights = {
/** Replaces this instance's matches and drops its active match. */
setMatches: (instance: DomTextSearchInstance, ranges: readonly Range[]) => void
setActive: (instance: DomTextSearchInstance, range: Range | undefined) => void
clear: (instance: DomTextSearchInstance) => void
}
// Avoid array spread when collecting union ranges — a large doc can produce
// 100k+ ranges and create()/registry writes must not build variadic arg lists.
function paintUnion(api: HighlightApi, name: string, ranges: Iterable<Range>): void {
const union: Range[] = []
for (const range of ranges) {
union.push(range)
}
if (union.length > 0) {
api.registry.set(name, api.create(union))
} else {
api.registry.delete(name)
}
}
function* flatten(groups: Iterable<readonly Range[]>): Iterable<Range> {
for (const group of groups) {
yield* group
}
}
/**
* CSS.highlights is a document-global registry keyed by a static name, while several
* surfaces of one kind can search at once (split panes, a floating window). Each
* instance's ranges are tracked by its own token and the UNION is painted, so one
* surface's find does not clobber another's.
*/
export function createDomTextSearchHighlights(
names: DomTextSearchHighlightNames
): DomTextSearchHighlights {
const matchesByInstance = new Map<DomTextSearchInstance, readonly Range[]>()
const activeByInstance = new Map<DomTextSearchInstance, Range>()
const paintMatches = (api: HighlightApi): void =>
paintUnion(api, names.match, flatten(matchesByInstance.values()))
const paintActive = (api: HighlightApi): void =>
paintUnion(api, names.active, activeByInstance.values())
return {
setMatches: (instance, ranges) => {
matchesByInstance.set(instance, ranges)
activeByInstance.delete(instance)
const api = getHighlightApi()
if (api) {
paintMatches(api)
paintActive(api)
}
},
setActive: (instance, range) => {
if (range) {
activeByInstance.set(instance, range)
} else {
activeByInstance.delete(instance)
}
// Only the active range changed — don't rebuild the (potentially 100k-range)
// match highlight on every Next/Prev navigation.
const api = getHighlightApi()
if (api) {
paintActive(api)
}
},
clear: (instance) => {
matchesByInstance.delete(instance)
activeByInstance.delete(instance)
const api = getHighlightApi()
if (api) {
paintMatches(api)
paintActive(api)
}
}
}
}
export type DomTextSearchScope = {
/** Text under an element this rejects is never searched. */
rejectElement?: (element: HTMLElement) => boolean
/** Selector for elements whose text matches as one string (e.g. highlighted code split into spans). */
joinedTextSelector?: string
/** The previous search's ranges, moved instead of allocated: every live Range slows the
* document's own text writes until it is collected, which a per-frame search outpaces. */
reuse?: Range[]
}
function appendTextSearchRanges(
nodes: Text[],
query: string,
ranges: Range[],
reuse: Range[] | undefined
): void {
const text = nodes.map((node) => node.data).join('')
if (!text.trim()) {
return
}
let nodeIndex = 0
let offset = 0
for (const { start, end } of findTextMatchRanges(text, query)) {
while (nodeIndex < nodes.length - 1 && offset + nodes[nodeIndex].length <= start) {
offset += nodes[nodeIndex++].length
}
const range = reuse?.pop() ?? document.createRange()
range.setStart(nodes[nodeIndex], start - offset)
while (nodeIndex < nodes.length - 1 && offset + nodes[nodeIndex].length < end) {
offset += nodes[nodeIndex++].length
}
range.setEnd(nodes[nodeIndex], end - offset)
ranges.push(range)
}
}
/** Case-insensitive matches of `query` in the text under `root`, in document order. */
export function findDomTextSearchRanges(
root: HTMLElement,
query: string,
scope: DomTextSearchScope = {}
): Range[] {
const ranges: Range[] = []
if (!query || isTextSearchQueryTooLarge(query)) {
return ranges
}
const { rejectElement, joinedTextSelector, reuse } = scope
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement
if (!(parent instanceof HTMLElement) || rejectElement?.(parent)) {
return NodeFilter.FILTER_REJECT
}
if (
!node.textContent?.trim() &&
!(joinedTextSelector && parent.closest(joinedTextSelector))
) {
return NodeFilter.FILTER_REJECT
}
return NodeFilter.FILTER_ACCEPT
}
})
let currentNode = walker.nextNode()
while (currentNode) {
if (!(currentNode instanceof Text)) {
currentNode = walker.nextNode()
continue
}
let joined = joinedTextSelector ? currentNode.parentElement?.closest(joinedTextSelector) : null
while (joined && joinedTextSelector) {
const outer = joined.parentElement?.closest(joinedTextSelector)
if (!outer || !root.contains(outer)) {
break
}
joined = outer
}
const nodes = [currentNode]
let next = walker.nextNode()
while (joined && next instanceof Text && joined.contains(next)) {
nodes.push(next)
next = walker.nextNode()
}
appendTextSearchRanges(nodes, query, ranges, reuse)
currentNode = next
}
return ranges
}
@@ -1,10 +1,10 @@
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text'
export const MARKDOWN_PREVIEW_SEARCH_QUERY_MAX_BYTES = 2 * 1024
export const TEXT_SEARCH_QUERY_MAX_BYTES = 2 * 1024
export function isMarkdownPreviewSearchQueryTooLarge(
export function isTextSearchQueryTooLarge(
query: string,
maxBytes = MARKDOWN_PREVIEW_SEARCH_QUERY_MAX_BYTES
maxBytes = TEXT_SEARCH_QUERY_MAX_BYTES
): boolean {
return isClipboardTextByteLengthOverLimit(query, maxBytes)
}
@@ -22,7 +22,7 @@ export function findTextMatchRanges(
if (!query) {
return []
}
if (isMarkdownPreviewSearchQueryTooLarge(query)) {
if (isTextSearchQueryTooLarge(query)) {
return []
}
@@ -2,6 +2,14 @@ import type { KeybindingDefinition } from './types'
import { platformBindings } from './definitions-support'
export const KEYBINDING_DEFINITION_CORE_4: readonly KeybindingDefinition[] = [
{
id: 'chat.find',
title: 'Find in chat',
group: 'Chat',
scope: 'chat',
searchKeywords: ['shortcut', 'chat', 'find', 'search'],
defaultBindings: platformBindings(['Mod+F'])
},
{
id: 'sidebar.childWorkspaces.toggle',
title: 'Toggle Child Workspaces',
+2
View File
@@ -8,6 +8,7 @@ export type KeybindingScope =
| 'editor'
| 'fileExplorer'
| 'composer'
| 'chat'
| 'settings'
export type KeybindingContext = 'app' | 'terminal' | 'browser'
@@ -78,6 +79,7 @@ export type KeybindingActionId =
| 'tab.selectByIndex'
| 'tab.openQuickCommandsMenu'
| 'browser.find'
| 'chat.find'
| 'browser.back'
| 'browser.forward'
| 'browser.reload'