mirror of
https://github.com/stablyai/orca.git
synced 2026-11-05 03:27:44 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
+62
@@ -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}
|
||||
|
||||
+4
-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' ? (
|
||||
|
||||
+4
-1
@@ -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
|
||||
])
|
||||
}
|
||||
|
||||
+4
-1
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18164,6 +18164,13 @@
|
||||
"toggleDetails": "ターンの詳細を切り替えます"
|
||||
},
|
||||
"jumpToLatest": "最新にジャンプ",
|
||||
"find": {
|
||||
"label": "チャット内を検索",
|
||||
"noResults": "結果はありません",
|
||||
"previousMatch": "前の一致",
|
||||
"nextMatch": "次の一致",
|
||||
"close": "閉じる"
|
||||
},
|
||||
"transcriptLabel": "会話",
|
||||
"toggle": {
|
||||
"showTerminal": "ターミナルを表示",
|
||||
|
||||
@@ -18164,6 +18164,13 @@
|
||||
"toggleDetails": "턴 세부정보 전환"
|
||||
},
|
||||
"jumpToLatest": "최신으로 이동",
|
||||
"find": {
|
||||
"label": "채팅에서 찾기",
|
||||
"noResults": "결과 없음",
|
||||
"previousMatch": "이전 일치 항목",
|
||||
"nextMatch": "다음 일치 항목",
|
||||
"close": "닫기"
|
||||
},
|
||||
"transcriptLabel": "대화",
|
||||
"toggle": {
|
||||
"showTerminal": "terminal 표시",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+5
-5
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user