Skip to content

Commit 1ac5fe2

Browse files
committed
feat: add @-mention file attachment in agent chat composer (Refs: beans-uk55)
- Add `listFiles` GraphQL query with case-insensitive substring matching across all git-tracked files (space-separated terms, all must match) - Extend `sendAgentMessage` mutation with optional `attachments` parameter; attached paths are prepended as context hints in the user message - Add @-detection in composer textarea with debounced autocomplete dropdown - Keyboard navigation (arrows, Enter/Tab to select, Escape to close) with scroll-into-view for selected items - Selected files shown as removable pills below the textarea - Add `FileEntry` type, `FileAttachmentInput` input, and `ListFiles` query to GraphQL schema with codegen - Add unit tests for ListFiles resolver
1 parent 638a1a4 commit 1ac5fe2

11 files changed

Lines changed: 848 additions & 25 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
# beans-uk55
3+
title: '@ mention file/directory attachment in agent chat'
4+
status: completed
5+
type: feature
6+
priority: normal
7+
created_at: 2026-03-21T09:33:57Z
8+
updated_at: 2026-03-21T09:46:22Z
9+
---
10+
11+
Allow users to type @ in the agent composer to autocomplete and attach files/directories from the codebase as context. File contents are injected into the prompt sent to Claude Code.
12+
13+
## Summary of Changes
14+
15+
Implemented @-mention file/directory attachment in the agent chat composer:
16+
17+
### Backend
18+
- Added `listFiles` GraphQL query that uses `git ls-files` to list tracked files, filtered by prefix, with directory deduplication (one level of depth)
19+
- Extended `sendAgentMessage` mutation to accept `attachments: [FileAttachmentInput!]` — attached paths are prepended as context hints in the user message
20+
- Added `FileEntry` type and `FileAttachmentInput` input to the GraphQL schema
21+
- Added unit tests for `ListFiles` resolver
22+
23+
### Frontend
24+
- Added @-detection in the composer textarea — typing `@` (preceded by whitespace or start-of-text) opens an autocomplete dropdown
25+
- Dropdown queries the backend via `ListFiles` with debounced input (100ms)
26+
- Keyboard navigation: ArrowUp/Down to navigate, Enter/Tab to select, Escape to close
27+
- Selecting a file adds it as a pill below the textarea and removes the @query text
28+
- Selecting a directory replaces the query to drill deeper (keeps dropdown open)
29+
- Pending attachments shown as removable pills with file/folder icons
30+
- Attachments are passed through to the GraphQL mutation via the store

frontend/src/lib/agentChat.svelte.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type SubagentActivity as GqlSubagentActivity,
1919
type InteractionType,
2020
type ImageInput,
21+
type FileAttachmentInput,
2122
} from './graphql/generated';
2223

2324
export type AgentMessageImage = GqlAgentMessageImage;
@@ -29,6 +30,7 @@ export type PendingInteraction = GqlPendingInteraction;
2930
export type SubagentActivity = GqlSubagentActivity;
3031
export type AgentSession = AgentSessionFieldsFragment;
3132
export type ImageUploadInput = ImageInput;
33+
export type FileAttachment = FileAttachmentInput;
3234

