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:
Kelvin Amoaba
2026-10-09 18:42:28 -07:00
committed by GitHub
co-authored by Brennan Benson
parent d3e3b173e6
commit cf5dda642d
11 changed files with 148 additions and 18 deletions
@@ -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>
)
}
@@ -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
@@ -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 = ''
@@ -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)
+4 -1
View File
@@ -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: {