mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Show picker-selected native chat skills as pills (#19616)
* Render picker-selected native chat skills as inline pills * Use cube icon for native chat skill pills * Update skill pill label assertion * Use cube icon for every native chat skill pill * Use neutral cube icon for native chat skill pills * Match native chat skill icon to selector --------- Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
4f0e3806a9
commit
d7d21b2c55
@@ -1,3 +1,4 @@
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import { forwardRef, useCallback, useImperativeHandle, useState } from 'react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
@@ -147,7 +148,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
? [true, !canSend]
|
||||
: [targetPtyId !== null, targetPtyId === null || !canSend]
|
||||
|
||||
const syncCaret = useCallback((el: HTMLTextAreaElement) => {
|
||||
const syncCaret = useCallback((el: NativeChatComposerInput) => {
|
||||
setCaret(el.selectionStart ?? el.value.length)
|
||||
}, [])
|
||||
|
||||
@@ -353,7 +354,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
})
|
||||
|
||||
const handleDraftChange = useCallback(
|
||||
(value: string, element: HTMLTextAreaElement) => {
|
||||
(value: string, element: NativeChatComposerInput) => {
|
||||
setDraft(value)
|
||||
setHistory((prev) => ({ entries: prev.entries, index: null }))
|
||||
syncCaret(element)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { NativeChatPromptEditor } from './NativeChatPromptEditor'
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { ImageOff } from 'lucide-react'
|
||||
@@ -19,7 +21,7 @@ export type NativeChatComposerFieldProps = {
|
||||
/** Pane identity published to the drop pipeline so a native file drop lands
|
||||
* only in the composer it was dropped on. */
|
||||
composerScopeKey: string
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>
|
||||
textareaRef: RefObject<NativeChatComposerInput | null>
|
||||
draft: string
|
||||
disabled: boolean
|
||||
hasPty: boolean
|
||||
@@ -35,11 +37,11 @@ export type NativeChatComposerFieldProps = {
|
||||
isDictating: boolean
|
||||
isDictationHoldMode: boolean
|
||||
imeEnterGesture: ReturnType<typeof useImeEnterGestureOwnership>
|
||||
onDraftChange: (value: string, element: HTMLTextAreaElement) => void
|
||||
onTextareaSelect: (element: HTMLTextAreaElement) => void
|
||||
onKeyDown: KeyboardEventHandler<HTMLTextAreaElement>
|
||||
onImeSettled: (element: HTMLTextAreaElement) => void
|
||||
onPaste: ClipboardEventHandler<HTMLTextAreaElement>
|
||||
onDraftChange: (value: string, element: NativeChatComposerInput) => void
|
||||
onTextareaSelect: (element: NativeChatComposerInput) => void
|
||||
onKeyDown: KeyboardEventHandler<HTMLElement>
|
||||
onImeSettled: (element: NativeChatComposerInput) => void
|
||||
onPaste: ClipboardEventHandler<HTMLElement>
|
||||
pickerListboxId: string
|
||||
onChoosePickerItem: (item: NativeChatPickerItem) => void
|
||||
onRetrySkills: () => void
|
||||
@@ -151,7 +153,7 @@ export function NativeChatComposerField({
|
||||
textarea.value = draft
|
||||
}, [draft, imeEnterGesture, textareaRef])
|
||||
|
||||
const settleImeValue = (element: HTMLTextAreaElement): void => {
|
||||
const settleImeValue = (element: NativeChatComposerInput): void => {
|
||||
if (droppedDraftClearRef.current) {
|
||||
droppedDraftClearRef.current = false
|
||||
element.value = imeComposedSegment(compositionBaseRef.current, element.value)
|
||||
@@ -204,38 +206,39 @@ export function NativeChatComposerField({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
defaultValue={draft}
|
||||
<NativeChatPromptEditor
|
||||
key={composerScopeKey}
|
||||
scopeKey={composerScopeKey}
|
||||
inputRef={textareaRef}
|
||||
initialValue={draft}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
onChange={(e) => onDraftChange(e.target.value, e.currentTarget)}
|
||||
onKeyDown={(event) => {
|
||||
onChange={(input) => onDraftChange(input.value, input)}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (!imeEnterGesture.ownsKeyDown(event)) {
|
||||
onKeyDown(event)
|
||||
}
|
||||
}}
|
||||
onKeyUp={imeEnterGesture.onKeyUp}
|
||||
onBlur={(event) => {
|
||||
onBlur={() => {
|
||||
const compositionWasActive = imeEnterGesture.isComposing()
|
||||
imeEnterGesture.reset()
|
||||
if (compositionWasActive) {
|
||||
settleImeValue(event.currentTarget)
|
||||
settleImeValue(textareaRef.current!)
|
||||
}
|
||||
}}
|
||||
onCompositionStart={(event) => {
|
||||
compositionBaseRef.current = event.currentTarget.value
|
||||
onCompositionStart={() => {
|
||||
compositionBaseRef.current = textareaRef.current!.value
|
||||
imeEnterGesture.setComposing(true)
|
||||
}}
|
||||
onCompositionEnd={(event) => {
|
||||
onCompositionEnd={() => {
|
||||
const compositionWasActive = imeEnterGesture.isComposing()
|
||||
imeEnterGesture.setComposing(false)
|
||||
if (compositionWasActive) {
|
||||
settleImeValue(event.currentTarget)
|
||||
settleImeValue(textareaRef.current!)
|
||||
}
|
||||
}}
|
||||
onPaste={onPaste}
|
||||
onSelect={(e) => onTextareaSelect(e.currentTarget)}
|
||||
onPasteCapture={onPaste}
|
||||
onSelect={onTextareaSelect}
|
||||
aria-expanded={autocomplete.mode === 'slash' || autocomplete.mode === 'skill'}
|
||||
aria-controls={
|
||||
autocomplete.mode === 'slash' || autocomplete.mode === 'skill'
|
||||
@@ -250,13 +253,13 @@ export function NativeChatComposerField({
|
||||
}
|
||||
placeholder={nativeChatComposerPlaceholder(hasPty, canSend)}
|
||||
// Why: coarse-pointer min-height follows the app's touch target convention.
|
||||
// field-sizing:content grows the field with the draft; the 8lh cap (plus
|
||||
// Editable content grows naturally; the 8lh cap (plus
|
||||
// py-1) turns further growth into internal scrolling, and scrollbar-sleek
|
||||
// keeps that gutter off the heavy native scrollbar. Both are layout-driven,
|
||||
// so re-wrap on window/pane resize is handled without a measure pass.
|
||||
className={cn(
|
||||
'scrollbar-sleek min-h-12 w-full resize-none bg-transparent px-2 py-1 text-sm outline-none pointer-coarse:min-h-14',
|
||||
'[field-sizing:content] max-h-[calc(8lh+0.5rem)]',
|
||||
'min-h-12 w-full bg-transparent px-2 py-1 text-sm outline-none pointer-coarse:min-h-14',
|
||||
'max-h-[calc(8lh+0.5rem)] overflow-y-auto scrollbar-sleek',
|
||||
'placeholder:text-muted-foreground/60 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { readNativeChatDraftDocument } from './native-chat-draft-cache'
|
||||
// @vitest-environment happy-dom
|
||||
import { createRef } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NativeChatPromptEditor } from './NativeChatPromptEditor'
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import { promptEditor } from './native-chat-prompt-editor.test-support'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function setup(value = '') {
|
||||
const inputRef = createRef<NativeChatComposerInput>()
|
||||
const onChange = vi.fn()
|
||||
const view = render(
|
||||
<NativeChatPromptEditor
|
||||
inputRef={inputRef}
|
||||
initialValue={value}
|
||||
disabled={false}
|
||||
placeholder="Message"
|
||||
onChange={onChange}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
return {
|
||||
...view,
|
||||
input: inputRef.current!,
|
||||
editor: promptEditor(screen.getByRole('textbox')),
|
||||
onChange
|
||||
}
|
||||
}
|
||||
|
||||
describe('native chat skill editor', () => {
|
||||
it('renders only picker insertions as pills and serializes the exact invocation', () => {
|
||||
const { input, container } = setup('Please $rev')
|
||||
act(() => input.insertSkill!(7, 11, '$review'))
|
||||
expect(container.querySelector('[data-native-chat-skill]')?.textContent).toBe('Review')
|
||||
expect(input.value).toBe('Please $review ')
|
||||
expect(input.selectionStart).toBe(15)
|
||||
act(() => {
|
||||
input.value += '$review typed manually'
|
||||
})
|
||||
expect(container.querySelectorAll('[data-native-chat-skill]')).toHaveLength(1)
|
||||
expect(input.value).toBe('Please $review $review typed manually')
|
||||
})
|
||||
|
||||
it('keeps typed and restored invocations plain', () => {
|
||||
const { container, input } = setup('$review /review')
|
||||
expect(container.querySelector('[data-native-chat-skill]')).toBeNull()
|
||||
act(() => {
|
||||
input.value = '$review restored'
|
||||
})
|
||||
expect(container.querySelector('[data-native-chat-skill]')).toBeNull()
|
||||
})
|
||||
|
||||
it('deletes a skill atomically and restores it with undo', () => {
|
||||
const { input, editor, container } = setup('$rev')
|
||||
act(() => input.insertSkill!(0, 4, '$review'))
|
||||
act(() => {
|
||||
input.setSelectionRange(0, 7)
|
||||
editor.commands.deleteSelection()
|
||||
})
|
||||
expect(input.value).toBe(' ')
|
||||
expect(container.querySelector('[data-native-chat-skill]')).toBeNull()
|
||||
act(() => {
|
||||
editor.commands.undo()
|
||||
})
|
||||
expect(input.value).toBe('$review ')
|
||||
expect(container.querySelector('[data-native-chat-skill]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('preserves multiple selected skills through multiline edits and clears them on send', () => {
|
||||
const { input, container } = setup('$one')
|
||||
act(() => input.insertSkill!(0, 4, '$one'))
|
||||
act(() => {
|
||||
input.value += '\nthen $two'
|
||||
})
|
||||
act(() => input.insertSkill!(11, 15, '$two'))
|
||||
expect(input.value).toBe('$one \nthen $two ')
|
||||
expect(container.querySelectorAll('[data-native-chat-skill]')).toHaveLength(2)
|
||||
act(() => {
|
||||
input.value = ''
|
||||
})
|
||||
expect(input.value).toBe('')
|
||||
expect(container.querySelector('[data-native-chat-skill]')).toBeNull()
|
||||
})
|
||||
|
||||
it('restores selected nodes only in their owning pane draft', async () => {
|
||||
const inputRef = createRef<NativeChatComposerInput>()
|
||||
const props = {
|
||||
inputRef,
|
||||
scopeKey: 'pill-pane',
|
||||
initialValue: '$rev',
|
||||
disabled: false,
|
||||
placeholder: 'Message',
|
||||
onChange: vi.fn(),
|
||||
onSelect: vi.fn()
|
||||
}
|
||||
const first = render(<NativeChatPromptEditor {...props} />)
|
||||
act(() => inputRef.current!.insertSkill!(0, 4, '$review'))
|
||||
expect(
|
||||
readNativeChatDraftDocument('pill-pane', '$review ')?.content?.[0]?.content?.[0]?.type
|
||||
).toBe('nativeChatSkill')
|
||||
first.unmount()
|
||||
const second = render(<NativeChatPromptEditor {...props} initialValue="$review " />)
|
||||
await waitFor(() =>
|
||||
expect(second.container.querySelector('[data-native-chat-skill]')).not.toBeNull()
|
||||
)
|
||||
second.unmount()
|
||||
const other = render(
|
||||
<NativeChatPromptEditor {...props} scopeKey="other-pane" initialValue="$review " />
|
||||
)
|
||||
expect(other.container.querySelector('[data-native-chat-skill]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['$revision ', '$preview '])(
|
||||
'replaces partial skill text with %s without a stale pill',
|
||||
(replacement) => {
|
||||
const { input, container } = setup('$rev')
|
||||
act(() => input.insertSkill!(0, 4, '$review'))
|
||||
act(() => {
|
||||
input.value = replacement
|
||||
})
|
||||
expect(input.value).toBe(replacement)
|
||||
expect(container.querySelector('[data-native-chat-skill]')).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('pastes rich clipboard content as literal text without manufacturing pills', () => {
|
||||
const { input, container } = setup()
|
||||
fireEvent.paste(screen.getByRole('textbox'), {
|
||||
clipboardData: {
|
||||
getData: (format: string) =>
|
||||
format === 'text/plain'
|
||||
? '$review\nhello'
|
||||
: '<span data-native-chat-skill="$review">review</span>'
|
||||
}
|
||||
})
|
||||
expect(input.value).toBe('$review\nhello')
|
||||
expect(container.querySelector('[data-native-chat-skill]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
readNativeChatDraftDocument,
|
||||
writeNativeChatDraftDocument
|
||||
} from './native-chat-draft-cache'
|
||||
import { closeHistory } from '@tiptap/pm/history'
|
||||
import { Slice } from '@tiptap/pm/model'
|
||||
import {
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type HTMLAttributes,
|
||||
type RefObject
|
||||
} from 'react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import {
|
||||
NativeChatSkill,
|
||||
promptTextContent,
|
||||
promptTextMap,
|
||||
promptTextOffset
|
||||
} from './native-chat-prompt-document'
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
|
||||
type Props = Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onSelect'> & {
|
||||
scopeKey?: string
|
||||
inputRef: RefObject<NativeChatComposerInput | null>
|
||||
initialValue: string
|
||||
disabled: boolean
|
||||
placeholder: string
|
||||
onChange: (input: NativeChatComposerInput) => void
|
||||
onSelect: (input: NativeChatComposerInput) => void
|
||||
}
|
||||
|
||||
export function NativeChatPromptEditor({
|
||||
scopeKey,
|
||||
inputRef,
|
||||
initialValue,
|
||||
disabled,
|
||||
placeholder,
|
||||
onChange,
|
||||
onSelect,
|
||||
className,
|
||||
...events
|
||||
}: Props): React.JSX.Element {
|
||||
const placeholderRef = useRef(placeholder)
|
||||
placeholderRef.current = placeholder
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
blockquote: false,
|
||||
bold: false,
|
||||
bulletList: false,
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
dropcursor: false,
|
||||
gapcursor: false,
|
||||
heading: false,
|
||||
horizontalRule: false,
|
||||
italic: false,
|
||||
link: false,
|
||||
listItem: false,
|
||||
orderedList: false,
|
||||
strike: false,
|
||||
underline: false,
|
||||
trailingNode: false
|
||||
}),
|
||||
NativeChatSkill,
|
||||
Placeholder.configure({ placeholder: () => placeholderRef.current })
|
||||
],
|
||||
content:
|
||||
(scopeKey && readNativeChatDraftDocument(scopeKey, initialValue)) ||
|
||||
promptTextContent(initialValue),
|
||||
editable: !disabled,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true',
|
||||
'aria-label': placeholder,
|
||||
class: `${className ?? ''} whitespace-pre-wrap break-words [&_p]:m-0 [&_p.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&_p.is-editor-empty:first-child]:before:text-muted-foreground/60 [&_p.is-editor-empty:first-child]:before:float-left [&_p.is-editor-empty:first-child]:before:h-0 [&_p.is-editor-empty:first-child]:before:pointer-events-none`,
|
||||
...Object.fromEntries(Object.entries(events).filter(([key]) => key.startsWith('aria-')))
|
||||
},
|
||||
// Clipboard input is always literal text; only the picker creates skill nodes.
|
||||
handlePaste: (view, event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return true
|
||||
}
|
||||
const text = event.clipboardData?.getData('text/plain')
|
||||
if (text == null) {
|
||||
return false
|
||||
}
|
||||
const content = editor?.schema.nodeFromJSON(promptTextContent(text))
|
||||
if (!content) {
|
||||
return false
|
||||
}
|
||||
view.dispatch(view.state.tr.replaceSelection(new Slice(content.content, 1, 1)))
|
||||
return true
|
||||
},
|
||||
clipboardTextSerializer: (slice) =>
|
||||
slice.content.textBetween(0, slice.content.size, '\n', (node) =>
|
||||
node.type.name === 'hardBreak' ? '\n' : String(node.attrs.token ?? '')
|
||||
)
|
||||
},
|
||||
onTransaction: ({ editor: current, transaction }) => {
|
||||
if (scopeKey && transaction.docChanged) {
|
||||
writeNativeChatDraftDocument(
|
||||
scopeKey,
|
||||
promptTextMap(current.state.doc).text,
|
||||
current.getJSON()
|
||||
)
|
||||
}
|
||||
},
|
||||
onUpdate: () => {
|
||||
if (inputRef.current) {
|
||||
onChange(inputRef.current)
|
||||
}
|
||||
},
|
||||
onSelectionUpdate: () => {
|
||||
if (inputRef.current) {
|
||||
onSelect(inputRef.current)
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
editor?.setEditable(!disabled, false)
|
||||
}, [disabled, editor])
|
||||
|
||||
const input = useMemo<NativeChatComposerInput | null>(
|
||||
() =>
|
||||
editor
|
||||
? {
|
||||
get value() {
|
||||
return promptTextMap(editor.state.doc).text
|
||||
},
|
||||
set value(value: string) {
|
||||
const old = promptTextMap(editor.state.doc)
|
||||
if (old.text === value) {
|
||||
return
|
||||
}
|
||||
if (!value) {
|
||||
editor.commands.setContent(promptTextContent(''), { emitUpdate: false })
|
||||
return
|
||||
}
|
||||
let start = 0
|
||||
while (
|
||||
start < old.text.length &&
|
||||
start < value.length &&
|
||||
old.text[start] === value[start]
|
||||
) {
|
||||
start++
|
||||
}
|
||||
let end = 0
|
||||
while (
|
||||
end < old.text.length - start &&
|
||||
end < value.length - start &&
|
||||
old.text[old.text.length - 1 - end] === value[value.length - 1 - end]
|
||||
) {
|
||||
end++
|
||||
}
|
||||
// A text replacement intersecting an atom replaces its entire serialized token.
|
||||
while (start > 0 && old.positions[start - 1] === old.positions[start]) {
|
||||
start--
|
||||
}
|
||||
while (
|
||||
end > 0 &&
|
||||
old.positions[old.text.length - end] === old.positions[old.text.length - end - 1]
|
||||
) {
|
||||
end--
|
||||
}
|
||||
const content = editor.schema.nodeFromJSON(
|
||||
promptTextContent(value.slice(start, value.length - end))
|
||||
)
|
||||
editor.commands.command(({ tr }) => {
|
||||
tr.replaceRange(
|
||||
old.positions[start],
|
||||
old.positions[old.text.length - end],
|
||||
new Slice(content.content, 1, 1)
|
||||
)
|
||||
tr.setMeta('preventUpdate', true)
|
||||
return true
|
||||
})
|
||||
},
|
||||
get disabled() {
|
||||
return !editor.isEditable
|
||||
},
|
||||
set disabled(value: boolean) {
|
||||
editor.setEditable(!value)
|
||||
},
|
||||
get selectionStart() {
|
||||
return promptTextOffset(editor.state.doc, editor.state.selection.from)
|
||||
},
|
||||
get selectionEnd() {
|
||||
return promptTextOffset(editor.state.doc, editor.state.selection.to)
|
||||
},
|
||||
focus: () => {
|
||||
editor.view.dom.focus()
|
||||
},
|
||||
contains: (node) => editor.view.dom.contains(node),
|
||||
select: () => {
|
||||
editor.commands.selectAll()
|
||||
},
|
||||
setSelectionRange: (from, to) => {
|
||||
const { positions } = promptTextMap(editor.state.doc)
|
||||
editor.commands.setTextSelection({
|
||||
from: positions[Math.min(from ?? 0, positions.length - 1)],
|
||||
to: positions[Math.min(to ?? 0, positions.length - 1)]
|
||||
})
|
||||
},
|
||||
insertSkill: (from, to, token) => {
|
||||
const { positions } = promptTextMap(editor.state.doc)
|
||||
editor.view.dispatch(closeHistory(editor.state.tr))
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt({ from: positions[from], to: positions[to] }, [
|
||||
{ type: 'nativeChatSkill', attrs: { token } },
|
||||
{ type: 'text', text: ' ' }
|
||||
])
|
||||
.run()
|
||||
editor.view.dispatch(closeHistory(editor.state.tr))
|
||||
}
|
||||
}
|
||||
: null,
|
||||
[editor]
|
||||
)
|
||||
useImperativeHandle(inputRef, () => input!, [input])
|
||||
|
||||
return <EditorContent editor={editor} {...events} />
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
|
||||
import { Package } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
function skillLabel(token: string): string {
|
||||
return token
|
||||
.replace(/^[$/]/, '')
|
||||
.split(/[-_]/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word[0]?.toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function NativeChatSkillPill({ node, selected }: NodeViewProps): React.JSX.Element {
|
||||
const token = String(node.attrs.token)
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="inline" contentEditable={false}>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
data-native-chat-skill={token}
|
||||
className={`gap-1 border-border px-1.5 py-0 text-sm font-medium text-muted-foreground align-baseline ${selected ? 'ring-1 ring-ring' : ''}`}
|
||||
>
|
||||
<Package className="size-4" aria-hidden="true" />
|
||||
{skillLabel(token)}
|
||||
</Badge>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
@@ -90,8 +90,8 @@ function renderField(draft: string): HTMLTextAreaElement {
|
||||
}
|
||||
|
||||
describe('native chat composer autogrow', () => {
|
||||
it('sizes the textarea from its content instead of staying at rows={2}', () => {
|
||||
expect(renderField('').className).toContain('[field-sizing:content]')
|
||||
it('grows naturally with editable content', () => {
|
||||
expect(renderField('').getAttribute('contenteditable')).toBe('true')
|
||||
})
|
||||
|
||||
it('caps growth at 8 lines plus the py-1 padding box', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { changePrompt, promptValue } from './native-chat-prompt-editor.test-support'
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createRef } from 'react'
|
||||
@@ -96,19 +97,21 @@ describe('native chat composer composition ownership', () => {
|
||||
const input = textarea()
|
||||
input.focus()
|
||||
fireEvent.compositionStart(input)
|
||||
input.value = '가'
|
||||
changePrompt(input, '가')
|
||||
|
||||
for (let index = 0; index < 120; index += 1) {
|
||||
view.rerender(<TestField {...props} draft={`stale streaming draft ${index}`} />)
|
||||
expect(textarea()).toBe(input)
|
||||
expect(document.activeElement).toBe(input)
|
||||
expect(input.value).toBe('가')
|
||||
expect(promptValue(input)).toBe('가')
|
||||
}
|
||||
|
||||
fireEvent.compositionEnd(input, { data: '가' })
|
||||
expect(onImeSettled).toHaveBeenCalledOnce()
|
||||
expect(onImeSettled).toHaveBeenCalledWith(input)
|
||||
expect(input.value).toBe('가')
|
||||
expect(onImeSettled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: promptValue(input) })
|
||||
)
|
||||
expect(promptValue(input)).toBe('가')
|
||||
})
|
||||
|
||||
it('synchronizes launch, programmatic, cleared, and pane-scoped drafts while idle', () => {
|
||||
@@ -120,7 +123,7 @@ describe('native chat composer composition ownership', () => {
|
||||
for (const draft of ['programmatic insertion', '', 'next pane draft']) {
|
||||
view.rerender(<TestField {...props} draft={draft} />)
|
||||
expect(textarea()).toBe(input)
|
||||
expect(input.value).toBe(draft)
|
||||
expect(promptValue(input)).toBe(draft)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -140,10 +143,13 @@ describe('native chat composer composition ownership', () => {
|
||||
)
|
||||
const input = textarea()
|
||||
fireEvent.compositionStart(input)
|
||||
fireEvent.change(input, { target: { value: '한글' } })
|
||||
expect(onDraftChange).toHaveBeenLastCalledWith('한글', input)
|
||||
changePrompt(input, '한글')
|
||||
expect(onDraftChange).toHaveBeenLastCalledWith(
|
||||
'한글',
|
||||
expect.objectContaining({ value: '한글' })
|
||||
)
|
||||
|
||||
input.value = ''
|
||||
changePrompt(input, '')
|
||||
fireEvent.compositionEnd(input, { data: '' })
|
||||
expect(settledValue).toBe('')
|
||||
})
|
||||
@@ -177,14 +183,14 @@ describe('native chat composer composition ownership', () => {
|
||||
const view = render(<TestField {...props} />)
|
||||
const input = textarea()
|
||||
fireEvent.compositionStart(input)
|
||||
input.value = '각'
|
||||
changePrompt(input, '각')
|
||||
|
||||
fireEvent.blur(input)
|
||||
view.rerender(<TestField {...props} draft="external draft" />)
|
||||
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, isComposing: false })
|
||||
|
||||
expect(onImeSettled).toHaveBeenCalledOnce()
|
||||
expect(input.value).toBe('external draft')
|
||||
expect(promptValue(input)).toBe('external draft')
|
||||
expect(onKeyDown).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -194,15 +200,17 @@ describe('native chat composer composition ownership', () => {
|
||||
const view = render(<TestField {...props} />)
|
||||
const input = textarea()
|
||||
fireEvent.compositionStart(input)
|
||||
input.value = '각'
|
||||
changePrompt(input, '각')
|
||||
|
||||
view.rerender(<TestField {...props} draft="programmatic draft" />)
|
||||
expect(input.value).toBe('각')
|
||||
expect(promptValue(input)).toBe('각')
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onImeSettled).toHaveBeenCalledOnce()
|
||||
expect(onImeSettled).toHaveBeenCalledWith(input)
|
||||
expect(input.value).toBe('각')
|
||||
expect(onImeSettled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: promptValue(input) })
|
||||
)
|
||||
expect(promptValue(input)).toBe('각')
|
||||
})
|
||||
|
||||
it('exposes the browser value on blur when compositionend is omitted', () => {
|
||||
@@ -210,7 +218,7 @@ describe('native chat composer composition ownership', () => {
|
||||
render(<TestField {...fieldProps({ onImeSettled })} />)
|
||||
const input = textarea()
|
||||
fireEvent.compositionStart(input)
|
||||
input.value = '각'
|
||||
changePrompt(input, '각')
|
||||
|
||||
fireEvent.blur(input)
|
||||
|
||||
@@ -223,7 +231,7 @@ describe('native chat composer composition ownership', () => {
|
||||
render(<TestField {...fieldProps({ onImeSettled })} />)
|
||||
const input = textarea()
|
||||
fireEvent.compositionStart(input)
|
||||
input.value = '각'
|
||||
changePrompt(input, '각')
|
||||
|
||||
act(() => {
|
||||
fireEvent.compositionEnd(input, { data: '각' })
|
||||
@@ -231,7 +239,9 @@ describe('native chat composer composition ownership', () => {
|
||||
})
|
||||
|
||||
expect(onImeSettled).toHaveBeenCalledOnce()
|
||||
expect(onImeSettled).toHaveBeenCalledWith(input)
|
||||
expect(onImeSettled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: promptValue(input) })
|
||||
)
|
||||
})
|
||||
|
||||
it('settles once when blur precedes compositionend in one batch', () => {
|
||||
@@ -239,7 +249,7 @@ describe('native chat composer composition ownership', () => {
|
||||
render(<TestField {...fieldProps({ onImeSettled })} />)
|
||||
const input = textarea()
|
||||
fireEvent.compositionStart(input)
|
||||
input.value = '각'
|
||||
changePrompt(input, '각')
|
||||
|
||||
act(() => {
|
||||
fireEvent.blur(input)
|
||||
@@ -247,7 +257,9 @@ describe('native chat composer composition ownership', () => {
|
||||
})
|
||||
|
||||
expect(onImeSettled).toHaveBeenCalledOnce()
|
||||
expect(onImeSettled).toHaveBeenCalledWith(input)
|
||||
expect(onImeSettled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: promptValue(input) })
|
||||
)
|
||||
})
|
||||
|
||||
it('replays a draft clear dropped mid-composition when the field settles on blur', () => {
|
||||
@@ -261,16 +273,16 @@ describe('native chat composer composition ownership', () => {
|
||||
const view = render(<TestField {...props} />)
|
||||
const input = textarea()
|
||||
fireEvent.compositionStart(input)
|
||||
input.value = '안녕하'
|
||||
changePrompt(input, '안녕하')
|
||||
view.rerender(<TestField {...props} draft="안녕하" />)
|
||||
|
||||
// The accepted structured send lands while the next composition is still open.
|
||||
view.rerender(<TestField {...props} draft="" />)
|
||||
expect(input.value).toBe('안녕하')
|
||||
expect(promptValue(input)).toBe('안녕하')
|
||||
|
||||
fireEvent.blur(input)
|
||||
expect(settledValue).toBe('하')
|
||||
expect(input.value).toBe('하')
|
||||
expect(promptValue(input)).toBe('하')
|
||||
})
|
||||
|
||||
it('forgets a dropped clear that the browser already settled', () => {
|
||||
@@ -279,16 +291,16 @@ describe('native chat composer composition ownership', () => {
|
||||
const view = render(<TestField {...props} />)
|
||||
const input = textarea()
|
||||
fireEvent.compositionStart(input)
|
||||
input.value = '안녕하'
|
||||
changePrompt(input, '안녕하')
|
||||
view.rerender(<TestField {...props} draft="" />)
|
||||
fireEvent.blur(input)
|
||||
|
||||
// A second composition must not inherit the first one's clear.
|
||||
fireEvent.compositionStart(input)
|
||||
input.value = '하늘'
|
||||
changePrompt(input, '하늘')
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(input.value).toBe('하늘')
|
||||
expect(promptValue(input)).toBe('하늘')
|
||||
})
|
||||
|
||||
it('keeps the browser value through a same-draft streaming rerender', () => {
|
||||
@@ -297,12 +309,14 @@ describe('native chat composer composition ownership', () => {
|
||||
const view = render(<TestField {...props} />)
|
||||
const input = textarea()
|
||||
fireEvent.compositionStart(input)
|
||||
input.value = '각'
|
||||
changePrompt(input, '각')
|
||||
|
||||
view.rerender(<TestField {...props} />)
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(input.value).toBe('각')
|
||||
expect(onImeSettled).toHaveBeenCalledWith(input)
|
||||
expect(promptValue(input)).toBe('각')
|
||||
expect(onImeSettled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: promptValue(input) })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Text coordinates keep transport, attachments, and picker logic independent of the editor. */
|
||||
export type NativeChatComposerInput = Pick<
|
||||
HTMLTextAreaElement,
|
||||
| 'value'
|
||||
| 'selectionStart'
|
||||
| 'selectionEnd'
|
||||
| 'disabled'
|
||||
| 'focus'
|
||||
| 'select'
|
||||
| 'setSelectionRange'
|
||||
> & {
|
||||
contains?: (node: Node | null) => boolean
|
||||
insertSkill?: (from: number, to: number, token: string) => void
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JSONContent } from '@tiptap/react'
|
||||
// Module-level cache for the composer's in-progress draft text, keyed by the
|
||||
// same stable pane scope as image attachments. The composer unmounts when the
|
||||
// pane toggles back to the hosted terminal, so without this the typed-but-unsent
|
||||
@@ -6,10 +7,10 @@
|
||||
|
||||
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
|
||||
|
||||
const draftCache = new Map<string, string>()
|
||||
const draftCache = new Map<string, { text: string; document?: JSONContent }>()
|
||||
|
||||
export function readNativeChatDraftCache(scopeKey: string): string {
|
||||
return draftCache.get(scopeKey) ?? ''
|
||||
return draftCache.get(scopeKey)?.text ?? ''
|
||||
}
|
||||
|
||||
export function writeNativeChatDraftCache(scopeKey: string, draft: string): void {
|
||||
@@ -20,9 +21,33 @@ export function writeNativeChatDraftCache(scopeKey: string, draft: string): void
|
||||
return
|
||||
}
|
||||
// LRU-bounded so unsent drafts for permanently-removed panes can't accumulate.
|
||||
setBoundedScopeCacheEntry(draftCache, scopeKey, draft)
|
||||
setBoundedScopeCacheEntry(draftCache, scopeKey, {
|
||||
text: draft,
|
||||
document:
|
||||
draftCache.get(scopeKey)?.text === draft ? draftCache.get(scopeKey)?.document : undefined
|
||||
})
|
||||
}
|
||||
|
||||
export function clearNativeChatDraftCacheForTests(): void {
|
||||
draftCache.clear()
|
||||
}
|
||||
|
||||
export function readNativeChatDraftDocument(
|
||||
scopeKey: string,
|
||||
text: string
|
||||
): JSONContent | undefined {
|
||||
const cached = draftCache.get(scopeKey)
|
||||
return cached?.text === text ? cached.document : undefined
|
||||
}
|
||||
|
||||
export function writeNativeChatDraftDocument(
|
||||
scopeKey: string,
|
||||
text: string,
|
||||
document: JSONContent
|
||||
): void {
|
||||
if (!text) {
|
||||
draftCache.delete(scopeKey)
|
||||
return
|
||||
}
|
||||
setBoundedScopeCacheEntry(draftCache, scopeKey, { text, document })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NativeChatSkillPill } from './NativeChatSkillPill'
|
||||
import { ReactNodeViewRenderer, Node, type JSONContent } from '@tiptap/react'
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
|
||||
export const NativeChatSkill = Node.create({
|
||||
name: 'nativeChatSkill',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
selectable: true,
|
||||
addNodeView: () => ReactNodeViewRenderer(NativeChatSkillPill),
|
||||
addAttributes: () => ({ token: { default: '' } }),
|
||||
renderText: ({ node }) => node.attrs.token,
|
||||
renderHTML: ({ node }) => [
|
||||
'span',
|
||||
{
|
||||
'data-native-chat-skill': node.attrs.token,
|
||||
contenteditable: 'false',
|
||||
class:
|
||||
'inline-flex items-center gap-1 rounded-full border border-border bg-muted px-1.5 text-sm font-medium text-muted-foreground align-baseline select-none'
|
||||
},
|
||||
['span', { 'aria-hidden': 'true' }, 'ϟ'],
|
||||
['span', {}, String(node.attrs.token).slice(1)]
|
||||
]
|
||||
})
|
||||
|
||||
export function promptTextContent(text: string): JSONContent {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: text.split('\n').map((line) => ({
|
||||
type: 'paragraph',
|
||||
content: line ? [{ type: 'text', text: line }] : []
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Each boundary maps a plain-text caret to a document position, including atomic skills. */
|
||||
export function promptTextMap(doc: ProseMirrorNode): { text: string; positions: number[] } {
|
||||
let text = ''
|
||||
const positions = [1]
|
||||
doc.forEach((block, blockOffset, index) => {
|
||||
if (index > 0) {
|
||||
text += '\n'
|
||||
positions.push(blockOffset + 1)
|
||||
}
|
||||
block.forEach((node, offset) => {
|
||||
const start = blockOffset + 1 + offset
|
||||
const value = node.isText
|
||||
? node.text!
|
||||
: node.type.name === 'hardBreak'
|
||||
? '\n'
|
||||
: String(node.attrs.token ?? '')
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
text += value[i]
|
||||
positions.push(
|
||||
node.isText ? start + i + 1 : i === value.length - 1 ? start + node.nodeSize : start
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
return { text, positions }
|
||||
}
|
||||
|
||||
export function promptTextOffset(doc: ProseMirrorNode, position: number): number {
|
||||
const { positions } = promptTextMap(doc)
|
||||
const index = positions.findIndex((candidate) => candidate >= position)
|
||||
return index === -1 ? positions.length - 1 : index
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { promptTextContent, promptTextMap } from './native-chat-prompt-document'
|
||||
|
||||
export function promptEditor(element: HTMLElement): Editor {
|
||||
return (element as HTMLElement & { editor: Editor }).editor
|
||||
}
|
||||
|
||||
export function promptValue(element: HTMLElement): string {
|
||||
return promptTextMap(promptEditor(element).state.doc).text
|
||||
}
|
||||
|
||||
export function changePrompt(element: HTMLElement, value: string): void {
|
||||
act(() => {
|
||||
promptEditor(element).commands.setContent(promptTextContent(value))
|
||||
})
|
||||
}
|
||||
+14
-13
@@ -1,3 +1,4 @@
|
||||
import { changePrompt, promptValue } from './native-chat-prompt-editor.test-support'
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
@@ -144,12 +145,12 @@ describe('structured send racing the next IME composition', () => {
|
||||
renderComposer(structured)
|
||||
const input = textarea()
|
||||
|
||||
fireEvent.change(input, { target: { value: '안녕' } })
|
||||
changePrompt(input, '안녕')
|
||||
pressEnter(input)
|
||||
expect(structured.dispatchCommand).toHaveBeenCalledWith('안녕')
|
||||
|
||||
fireEvent.compositionStart(input)
|
||||
fireEvent.change(input, { target: { value: '안녕하' } })
|
||||
changePrompt(input, '안녕하')
|
||||
|
||||
await act(async () => {
|
||||
dispatch.resolve(PASS_THROUGH)
|
||||
@@ -157,7 +158,7 @@ describe('structured send racing the next IME composition', () => {
|
||||
})
|
||||
fireEvent.compositionEnd(input, { data: '하' })
|
||||
|
||||
expect(input.value).toBe('하')
|
||||
expect(promptValue(input)).toBe('하')
|
||||
|
||||
await act(async () => pressEnter(input))
|
||||
expect(structured.send).toHaveBeenLastCalledWith('하', [])
|
||||
@@ -169,11 +170,11 @@ describe('structured send racing the next IME composition', () => {
|
||||
renderComposer(structured)
|
||||
const input = textarea()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'abcd' } })
|
||||
changePrompt(input, 'abcd')
|
||||
pressEnter(input)
|
||||
|
||||
fireEvent.compositionStart(input)
|
||||
fireEvent.change(input, { target: { value: 'ab가cd' } })
|
||||
changePrompt(input, 'ab가cd')
|
||||
|
||||
await act(async () => {
|
||||
dispatch.resolve(PASS_THROUGH)
|
||||
@@ -181,7 +182,7 @@ describe('structured send racing the next IME composition', () => {
|
||||
})
|
||||
fireEvent.compositionEnd(input, { data: '가' })
|
||||
|
||||
expect(input.value).toBe('가')
|
||||
expect(promptValue(input)).toBe('가')
|
||||
})
|
||||
|
||||
// Clearing optimistically before the RPC would lose the draft here, which is why the clear
|
||||
@@ -191,11 +192,11 @@ describe('structured send racing the next IME composition', () => {
|
||||
renderComposer(structured)
|
||||
const input = textarea()
|
||||
|
||||
fireEvent.change(input, { target: { value: '안녕' } })
|
||||
changePrompt(input, '안녕')
|
||||
await act(async () => pressEnter(input))
|
||||
|
||||
expect(structured.send).toHaveBeenCalledWith('안녕', [])
|
||||
expect(input.value).toBe('안녕')
|
||||
expect(promptValue(input)).toBe('안녕')
|
||||
})
|
||||
|
||||
it('keeps the draft when a rejected send races the next composition', async () => {
|
||||
@@ -207,11 +208,11 @@ describe('structured send racing the next IME composition', () => {
|
||||
renderComposer(structured)
|
||||
const input = textarea()
|
||||
|
||||
fireEvent.change(input, { target: { value: '안녕' } })
|
||||
changePrompt(input, '안녕')
|
||||
pressEnter(input)
|
||||
|
||||
fireEvent.compositionStart(input)
|
||||
fireEvent.change(input, { target: { value: '안녕하' } })
|
||||
changePrompt(input, '안녕하')
|
||||
|
||||
await act(async () => {
|
||||
dispatch.resolve(PASS_THROUGH)
|
||||
@@ -219,7 +220,7 @@ describe('structured send racing the next IME composition', () => {
|
||||
})
|
||||
fireEvent.compositionEnd(input, { data: '하' })
|
||||
|
||||
expect(input.value).toBe('안녕하')
|
||||
expect(promptValue(input)).toBe('안녕하')
|
||||
})
|
||||
|
||||
// A rejected command still reports its error, and the composer keeps the text to retry.
|
||||
@@ -230,11 +231,11 @@ describe('structured send racing the next IME composition', () => {
|
||||
renderComposer(structured)
|
||||
const input = textarea()
|
||||
|
||||
fireEvent.change(input, { target: { value: '/model' } })
|
||||
changePrompt(input, '/model')
|
||||
await act(async () => pressEnter(input))
|
||||
|
||||
expect(structured.onError).toHaveBeenCalledWith('nope')
|
||||
expect(structured.send).not.toHaveBeenCalled()
|
||||
expect(input.value).toBe('/model')
|
||||
expect(promptValue(input)).toBe('/model')
|
||||
})
|
||||
})
|
||||
|
||||
+3
-1
@@ -23,7 +23,9 @@ function ComposerHarness(): React.JSX.Element {
|
||||
<div>
|
||||
<textarea
|
||||
aria-label="Composer"
|
||||
ref={textareaRef}
|
||||
ref={(element) => {
|
||||
textareaRef.current = element
|
||||
}}
|
||||
defaultValue={'first line\nsecond line'}
|
||||
onCompositionStart={() => {
|
||||
isComposingRef.current = true
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
APP_MENU_SELECTION_ACTION_EVENT,
|
||||
@@ -5,7 +6,7 @@ import {
|
||||
} from '@/lib/app-menu-selection-actions'
|
||||
|
||||
export function useNativeChatComposerAppMenuSelection(isComposingOverride?: () => boolean) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const textareaRef = useRef<NativeChatComposerInput>(null)
|
||||
const isComposingRef = useRef(false)
|
||||
const isComposing = useCallback(
|
||||
() => isComposingOverride?.() ?? isComposingRef.current,
|
||||
@@ -18,7 +19,7 @@ export function useNativeChatComposerAppMenuSelection(isComposingOverride?: () =
|
||||
if (
|
||||
(event as CustomEvent<AppMenuSelectionAction>).detail !== 'select-all' ||
|
||||
!textarea ||
|
||||
document.activeElement !== textarea
|
||||
!textarea.contains?.(document.activeElement)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import { useCallback, useLayoutEffect, useRef, useState, type RefObject } from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop'
|
||||
@@ -17,7 +18,7 @@ export type UseNativeChatComposerAttachmentsArgs = {
|
||||
disabled: boolean
|
||||
isComposing: () => boolean
|
||||
resolveTarget: () => NativeChatResolvedTarget | null
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>
|
||||
textareaRef: RefObject<NativeChatComposerInput | null>
|
||||
setCaret: (caret: number) => void
|
||||
setDraft: (updater: (previous: string) => string) => void
|
||||
setNotice: (notice: string | null) => void
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useNativeChatComposerKeyDown({
|
||||
setDraft,
|
||||
setCaret,
|
||||
setHistory
|
||||
}: UseNativeChatComposerKeyDownArgs): KeyboardEventHandler<HTMLTextAreaElement> {
|
||||
}: UseNativeChatComposerKeyDownArgs): KeyboardEventHandler<HTMLElement> {
|
||||
return useCallback(
|
||||
(event) => {
|
||||
if (isComposing() || event.nativeEvent.isComposing || event.keyCode === 229) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react'
|
||||
import { dispatchDictationControl } from '../dictation/dictation-control-events'
|
||||
|
||||
export function useNativeChatDictationActions(args: {
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>
|
||||
textareaRef: RefObject<NativeChatComposerInput | null>
|
||||
setDictationPressed: Dispatch<SetStateAction<boolean>>
|
||||
}): {
|
||||
toggleDictation: () => void
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -48,7 +49,7 @@ export function useNativeChatPickerState(args: {
|
||||
agentCommands: readonly SlashCommandSuggestion[]
|
||||
/** Skill names the running session reports; undefined keeps the host disk scan. */
|
||||
sessionSkillNames?: readonly string[]
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>
|
||||
textareaRef: RefObject<NativeChatComposerInput | null>
|
||||
setDraft: (value: string) => void
|
||||
setCaret: Dispatch<SetStateAction<number>>
|
||||
setActiveSuggestion: Dispatch<SetStateAction<number>>
|
||||
@@ -130,6 +131,10 @@ export function useNativeChatPickerState(args: {
|
||||
return
|
||||
}
|
||||
const result = applyPickerSuggestion(draft, caret, item, autocomplete.prefix)
|
||||
if (item.kind === 'skill' && textareaRef.current?.insertSkill) {
|
||||
const from = result.caret - result.insertedToken.length - 1
|
||||
textareaRef.current.insertSkill(from, caret, result.insertedToken)
|
||||
}
|
||||
setDraft(result.draft)
|
||||
setCaret(result.caret)
|
||||
setActiveSuggestion(0)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react'
|
||||
import type { HistoryState } from './native-chat-composer-state'
|
||||
|
||||
/** Imperative text insertion and focus for the composer textarea, used by the
|
||||
* paste pipeline and the composer's imperative handle. */
|
||||
export function useNativeChatTypedInsertion(args: {
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>
|
||||
textareaRef: RefObject<NativeChatComposerInput | null>
|
||||
caret: number
|
||||
draft: string
|
||||
setDraft: (value: string) => void
|
||||
|
||||
Reference in New Issue
Block a user