3335
export class AgentChatStore {
3436
session = $state<AgentSession | null>(null);
@@ -104,7 +106,8 @@ export class AgentChatStore {
104106
async sendMessage(
105107
beanId: string,
106108
message: string,
107-
images?: ImageUploadInput[]
109+
images?: ImageUploadInput[],
110+
attachments?: FileAttachment[]
108111
): Promise<boolean> {
109112
this.sending = true;
110113
this.error = null;
@@ -129,7 +132,8 @@ export class AgentChatStore {
129132
.mutation(SendAgentMessageDocument, {
130133
beanId,
131134
message,
132-
images: images ?? null
135+
images: images ?? null,
136+
attachments: attachments?.length ? attachments : null
133137
})
134138
.toPromise();
135139

frontend/src/lib/components/AgentChat.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@
8989
{systemStatus}
9090
{subagentActivities}
9191
{quickReplies}
92-
onSend={(text, images) => { internalScrollTrigger++; store.sendMessage(beanId, text, images); }}
92+
workspaceId={beanId}
93+
onSend={(text, images, attachments) => { internalScrollTrigger++; store.sendMessage(beanId, text, images, attachments); }}
9394
onStop={() => store.stop(beanId)}
9495
onSetMode={setAgentMode}
9596
onSetEffort={(effort) => store.setEffort(beanId, effort)}

frontend/src/lib/components/AgentComposer.svelte

Lines changed: 164 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,23 @@
33
import { Editor, Extension } from '@tiptap/core';
44
import StarterKit from '@tiptap/starter-kit';
55
import Placeholder from '@tiptap/extension-placeholder';
6+
import { ListFilesDocument } from '$lib/graphql/generated';
7+
import { client } from '$lib/graphqlClient';
68
79
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
810
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
911
1012
interface Props {
1113
beanId: string;
14+
workspaceId: string;
1215
isRunning: boolean;
1316
hasMessages: boolean;
1417
agentMode: 'plan' | 'act';
1518
effort: string;
1619
systemStatus: string | null;
1720
subagentActivities: SubagentActivity[];
1821
quickReplies: string[];
19-
onSend: (message: string, images?: { data: string; mediaType: string }[]) => void;
22+
onSend: (message: string, images?: { data: string; mediaType: string }[], attachments?: { path: string }[]) => void;
2023
onStop: () => void;
2124
onSetMode: (mode: 'plan' | 'act') => void;
2225
onSetEffort: (effort: string) => void;
@@ -26,6 +29,7 @@
2629
2730
let {
2831
beanId,
32+
workspaceId,
2933
isRunning,
3034
hasMessages,
3135
agentMode,
@@ -49,26 +53,137 @@
4953
let editorEl: HTMLDivElement | undefined = $state();
5054
let editor: Editor | undefined = $state();
5155
52-
// Create a tiptap extension for keyboard shortcuts that need access to component state.
53-
// We use closures so the handlers always read the latest reactive values.
56+
// @-mention autocomplete state
57+
let pendingAttachments = $state<{ path: string; isDir: boolean }[]>([]);
58+
let showMention = $state(false);
59+
let mentionResults = $state<{ path: string; isDir: boolean }[]>([]);
60+
let mentionSelectedIndex = $state(0);
61+
let mentionStartIndex = $state(-1);
62+
let mentionDebounceTimer: ReturnType<typeof setTimeout> | undefined;
63+
64+
function removeAttachment(index: number) {
65+
pendingAttachments = pendingAttachments.filter((_, i) => i !== index);
66+
}
67+
68+
function closeMention() {
69+
showMention = false;
70+
mentionResults = [];
71+
mentionSelectedIndex = 0;
72+
mentionStartIndex = -1;
73+
}
74+
75+
async function queryFiles(query: string) {
76+
const result = await client.query(ListFilesDocument, {
77+
workspaceId,
78+
prefix: query,
79+
limit: 20
80+
}).toPromise();
81+
if (result.data?.listFiles) {
82+
mentionResults = result.data.listFiles;
83+
mentionSelectedIndex = 0;
84+
}
85+
}
86+
87+
function scrollSelectedIntoView() {
88+
const container = document.querySelector('[data-mention-list]');
89+
const selected = container?.querySelector('[data-selected]');
90+
selected?.scrollIntoView({ block: 'nearest' });
91+
}
92+
93+
// Detect @-mention triggers in the editor content.
94+
// For single-paragraph content, PM position = text index + 1.
95+
function handleMentionDetection(e: Editor) {
96+
const text = e.getText();
97+
const { from } = e.state.selection;
98+
const cursorTextIndex = from - 1;
99+
100+
if (showMention) {
101+
if (cursorTextIndex <= mentionStartIndex || text[mentionStartIndex] !== '@') {
102+
closeMention();
103+
return;
104+
}
105+
const query = text.slice(mentionStartIndex + 1, cursorTextIndex);
106+
if (mentionDebounceTimer) clearTimeout(mentionDebounceTimer);
107+
mentionDebounceTimer = setTimeout(() => queryFiles(query), 100);
108+
} else {
109+
if (cursorTextIndex > 0 && text[cursorTextIndex - 1] === '@') {
110+
const charBefore = cursorTextIndex >= 2 ? text[cursorTextIndex - 2] : undefined;
111+
if (charBefore === undefined || /\s/.test(charBefore)) {
112+
mentionStartIndex = cursorTextIndex - 1;
113+
showMention = true;
114+
if (mentionDebounceTimer) clearTimeout(mentionDebounceTimer);
115+
mentionDebounceTimer = setTimeout(() => queryFiles(''), 100);
116+
}
117+
}
118+
}
119+
}
120+
121+
function selectMentionItem(item: { path: string; isDir: boolean }) {
122+
if (!editor) return;
123+
124+
// Delete @query from editor (PM pos = text index + 1)
125+
const pmStart = mentionStartIndex + 1;
126+
const pmEnd = editor.state.selection.from;
127+
editor.chain().deleteRange({ from: pmStart, to: pmEnd }).run();
128+
129+
if (!pendingAttachments.some(a => a.path === item.path)) {
130+
pendingAttachments = [...pendingAttachments, { path: item.path, isDir: item.isDir }];
131+
}
132+
closeMention();
133+
editor.commands.focus();
134+
}
135+
136+
// TipTap extension for keyboard shortcuts (closures read latest reactive state)
54137
function createComposerKeymap() {
55138
return Extension.create({
56139
name: 'composerKeymap',
57140
addKeyboardShortcuts() {
58141
return {
142+
ArrowDown: () => {
143+
if (showMention && mentionResults.length > 0) {
144+
mentionSelectedIndex = (mentionSelectedIndex + 1) % mentionResults.length;
145+
setTimeout(scrollSelectedIntoView, 0);
146+
return true;
147+
}
148+
return false;
149+
},
150+
ArrowUp: () => {
151+
if (showMention && mentionResults.length > 0) {
152+
mentionSelectedIndex = (mentionSelectedIndex - 1 + mentionResults.length) % mentionResults.length;
153+
setTimeout(scrollSelectedIntoView, 0);
154+
return true;
155+
}
156+
return false;
157+
},
59158
Enter: () => {
159+
if (showMention && mentionResults.length > 0) {
160+
selectMentionItem(mentionResults[mentionSelectedIndex]);
161+
return true;
162+
}
60163
send();
61164
return true;
62165
},
63-
'Shift-Tab': () => {
64-
if (!isRunning) {
65-
onSetMode(agentMode === 'plan' ? 'act' : 'plan');
166+
Tab: () => {
167+
if (showMention && mentionResults.length > 0) {
168+
selectMentionItem(mentionResults[mentionSelectedIndex]);
169+
return true;
66170
}
67-
return true;
171+
return false;
68172
},
69173
Escape: () => {
174+
if (showMention) {
175+
closeMention();
176+
return true;
177+
}
70178
if (isRunning) {
71179
onStop();
180+
return true;
181+
}
182+
return false;
183+
},
184+
'Shift-Tab': () => {
185+
if (!isRunning) {
186+
onSetMode(agentMode === 'plan' ? 'act' : 'plan');
72187
}
73188
return true;
74189
}
@@ -77,7 +192,7 @@
77192
});
78193
}
79194
80-
// Initialize the tiptap editor when the DOM element is available
195+
// Initialize TipTap editor
81196
$effect(() => {
82197
if (!editorEl) return;
83198
@@ -87,7 +202,6 @@
87202
element: editorEl,
88203
extensions: [
89204
StarterKit.configure({
90-
// Disable features we don't need in a chat composer
91205
heading: false,
92206
blockquote: false,
93207
codeBlock: false,
@@ -115,13 +229,13 @@
115229
const file = item.getAsFile();
116230
if (file) addImageFile(file);
117231
}
118-
// If there's also text content, let tiptap handle the text paste
119232
const hasText = items.some((item) => item.type === 'text/plain');
120233
return !hasText;
121234
}
122235
},
123236
onUpdate: ({ editor: e }) => {
124237
inputText = e.getText();
238+
handleMentionDetection(e);
125239
}
126240
});
127241
@@ -208,16 +322,22 @@
208322
209323
function send() {
210324
const text = inputText.trim();
211-
if (!text && pendingImages.length === 0) return;
325+
if (!text && pendingImages.length === 0 && pendingAttachments.length === 0) return;
212326
const images =
213327
pendingImages.length > 0
214328
? pendingImages.map(({ data, mediaType }) => ({ data, mediaType }))
215329
: undefined;
330+
const attachments =
331+
pendingAttachments.length > 0
332+
? pendingAttachments.map(({ path }) => ({ path }))
333+
: undefined;
216334
for (const img of pendingImages) URL.revokeObjectURL(img.preview);
217335
pendingImages = [];
336+
pendingAttachments = [];
218337
inputText = '';
219338
editor?.commands.clearContent(true);
220-
onSend(text, images);
339+
closeMention();
340+
onSend(text, images, attachments);
221341
}
222342
</script>
223343

@@ -261,7 +381,38 @@
261381
ondragleave={handleDragLeave}
262382
ondrop={handleDrop}
263383
>
384+
{#if showMention && mentionResults.length > 0}
385+
<div data-mention-list class="absolute bottom-full left-0 z-50 mb-1 max-h-48 w-full overflow-y-auto rounded border border-border bg-surface shadow-lg">
386+
{#each mentionResults as item, i (item.path)}
387+
<button
388+
type="button"
389+
data-selected={i === mentionSelectedIndex ? '' : undefined}
390+
class={[
391+
'flex w-full cursor-pointer items-center gap-2 px-2 py-1 text-left text-xs',
392+
i === mentionSelectedIndex ? 'bg-accent/10 text-accent' : 'text-text-muted hover:bg-surface-alt'
393+
]}
394+
onmousedown={(e) => { e.preventDefault(); selectMentionItem(item); }}
395+
>
396+
<span class={[item.isDir ? 'icon-[uil--folder]' : 'icon-[uil--file]', 'size-3.5 shrink-0']}></span>
397+
<span class="truncate">{item.path}</span>
398+
</button>
399+
{/each}
400+
</div>
401+
{/if}
264402
<div bind:this={editorEl} class="composer-editor-wrapper"></div>
403+
{#if pendingAttachments.length > 0}
404+
<div class="flex flex-wrap gap-1 px-2 py-1">
405+
{#each pendingAttachments as att, i (att.path)}
406+
<span class="inline-flex items-center gap-1 rounded bg-accent/10 px-1.5 py-0.5 text-xs text-accent">
407+
<span class={[att.isDir ? 'icon-[uil--folder]' : 'icon-[uil--file]', 'size-3']}></span>
408+
{att.path}
409+
<button type="button" class="cursor-pointer" onclick={() => removeAttachment(i)} aria-label="Remove {att.path}">
410+
<span class="icon-[uil--times] size-3"></span>
411+
</button>
412+
</span>
413+
{/each}
414+
</div>
415+
{/if}
265416
<div class="flex items-center gap-1 px-2 pb-1.5">
266417
<input
267418
bind:this={fileInputEl}
@@ -291,7 +442,7 @@
291442
{/if}
292443
<button
293444
onclick={send}
294-
disabled={!inputText.trim() && pendingImages.length === 0}
445+
disabled={!inputText.trim() && pendingImages.length === 0 && pendingAttachments.length === 0}
295446
class="cursor-pointer rounded p-1 text-text-muted transition-colors hover:bg-surface hover:text-text
296447
disabled:cursor-not-allowed disabled:opacity-30"
297448
aria-label="Send message"

0 commit comments

Comments
 (0)