From cf5dda642d96f93efd38dc4ee1cf0bd08c448bb1 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:42:28 +0000 Subject: [PATCH] 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> --- .../NativeChatFileReferencePill.tsx | 19 ++++++++++++++ .../NativeChatPromptEditor.test.tsx | 19 ++++++++++++++ .../native-chat/NativeChatPromptEditor.tsx | 18 +++++++++++-- .../native-chat/NativeChatPromptPill.tsx | 22 ++++++++++++++++ .../native-chat/NativeChatSkillPill.tsx | 14 +++++------ ...ative-chat-composer-ime-file-drop.test.tsx | 25 +++++++++++++++++-- .../native-chat/native-chat-composer-input.ts | 2 ++ ...chat-composer-workspace-file-drop.test.tsx | 5 ++++ .../native-chat-prompt-document.ts | 24 +++++++++++++++++- ...e-native-chat-resolved-path-attachments.ts | 13 +++++++--- src/renderer/src/components/ui/badge.tsx | 5 +++- 11 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatFileReferencePill.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatPromptPill.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatFileReferencePill.tsx b/src/renderer/src/components/native-chat/NativeChatFileReferencePill.tsx new file mode 100644 index 00000000000..d62bc4795b7 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatFileReferencePill.tsx @@ -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 ( + + + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx index 652086366c0..321c514bce7 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx @@ -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() diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx index d2655c0ceaf..3fa75d093c0 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx @@ -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, diff --git a/src/renderer/src/components/native-chat/NativeChatPromptPill.tsx b/src/renderer/src/components/native-chat/NativeChatPromptPill.tsx new file mode 100644 index 00000000000..d4a6480ae19 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatPromptPill.tsx @@ -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 ( + + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatSkillPill.tsx b/src/renderer/src/components/native-chat/NativeChatSkillPill.tsx index 9bde1a5a1c9..0bb8e42bf60 100644 --- a/src/renderer/src/components/native-chat/NativeChatSkillPill.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSkillPill.tsx @@ -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 ( - - + /> ) } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-ime-file-drop.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-ime-file-drop.test.tsx index 3687e99afb9..f646d0f0239 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-ime-file-drop.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-ime-file-drop.test.tsx @@ -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={[]} /> + {draft} ) @@ -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( + + + + ) + 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" ' + ) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-input.ts b/src/renderer/src/components/native-chat/native-chat-composer-input.ts index f4483d7575b..9e7dc4abce2 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-input.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-input.ts @@ -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 diff --git a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx index ac039ffb7c1..0475525fb00 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx @@ -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() }) diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-document.ts b/src/renderer/src/components/native-chat/native-chat-prompt-document.ts index 96bba1a75fd..840e1e26e27 100644 --- a/src/renderer/src/components/native-chat/native-chat-prompt-document.ts +++ b/src/renderer/src/components/native-chat/native-chat-prompt-document.ts @@ -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 = '' diff --git a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts index 4af35932097..e159c866042 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts @@ -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) diff --git a/src/renderer/src/components/ui/badge.tsx b/src/renderer/src/components/ui/badge.tsx index 25b701f014f..8eb3015b600 100644 --- a/src/renderer/src/components/ui/badge.tsx +++ b/src/renderer/src/components/ui/badge.tsx @@ -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: {