mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(native-chat): show attached files as filename pills in the composer (#26409)
A dropped or picked file used to land as its raw @path text. The pill still serializes to that reference, so what the agent reads is unchanged. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
co-authored by
Brennan Benson
parent
d3e3b173e6
commit
cf5dda642d
@@ -0,0 +1,19 @@
|
||||
import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
|
||||
import { basename } from '@/lib/path'
|
||||
import { getFileTypeIcon } from '@/lib/file-type-icons'
|
||||
import { NativeChatPromptPill } from './NativeChatPromptPill'
|
||||
|
||||
export function NativeChatFileReferencePill({ node, selected }: NodeViewProps): React.JSX.Element {
|
||||
const path = String(node.attrs.path)
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="inline" contentEditable={false}>
|
||||
<NativeChatPromptPill
|
||||
icon={getFileTypeIcon(path)}
|
||||
label={basename(path)}
|
||||
selected={selected}
|
||||
title={path}
|
||||
data-native-chat-file-reference={path}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
@@ -50,6 +50,25 @@ describe('native chat skill editor', () => {
|
||||
expect(input.value).toBe('Please $review $review typed manually')
|
||||
})
|
||||
|
||||
it('shows attached files as filename pills that serialize to the reference the agent reads', async () => {
|
||||
const { input, container, onChange } = setup('See please')
|
||||
act(() => input.setSelectionRange(4, 4))
|
||||
await act(async () =>
|
||||
input.insertFileReferences!(['/Users/abc/Downloads/test (1).csv', 'C:\\docs\\letter.pdf'])
|
||||
)
|
||||
const pills = [...container.querySelectorAll('[data-native-chat-file-reference]')]
|
||||
expect(pills.map((pill) => pill.textContent)).toEqual(['test (1).csv', 'letter.pdf'])
|
||||
expect(pills[0].getAttribute('title')).toBe('/Users/abc/Downloads/test (1).csv')
|
||||
const references = '@"/Users/abc/Downloads/test (1).csv" @C:\\docs\\letter.pdf '
|
||||
expect(input.value).toBe(`See ${references} please`)
|
||||
expect(input.selectionStart).toBe(4 + references.length)
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
act(() => {
|
||||
input.value = input.value.replace('letter.pdf', 'letter.pd')
|
||||
})
|
||||
expect(container.querySelectorAll('[data-native-chat-file-reference]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps typed and restored invocations plain', () => {
|
||||
const { container, input } = setup('$review /review')
|
||||
expect(container.querySelector('[data-native-chat-skill]')).toBeNull()
|
||||
|
||||
@@ -17,6 +17,8 @@ import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import {
|
||||
fileReferenceContent,
|
||||
NativeChatFileReference,
|
||||
NativeChatSkill,
|
||||
promptTextContent,
|
||||
promptTextMap,
|
||||
@@ -69,6 +71,7 @@ export function NativeChatPromptEditor({
|
||||
trailingNode: false
|
||||
}),
|
||||
NativeChatSkill,
|
||||
NativeChatFileReference,
|
||||
Placeholder.configure({ placeholder: () => placeholderRef.current })
|
||||
],
|
||||
content:
|
||||
@@ -83,7 +86,7 @@ export function NativeChatPromptEditor({
|
||||
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-chat-foreground-faint [&_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.
|
||||
// Clipboard input is always literal text; only the picker and attachments create pills.
|
||||
handlePaste: (view, event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return true
|
||||
@@ -219,7 +222,12 @@ export function NativeChatPromptEditor({
|
||||
return promptTextOffset(editor.state.doc, editor.state.selection.to)
|
||||
},
|
||||
focus: () => {
|
||||
editor.view.dom.focus()
|
||||
// Why: a blurred editor never writes its caret to the page, so focusing the element
|
||||
// alone would bring back the stale one and undo a caret moved by an insertion.
|
||||
editor.view.focus()
|
||||
if (!editor.isEditable) {
|
||||
editor.view.dom.focus()
|
||||
}
|
||||
},
|
||||
contains: (node) => editor.view.dom.contains(node),
|
||||
isCaretOnVisualEdge: (edge) => {
|
||||
@@ -261,6 +269,12 @@ export function NativeChatPromptEditor({
|
||||
])
|
||||
.run()
|
||||
editor.view.dispatch(closeHistory(editor.state.tr))
|
||||
},
|
||||
insertFileReferences: (paths) => {
|
||||
editor.commands.insertContentAt(
|
||||
editor.state.selection.from,
|
||||
paths.flatMap((path) => [fileReferenceContent(path), { type: 'text', text: ' ' }])
|
||||
)
|
||||
}
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
/** The inline chip an atomic prompt node renders as: a skill, or a referenced file. */
|
||||
export function NativeChatPromptPill({
|
||||
icon: Icon,
|
||||
label,
|
||||
selected,
|
||||
...badgeProps
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
selected: boolean
|
||||
title?: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Badge variant="promptPill" data-selected={selected} {...badgeProps}>
|
||||
<Icon aria-hidden="true" />
|
||||
<span className="truncate">{label}</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
|
||||
import { Package } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { NativeChatPromptPill } from './NativeChatPromptPill'
|
||||
|
||||
function skillLabel(token: string): string {
|
||||
return token
|
||||
@@ -15,14 +15,12 @@ export function NativeChatSkillPill({ node, selected }: NodeViewProps): React.JS
|
||||
const token = String(node.attrs.token)
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="inline" contentEditable={false}>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
<NativeChatPromptPill
|
||||
icon={Package}
|
||||
label={skillLabel(token)}
|
||||
selected={selected}
|
||||
data-native-chat-skill={token}
|
||||
className={`border-border px-1.5 py-0 text-muted-foreground align-baseline ${selected ? 'ring-1 ring-ring' : ''}`}
|
||||
>
|
||||
<Package aria-hidden="true" />
|
||||
{skillLabel(token)}
|
||||
</Badge>
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
+23
-2
@@ -128,7 +128,7 @@ function ComposerProbe({
|
||||
setDraft,
|
||||
setNotice
|
||||
})
|
||||
useNativeChatFileDrops({
|
||||
const { pickAttachments } = useNativeChatFileDrops({
|
||||
paneKey: `pane:${workspaceId}`,
|
||||
draftScopeKey: `pane:${workspaceId}`,
|
||||
targetPtyId: 'pty-1',
|
||||
@@ -188,6 +188,7 @@ function ComposerProbe({
|
||||
sessionOptionsSnapshot={[]}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={pickAttachments}>Attach</button>
|
||||
<output data-testid="draft">{draft}</output>
|
||||
</div>
|
||||
)
|
||||
@@ -202,7 +203,8 @@ beforeEach(() => {
|
||||
getPathForFile: (file: File) => `/drop/${file.name}`,
|
||||
prepareDroppedPaths: prepare,
|
||||
stat: vi.fn(async () => ({ isDirectory: false }))
|
||||
}
|
||||
},
|
||||
shell: { pickAttachments: vi.fn(async () => ['/picked/browser issues.md']) }
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
@@ -258,4 +260,23 @@ describe('OS file drops through the real composing chat field', () => {
|
||||
expect(promptValue(editor).match(/@\/drop\/notes.txt/g)).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('shows a dropped file and a file picked with the attach button as the same filename pill', async () => {
|
||||
render(
|
||||
<NativeChatPaneFileDropSurface className="chat">
|
||||
<ComposerProbe />
|
||||
</NativeChatPaneFileDropSurface>
|
||||
)
|
||||
const editor = screen.getByRole('textbox')
|
||||
await osDrop(editor)
|
||||
await act(async () => fireEvent.click(screen.getByText('Attach')))
|
||||
expect(
|
||||
[...editor.querySelectorAll('[data-native-chat-file-reference]')].map(
|
||||
(pill) => pill.textContent
|
||||
)
|
||||
).toEqual(['notes.txt', 'browser issues.md'])
|
||||
expect(screen.getByTestId('draft').textContent).toBe(
|
||||
'@/drop/notes.txt @"/picked/browser issues.md" '
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ export type NativeChatComposerInput = Pick<
|
||||
contains?: (node: Node | null) => boolean
|
||||
insertText?: (text: string) => void
|
||||
insertSkill?: (from: number, to: number, token: string) => void
|
||||
/** Inserts each file at the caret as a pill; its text stays the `@path` reference. */
|
||||
insertFileReferences?: (paths: string[]) => void
|
||||
/** Whether a collapsed caret sits on the first (`start`) or last (`end`) visual line,
|
||||
* counting wrapped lines, so an arrow key there has nowhere left to move it. */
|
||||
isCaretOnVisualEdge?: (edge: 'start' | 'end') => boolean
|
||||
|
||||
+5
@@ -285,6 +285,11 @@ describe('native chat workspace file drops', () => {
|
||||
'$review @/repo/src @"/repo/My File.ts" tail'
|
||||
)
|
||||
expect(editor().querySelectorAll('[data-native-chat-skill]')).toHaveLength(1)
|
||||
expect(
|
||||
[...editor().querySelectorAll('[data-native-chat-file-reference]')].map(
|
||||
(pill) => pill.textContent
|
||||
)
|
||||
).toEqual(['src', 'My File.ts'])
|
||||
expect(editor().textContent).not.toContain('must not be inserted')
|
||||
expect(bubbledDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NativeChatFileReferencePill } from './NativeChatFileReferencePill'
|
||||
import { NativeChatSkillPill } from './NativeChatSkillPill'
|
||||
import { ReactNodeViewRenderer, Node } from '@tiptap/react'
|
||||
import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste'
|
||||
import { ReactNodeViewRenderer, Node, type JSONContent } from '@tiptap/react'
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
export { promptTextContent } from './native-chat-prompt-document-text'
|
||||
|
||||
@@ -25,6 +27,26 @@ export const NativeChatSkill = Node.create({
|
||||
]
|
||||
})
|
||||
|
||||
/** A file attached by path. `token` is the reference the agent reads; the pill shows the filename. */
|
||||
export const NativeChatFileReference = Node.create({
|
||||
name: 'nativeChatFileReference',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
selectable: true,
|
||||
addNodeView: () => ReactNodeViewRenderer(NativeChatFileReferencePill),
|
||||
addAttributes: () => ({ token: { default: '' }, path: { default: '' } }),
|
||||
renderText: ({ node }) => node.attrs.token,
|
||||
renderHTML: ({ node }) => ['span', { 'data-native-chat-file-reference': node.attrs.path }]
|
||||
})
|
||||
|
||||
export function fileReferenceContent(path: string): JSONContent {
|
||||
return {
|
||||
type: NativeChatFileReference.name,
|
||||
attrs: { token: formatNativeChatFileReference(path), path }
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 = ''
|
||||
|
||||
+9
-4
@@ -65,12 +65,17 @@ export function useNativeChatResolvedPathAttachments({
|
||||
|
||||
const insertFileReferences = useCallback(
|
||||
(paths: string[]) => {
|
||||
const references = paths.map(formatNativeChatFileReference).join(' ')
|
||||
if (references.length === 0) {
|
||||
if (paths.length === 0) {
|
||||
return
|
||||
}
|
||||
const insertion = `${references} `
|
||||
const caretAtInsert = textareaRef.current?.selectionStart ?? caret
|
||||
const input = textareaRef.current
|
||||
// The editor shows each file as a pill and reports the new draft and caret itself.
|
||||
if (input?.insertFileReferences) {
|
||||
input.insertFileReferences(paths)
|
||||
return
|
||||
}
|
||||
const insertion = `${paths.map(formatNativeChatFileReference).join(' ')} `
|
||||
const caretAtInsert = input?.selectionStart ?? caret
|
||||
setDraft((prev) => {
|
||||
const before = prev.slice(0, caretAtInsert)
|
||||
const after = prev.slice(caretAtInsert)
|
||||
|
||||
@@ -21,7 +21,10 @@ const badgeVariants = cva(
|
||||
/** The chip naming the machine a workspace runs on — quieter and squarer than `secondary`,
|
||||
* so it reads as context beside a workspace name rather than as a status of its own. */
|
||||
hostContext:
|
||||
'h-4 rounded border-border bg-accent px-1.5 text-[10px] leading-none text-muted-foreground dark:border-border/50 dark:bg-accent/80'
|
||||
'h-4 rounded border-border bg-accent px-1.5 text-[10px] leading-none text-muted-foreground dark:border-border/50 dark:bg-accent/80',
|
||||
/** A skill or file sitting inline in the chat message box, ringed while selected. */
|
||||
promptPill:
|
||||
'max-w-full border-border bg-secondary px-1.5 py-0 align-baseline text-muted-foreground data-[selected=true]:ring-1 data-[selected=true]:ring-ring'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
Reference in New Issue
Block a user