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 (
+
+
+ {label}
+
+ )
+}
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 (
-
-
- {skillLabel(token)}
-
+ />
)
}
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={[]}
/>
+
)
@@ -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: {