diff --git a/.beans/beans-uk55--mention-filedirectory-attachment-in-agent-chat.md b/.beans/beans-uk55--mention-filedirectory-attachment-in-agent-chat.md new file mode 100644 index 00000000..17a73c63 --- /dev/null +++ b/.beans/beans-uk55--mention-filedirectory-attachment-in-agent-chat.md @@ -0,0 +1,30 @@ +--- +# beans-uk55 +title: '@ mention file/directory attachment in agent chat' +status: completed +type: feature +priority: normal +created_at: 2026-03-21T09:33:57Z +updated_at: 2026-03-21T09:46:22Z +--- + +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. + +## Summary of Changes + +Implemented @-mention file/directory attachment in the agent chat composer: + +### Backend +- Added `listFiles` GraphQL query that uses `git ls-files` to list tracked files, filtered by prefix, with directory deduplication (one level of depth) +- Extended `sendAgentMessage` mutation to accept `attachments: [FileAttachmentInput!]` — attached paths are prepended as context hints in the user message +- Added `FileEntry` type and `FileAttachmentInput` input to the GraphQL schema +- Added unit tests for `ListFiles` resolver + +### Frontend +- Added @-detection in the composer textarea — typing `@` (preceded by whitespace or start-of-text) opens an autocomplete dropdown +- Dropdown queries the backend via `ListFiles` with debounced input (100ms) +- Keyboard navigation: ArrowUp/Down to navigate, Enter/Tab to select, Escape to close +- Selecting a file adds it as a pill below the textarea and removes the @query text +- Selecting a directory replaces the query to drill deeper (keeps dropdown open) +- Pending attachments shown as removable pills with file/folder icons +- Attachments are passed through to the GraphQL mutation via the store diff --git a/frontend/src/lib/agentChat.svelte.ts b/frontend/src/lib/agentChat.svelte.ts index 0c2a323f..697ce9a5 100644 --- a/frontend/src/lib/agentChat.svelte.ts +++ b/frontend/src/lib/agentChat.svelte.ts @@ -18,6 +18,7 @@ import { type SubagentActivity as GqlSubagentActivity, type InteractionType, type ImageInput, + type FileAttachmentInput, } from './graphql/generated'; export type AgentMessageImage = GqlAgentMessageImage; @@ -29,6 +30,7 @@ export type PendingInteraction = GqlPendingInteraction; export type SubagentActivity = GqlSubagentActivity; export type AgentSession = AgentSessionFieldsFragment; export type ImageUploadInput = ImageInput; +export type FileAttachment = FileAttachmentInput; export class AgentChatStore { session = $state(null); @@ -104,7 +106,8 @@ export class AgentChatStore { async sendMessage( beanId: string, message: string, - images?: ImageUploadInput[] + images?: ImageUploadInput[], + attachments?: FileAttachment[] ): Promise { this.sending = true; this.error = null; @@ -119,6 +122,7 @@ export class AgentChatStore { role: AgentMessageRole.User, content: message, images: [], + attachments: attachments?.map(a => a.path) ?? [], diff: null } ] @@ -129,7 +133,8 @@ export class AgentChatStore { .mutation(SendAgentMessageDocument, { beanId, message, - images: images ?? null + images: images ?? null, + attachments: attachments?.length ? attachments : null }) .toPromise(); diff --git a/frontend/src/lib/components/AgentChat.svelte b/frontend/src/lib/components/AgentChat.svelte index 2a6badc7..f9cbf408 100644 --- a/frontend/src/lib/components/AgentChat.svelte +++ b/frontend/src/lib/components/AgentChat.svelte @@ -89,7 +89,8 @@ {systemStatus} {subagentActivities} {quickReplies} - onSend={(text, images) => { internalScrollTrigger++; store.sendMessage(beanId, text, images); }} + workspaceId={beanId} + onSend={(text, images, attachments) => { internalScrollTrigger++; store.sendMessage(beanId, text, images, attachments); }} onStop={() => store.stop(beanId)} onSetMode={setAgentMode} onSetEffort={(effort) => store.setEffort(beanId, effort)} diff --git a/frontend/src/lib/components/AgentComposer.svelte b/frontend/src/lib/components/AgentComposer.svelte index feaf11dd..0d4cfe03 100644 --- a/frontend/src/lib/components/AgentComposer.svelte +++ b/frontend/src/lib/components/AgentComposer.svelte @@ -3,12 +3,16 @@ import { Editor, Extension } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import Placeholder from '@tiptap/extension-placeholder'; + import { FileMention } from '$lib/tiptap/FileMentionNode'; + import { ListFilesDocument } from '$lib/graphql/generated'; + import { client } from '$lib/graphqlClient'; const MAX_IMAGE_SIZE = 5 * 1024 * 1024; const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; interface Props { beanId: string; + workspaceId: string; isRunning: boolean; hasMessages: boolean; agentMode: 'plan' | 'act'; @@ -16,7 +20,7 @@ systemStatus: string | null; subagentActivities: SubagentActivity[]; quickReplies: string[]; - onSend: (message: string, images?: { data: string; mediaType: string }[]) => void; + onSend: (message: string, images?: { data: string; mediaType: string }[], attachments?: { path: string }[]) => void; onStop: () => void; onSetMode: (mode: 'plan' | 'act') => void; onSetEffort: (effort: string) => void; @@ -26,6 +30,7 @@ let { beanId, + workspaceId, isRunning, hasMessages, agentMode, @@ -43,32 +48,193 @@ const inputStorageKey = $derived(`agent-chat-input:${beanId}`); let inputText = $state(''); + let hasAttachments = $state(false); let pendingImages = $state<{ data: string; mediaType: string; preview: string }[]>([]); let isDragging = $state(false); let fileInputEl: HTMLInputElement | undefined = $state(); let editorEl: HTMLDivElement | undefined = $state(); let editor: Editor | undefined = $state(); - // Create a tiptap extension for keyboard shortcuts that need access to component state. - // We use closures so the handlers always read the latest reactive values. + // @-mention autocomplete state + let showMention = $state(false); + let mentionResults = $state<{ path: string }[]>([]); + let mentionSelectedIndex = $state(0); + let mentionStartPos = $state(-1); // ProseMirror position of the @ + // Full file list fetched once per mention session, filtered client-side + let allFiles: { path: string }[] = []; + let allFilesLoaded = false; + + const MIN_QUERY_LENGTH = 2; // Don't fetch until user types at least this many chars + + function closeMention() { + showMention = false; + mentionResults = []; + mentionSelectedIndex = 0; + mentionStartPos = -1; + allFiles = []; + allFilesLoaded = false; + } + + function filterFiles(query: string) { + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + if (terms.length === 0) { + mentionResults = allFiles.slice(0, 50); + } else { + const filtered: typeof allFiles = []; + for (const file of allFiles) { + const lower = file.path.toLowerCase(); + if (terms.every(t => lower.includes(t))) { + filtered.push(file); + if (filtered.length >= 50) break; + } + } + mentionResults = filtered; + } + mentionSelectedIndex = 0; + } + + async function fetchAllFiles() { + if (allFilesLoaded) return; + const result = await client.query(ListFilesDocument, { + workspaceId, + prefix: '', + limit: null + }, { requestPolicy: 'network-only' }).toPromise(); + if (result.data?.listFiles) { + allFiles = result.data.listFiles; + allFilesLoaded = true; + } + } + + function scrollSelectedIntoView() { + const container = document.querySelector('[data-mention-list]'); + const selected = container?.querySelector('[data-selected]'); + selected?.scrollIntoView({ block: 'nearest' }); + } + + // Detect @-mention triggers using ProseMirror positions directly. + // This avoids text-index math that breaks with inline atom nodes. + function handleMentionDetection(e: Editor) { + const { from } = e.state.selection; + + if (showMention) { + if (from <= mentionStartPos) { + closeMention(); + return; + } + // Verify the @ is still at the stored position + const charAtStart = e.state.doc.textBetween(mentionStartPos, mentionStartPos + 1); + if (charAtStart !== '@') { + closeMention(); + return; + } + const query = e.state.doc.textBetween(mentionStartPos + 1, from); + if (query.length < MIN_QUERY_LENGTH) { + mentionResults = []; + return; + } + if (!allFilesLoaded) { + // First time reaching the threshold — fetch the full list, then filter + fetchAllFiles().then(() => filterFiles(query)); + } else { + filterFiles(query); + } + } else { + if (from > 1) { + const charBefore = e.state.doc.textBetween(from - 1, from); + if (charBefore === '@') { + const charBeforeThat = from > 2 ? e.state.doc.textBetween(from - 2, from - 1) : ''; + if (!charBeforeThat || /\s/.test(charBeforeThat)) { + mentionStartPos = from - 1; + showMention = true; + } + } + } + } + } + + function selectMentionItem(item: { path: string }) { + if (!editor) return; + + const { from } = editor.state.selection; + + // Replace @query with an inline fileMention node + editor.chain() + .deleteRange({ from: mentionStartPos, to: from }) + .insertContentAt(mentionStartPos, [ + { type: 'fileMention', attrs: { path: item.path } }, + { type: 'text', text: ' ' } + ]) + .run(); + + closeMention(); + editor.commands.focus(); + } + + // Extract all fileMention nodes from the editor document + function extractAttachments(e: Editor): { path: string }[] { + const attachments: { path: string }[] = []; + const seen = new Set(); + e.state.doc.descendants((node) => { + if (node.type.name === 'fileMention' && node.attrs.path && !seen.has(node.attrs.path)) { + seen.add(node.attrs.path); + attachments.push({ path: node.attrs.path }); + } + }); + return attachments; + } + + // TipTap extension for keyboard shortcuts (closures read latest reactive state) function createComposerKeymap() { return Extension.create({ name: 'composerKeymap', addKeyboardShortcuts() { return { + ArrowDown: () => { + if (showMention && mentionResults.length > 0) { + mentionSelectedIndex = (mentionSelectedIndex + 1) % mentionResults.length; + setTimeout(scrollSelectedIntoView, 0); + return true; + } + return false; + }, + ArrowUp: () => { + if (showMention && mentionResults.length > 0) { + mentionSelectedIndex = (mentionSelectedIndex - 1 + mentionResults.length) % mentionResults.length; + setTimeout(scrollSelectedIntoView, 0); + return true; + } + return false; + }, Enter: () => { + if (showMention && mentionResults.length > 0) { + selectMentionItem(mentionResults[mentionSelectedIndex]); + return true; + } send(); return true; }, - 'Shift-Tab': () => { - if (!isRunning) { - onSetMode(agentMode === 'plan' ? 'act' : 'plan'); + Tab: () => { + if (showMention && mentionResults.length > 0) { + selectMentionItem(mentionResults[mentionSelectedIndex]); + return true; } - return true; + return false; }, Escape: () => { + if (showMention) { + closeMention(); + return true; + } if (isRunning) { onStop(); + return true; + } + return false; + }, + 'Shift-Tab': () => { + if (!isRunning) { + onSetMode(agentMode === 'plan' ? 'act' : 'plan'); } return true; } @@ -77,7 +243,7 @@ }); } - // Initialize the tiptap editor when the DOM element is available + // Initialize TipTap editor $effect(() => { if (!editorEl) return; @@ -87,7 +253,6 @@ element: editorEl, extensions: [ StarterKit.configure({ - // Disable features we don't need in a chat composer heading: false, blockquote: false, codeBlock: false, @@ -99,6 +264,7 @@ Placeholder.configure({ placeholder: 'Send a message...' }), + FileMention, createComposerKeymap() ], content: initialContent ? `

${initialContent.replace(/\n/g, '
')}

` : '', @@ -115,13 +281,14 @@ const file = item.getAsFile(); if (file) addImageFile(file); } - // If there's also text content, let tiptap handle the text paste const hasText = items.some((item) => item.type === 'text/plain'); return !hasText; } }, onUpdate: ({ editor: e }) => { inputText = e.getText(); + hasAttachments = extractAttachments(e).length > 0; + handleMentionDetection(e); } }); @@ -207,17 +374,29 @@ } function send() { - const text = inputText.trim(); - if (!text && pendingImages.length === 0) return; + if (!editor) return; + // Serialize with inline markers for file mentions so they appear + // at the correct position in the displayed message + const text = editor.getText({ + blockSeparator: '\n', + textSerializers: { + fileMention: ({ node }: { node: { attrs: { path: string } } }) => + `{{file:${node.attrs.path}}}` + } + }).trim(); + const attachments = extractAttachments(editor); + if (!text && pendingImages.length === 0 && attachments.length === 0) return; const images = pendingImages.length > 0 ? pendingImages.map(({ data, mediaType }) => ({ data, mediaType })) : undefined; for (const img of pendingImages) URL.revokeObjectURL(img.preview); pendingImages = []; + hasAttachments = false; inputText = ''; - editor?.commands.clearContent(true); - onSend(text, images); + editor.commands.clearContent(true); + closeMention(); + onSend(text, images, attachments.length > 0 ? attachments : undefined); } @@ -261,6 +440,24 @@ ondragleave={handleDragLeave} ondrop={handleDrop} > + {#if showMention && mentionResults.length > 0} +
+ {#each mentionResults as item, i (item.path)} + + {/each} +
+ {/if}
diff --git a/frontend/src/lib/components/AgentMessages.svelte b/frontend/src/lib/components/AgentMessages.svelte index 4930a72d..952cc6db 100644 --- a/frontend/src/lib/components/AgentMessages.svelte +++ b/frontend/src/lib/components/AgentMessages.svelte @@ -84,6 +84,13 @@ // instead of being interpreted as HTML tags by the markdown parser. const content = msg.role === 'USER' ? escapeHtml(msg.content) : msg.content; renderMarkdown(content).then((html) => { + // Replace {{file:path}} markers with inline pills for user messages + if (msg.role === 'USER') { + html = html.replace( + /\{\{file:([^}]+)\}\}/g, + '$1' + ); + } renderedMessages = new Map(renderedMessages).set(key, html); }); } diff --git a/frontend/src/lib/graphql/generated.ts b/frontend/src/lib/graphql/generated.ts index f3c1a9f3..9440b811 100644 --- a/frontend/src/lib/graphql/generated.ts +++ b/frontend/src/lib/graphql/generated.ts @@ -41,6 +41,8 @@ export type AgentAction = { /** A single message in an agent conversation */ export type AgentMessage = { + /** File/directory paths attached via @-mention (only present on user messages) */ + attachments: Array; /** Text content */ content: Scalars['String']['output']; /** Unified diff output (only present on tool messages for Write/Edit tools) */ @@ -327,6 +329,12 @@ export type CreateBeanInput = { type?: InputMaybe; }; +/** Input for attaching a file or directory as context to an agent message. */ +export type FileAttachmentInput = { + /** Relative file or directory path */ + path: Scalars['String']['input']; +}; + /** A changed file in a git working tree */ export type FileChange = { /** Number of added lines */ @@ -341,6 +349,12 @@ export type FileChange = { status: Scalars['String']['output']; }; +/** A file entry in the project, used for @-mention autocomplete. */ +export type FileEntry = { + /** Relative path from the workspace root */ + path: Scalars['String']['output']; +}; + /** Input for uploading an image attachment */ export type ImageInput = { /** Base64-encoded image data */ @@ -535,6 +549,7 @@ export type MutationSaveBeanArgs = { export type MutationSendAgentMessageArgs = { + attachments?: InputMaybe>; beanId: Scalars['ID']['input']; images?: InputMaybe>; message: Scalars['String']['input']; @@ -674,6 +689,12 @@ export type Query = { hasDirtyBeans: Scalars['Boolean']['output']; /** Check whether a run session is alive for a workspace. */ isRunning: Scalars['Boolean']['output']; + /** + * List files tracked by git in a workspace directory. + * Performs case-insensitive substring matching — space-separated terms must all + * match somewhere in the file path. Used for @-mention autocomplete. + */ + listFiles: Array; /** The current branch of the main repository. */ mainBranch: Scalars['String']['output']; /** @@ -759,6 +780,13 @@ export type QueryIsRunningArgs = { }; +export type QueryListFilesArgs = { + limit?: InputMaybe; + prefix: Scalars['String']['input']; + workspaceId?: InputMaybe; +}; + + export type QueryWorkspacePortArgs = { workspaceId: Scalars['ID']['input']; }; @@ -912,7 +940,7 @@ export type BeanFieldsFragment = { id: string, slug?: string | null, path: strin export type WorktreeFieldsFragment = { id: string, name?: string | null, description?: string | null, branch: string, path: string, setupStatus?: WorktreeSetupStatus | null, setupError?: string | null, beans: Array<{ id: string }>, pullRequest?: { number: number, title: string, state: string, url: string, isDraft: boolean, checkStatus: string, reviewApproved: boolean, mergeable: boolean } | null }; -export type AgentSessionFieldsFragment = { beanId: string, agentType: string, status: AgentSessionStatus, error?: string | null, effort?: string | null, planMode: boolean, actMode: boolean, systemStatus?: string | null, workDir?: string | null, quickReplies: Array, messages: Array<{ role: AgentMessageRole, content: string, diff?: string | null, images: Array<{ url: string, mediaType: string }> }>, pendingInteraction?: { type: InteractionType, planContent?: string | null, questions?: Array<{ header: string, question: string, multiSelect: boolean, options: Array<{ label: string, description: string }> }> | null } | null, subagentActivities: Array<{ taskId: string, index: number, description: string, currentTool: string }> }; +export type AgentSessionFieldsFragment = { beanId: string, agentType: string, status: AgentSessionStatus, error?: string | null, effort?: string | null, planMode: boolean, actMode: boolean, systemStatus?: string | null, workDir?: string | null, quickReplies: Array, messages: Array<{ role: AgentMessageRole, content: string, attachments: Array, diff?: string | null, images: Array<{ url: string, mediaType: string }> }>, pendingInteraction?: { type: InteractionType, planContent?: string | null, questions?: Array<{ header: string, question: string, multiSelect: boolean, options: Array<{ label: string, description: string }> }> | null } | null, subagentActivities: Array<{ taskId: string, index: number, description: string, currentTool: string }> }; export type FileChangeFieldsFragment = { path: string, status: string, additions: number, deletions: number, staged: boolean }; @@ -935,7 +963,7 @@ export type AgentSessionChangedSubscriptionVariables = Exact<{ }>; -export type AgentSessionChangedSubscription = { agentSessionChanged: { beanId: string, agentType: string, status: AgentSessionStatus, error?: string | null, effort?: string | null, planMode: boolean, actMode: boolean, systemStatus?: string | null, workDir?: string | null, quickReplies: Array, messages: Array<{ role: AgentMessageRole, content: string, diff?: string | null, images: Array<{ url: string, mediaType: string }> }>, pendingInteraction?: { type: InteractionType, planContent?: string | null, questions?: Array<{ header: string, question: string, multiSelect: boolean, options: Array<{ label: string, description: string }> }> | null } | null, subagentActivities: Array<{ taskId: string, index: number, description: string, currentTool: string }> } }; +export type AgentSessionChangedSubscription = { agentSessionChanged: { beanId: string, agentType: string, status: AgentSessionStatus, error?: string | null, effort?: string | null, planMode: boolean, actMode: boolean, systemStatus?: string | null, workDir?: string | null, quickReplies: Array, messages: Array<{ role: AgentMessageRole, content: string, attachments: Array, diff?: string | null, images: Array<{ url: string, mediaType: string }> }>, pendingInteraction?: { type: InteractionType, planContent?: string | null, questions?: Array<{ header: string, question: string, multiSelect: boolean, options: Array<{ label: string, description: string }> }> | null } | null, subagentActivities: Array<{ taskId: string, index: number, description: string, currentTool: string }> } }; export type ActiveAgentStatusesSubscriptionVariables = Exact<{ [key: string]: never; }>; @@ -1066,11 +1094,21 @@ export type SendAgentMessageMutationVariables = Exact<{ beanId: Scalars['ID']['input']; message: Scalars['String']['input']; images?: InputMaybe | ImageInput>; + attachments?: InputMaybe | FileAttachmentInput>; }>; export type SendAgentMessageMutation = { sendAgentMessage: boolean }; +export type ListFilesQueryVariables = Exact<{ + workspaceId?: InputMaybe; + prefix: Scalars['String']['input']; + limit?: InputMaybe; +}>; + + +export type ListFilesQuery = { listFiles: Array<{ path: string }> }; + export type StopAgentMutationVariables = Exact<{ beanId: Scalars['ID']['input']; }>; @@ -1171,12 +1209,12 @@ export type WorkspacePortQuery = { workspacePort: number }; export const BeanFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BeanFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Bean"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"parentId"}},{"kind":"Field","name":{"kind":"Name","value":"blockingIds"}},{"kind":"Field","name":{"kind":"Name","value":"worktreeId"}}]}}]} as unknown as DocumentNode; export const WorktreeFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorktreeFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Worktree"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"branch"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"beans"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"setupStatus"}},{"kind":"Field","name":{"kind":"Name","value":"setupError"}},{"kind":"Field","name":{"kind":"Name","value":"pullRequest"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"number"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isDraft"}},{"kind":"Field","name":{"kind":"Name","value":"checkStatus"}},{"kind":"Field","name":{"kind":"Name","value":"reviewApproved"}},{"kind":"Field","name":{"kind":"Name","value":"mergeable"}}]}}]}}]} as unknown as DocumentNode; -export const AgentSessionFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AgentSessionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AgentSession"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"beanId"}},{"kind":"Field","name":{"kind":"Name","value":"agentType"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"mediaType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"diff"}}]}},{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"effort"}},{"kind":"Field","name":{"kind":"Name","value":"planMode"}},{"kind":"Field","name":{"kind":"Name","value":"actMode"}},{"kind":"Field","name":{"kind":"Name","value":"systemStatus"}},{"kind":"Field","name":{"kind":"Name","value":"pendingInteraction"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"planContent"}},{"kind":"Field","name":{"kind":"Name","value":"questions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"question"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelect"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"workDir"}},{"kind":"Field","name":{"kind":"Name","value":"subagentActivities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"taskId"}},{"kind":"Field","name":{"kind":"Name","value":"index"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"currentTool"}}]}},{"kind":"Field","name":{"kind":"Name","value":"quickReplies"}}]}}]} as unknown as DocumentNode; +export const AgentSessionFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AgentSessionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AgentSession"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"beanId"}},{"kind":"Field","name":{"kind":"Name","value":"agentType"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"mediaType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"attachments"}},{"kind":"Field","name":{"kind":"Name","value":"diff"}}]}},{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"effort"}},{"kind":"Field","name":{"kind":"Name","value":"planMode"}},{"kind":"Field","name":{"kind":"Name","value":"actMode"}},{"kind":"Field","name":{"kind":"Name","value":"systemStatus"}},{"kind":"Field","name":{"kind":"Name","value":"pendingInteraction"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"planContent"}},{"kind":"Field","name":{"kind":"Name","value":"questions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"question"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelect"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"workDir"}},{"kind":"Field","name":{"kind":"Name","value":"subagentActivities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"taskId"}},{"kind":"Field","name":{"kind":"Name","value":"index"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"currentTool"}}]}},{"kind":"Field","name":{"kind":"Name","value":"quickReplies"}}]}}]} as unknown as DocumentNode; export const FileChangeFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FileChangeFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FileChange"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"additions"}},{"kind":"Field","name":{"kind":"Name","value":"deletions"}},{"kind":"Field","name":{"kind":"Name","value":"staged"}}]}}]} as unknown as DocumentNode; export const AgentActionFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AgentActionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AgentAction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"disabled"}},{"kind":"Field","name":{"kind":"Name","value":"disabledReason"}}]}}]} as unknown as DocumentNode; export const BeanChangedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"BeanChanged"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"includeInitial"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"beanChanged"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"includeInitial"},"value":{"kind":"Variable","name":{"kind":"Name","value":"includeInitial"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"beanId"}},{"kind":"Field","name":{"kind":"Name","value":"bean"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BeanFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"beans"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BeanFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BeanFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Bean"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"parentId"}},{"kind":"Field","name":{"kind":"Name","value":"blockingIds"}},{"kind":"Field","name":{"kind":"Name","value":"worktreeId"}}]}}]} as unknown as DocumentNode; export const WorktreesChangedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"WorktreesChanged"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"worktreesChanged"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorktreeFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorktreeFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Worktree"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"branch"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"beans"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"setupStatus"}},{"kind":"Field","name":{"kind":"Name","value":"setupError"}},{"kind":"Field","name":{"kind":"Name","value":"pullRequest"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"number"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isDraft"}},{"kind":"Field","name":{"kind":"Name","value":"checkStatus"}},{"kind":"Field","name":{"kind":"Name","value":"reviewApproved"}},{"kind":"Field","name":{"kind":"Name","value":"mergeable"}}]}}]}}]} as unknown as DocumentNode; -export const AgentSessionChangedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"AgentSessionChanged"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"agentSessionChanged"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"beanId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AgentSessionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AgentSessionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AgentSession"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"beanId"}},{"kind":"Field","name":{"kind":"Name","value":"agentType"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"mediaType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"diff"}}]}},{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"effort"}},{"kind":"Field","name":{"kind":"Name","value":"planMode"}},{"kind":"Field","name":{"kind":"Name","value":"actMode"}},{"kind":"Field","name":{"kind":"Name","value":"systemStatus"}},{"kind":"Field","name":{"kind":"Name","value":"pendingInteraction"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"planContent"}},{"kind":"Field","name":{"kind":"Name","value":"questions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"question"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelect"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"workDir"}},{"kind":"Field","name":{"kind":"Name","value":"subagentActivities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"taskId"}},{"kind":"Field","name":{"kind":"Name","value":"index"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"currentTool"}}]}},{"kind":"Field","name":{"kind":"Name","value":"quickReplies"}}]}}]} as unknown as DocumentNode; +export const AgentSessionChangedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"AgentSessionChanged"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"agentSessionChanged"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"beanId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AgentSessionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AgentSessionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AgentSession"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"beanId"}},{"kind":"Field","name":{"kind":"Name","value":"agentType"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"mediaType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"attachments"}},{"kind":"Field","name":{"kind":"Name","value":"diff"}}]}},{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"effort"}},{"kind":"Field","name":{"kind":"Name","value":"planMode"}},{"kind":"Field","name":{"kind":"Name","value":"actMode"}},{"kind":"Field","name":{"kind":"Name","value":"systemStatus"}},{"kind":"Field","name":{"kind":"Name","value":"pendingInteraction"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"planContent"}},{"kind":"Field","name":{"kind":"Name","value":"questions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"question"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelect"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"workDir"}},{"kind":"Field","name":{"kind":"Name","value":"subagentActivities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"taskId"}},{"kind":"Field","name":{"kind":"Name","value":"index"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"currentTool"}}]}},{"kind":"Field","name":{"kind":"Name","value":"quickReplies"}}]}}]} as unknown as DocumentNode; export const ActiveAgentStatusesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"ActiveAgentStatuses"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeAgentStatuses"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"beanId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; export const WorkspaceStatusesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"WorkspaceStatuses"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceStatuses"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasChanges"}},{"kind":"Field","name":{"kind":"Name","value":"hasUnmergedCommits"}}]}}]}}]} as unknown as DocumentNode; export const ConfigDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectName"}},{"kind":"Field","name":{"kind":"Name","value":"mainBranch"}},{"kind":"Field","name":{"kind":"Name","value":"agentEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"worktreeBaseRef"}},{"kind":"Field","name":{"kind":"Name","value":"worktreeRunCommand"}},{"kind":"Field","name":{"kind":"Name","value":"worktreeIntegrateMode"}}]}}]} as unknown as DocumentNode; @@ -1195,7 +1233,8 @@ export const DeleteBeanDocument = {"kind":"Document","definitions":[{"kind":"Ope export const ArchiveBeanDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ArchiveBean"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archiveBean"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; export const CreateWorktreeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateWorktree"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createWorktree"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorktreeFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorktreeFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Worktree"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"branch"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"beans"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"setupStatus"}},{"kind":"Field","name":{"kind":"Name","value":"setupError"}},{"kind":"Field","name":{"kind":"Name","value":"pullRequest"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"number"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isDraft"}},{"kind":"Field","name":{"kind":"Name","value":"checkStatus"}},{"kind":"Field","name":{"kind":"Name","value":"reviewApproved"}},{"kind":"Field","name":{"kind":"Name","value":"mergeable"}}]}}]}}]} as unknown as DocumentNode; export const RemoveWorktreeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveWorktree"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeWorktree"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; -export const SendAgentMessageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SendAgentMessage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"message"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"images"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImageInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sendAgentMessage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"beanId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}}},{"kind":"Argument","name":{"kind":"Name","value":"message"},"value":{"kind":"Variable","name":{"kind":"Name","value":"message"}}},{"kind":"Argument","name":{"kind":"Name","value":"images"},"value":{"kind":"Variable","name":{"kind":"Name","value":"images"}}}]}]}}]} as unknown as DocumentNode; +export const SendAgentMessageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SendAgentMessage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"message"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"images"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImageInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"attachments"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FileAttachmentInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sendAgentMessage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"beanId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}}},{"kind":"Argument","name":{"kind":"Name","value":"message"},"value":{"kind":"Variable","name":{"kind":"Name","value":"message"}}},{"kind":"Argument","name":{"kind":"Name","value":"images"},"value":{"kind":"Variable","name":{"kind":"Name","value":"images"}}},{"kind":"Argument","name":{"kind":"Name","value":"attachments"},"value":{"kind":"Variable","name":{"kind":"Name","value":"attachments"}}}]}]}}]} as unknown as DocumentNode; +export const ListFilesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListFiles"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"listFiles"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"prefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}}]}}]}}]} as unknown as DocumentNode; export const StopAgentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StopAgent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stopAgent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"beanId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}}}]}]}}]} as unknown as DocumentNode; export const SetAgentPlanModeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetAgentPlanMode"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"planMode"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setAgentPlanMode"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"beanId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}}},{"kind":"Argument","name":{"kind":"Name","value":"planMode"},"value":{"kind":"Variable","name":{"kind":"Name","value":"planMode"}}}]}]}}]} as unknown as DocumentNode; export const SetAgentActModeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetAgentActMode"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"actMode"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setAgentActMode"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"beanId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"beanId"}}},{"kind":"Argument","name":{"kind":"Name","value":"actMode"},"value":{"kind":"Variable","name":{"kind":"Name","value":"actMode"}}}]}]}}]} as unknown as DocumentNode; diff --git a/frontend/src/lib/graphql/operations.graphql b/frontend/src/lib/graphql/operations.graphql index d4e6090a..26f3b318 100644 --- a/frontend/src/lib/graphql/operations.graphql +++ b/frontend/src/lib/graphql/operations.graphql @@ -54,6 +54,7 @@ fragment AgentSessionFields on AgentSession { url mediaType } + attachments diff } error @@ -249,8 +250,14 @@ mutation RemoveWorktree($id: ID!) { removeWorktree(id: $id) } -mutation SendAgentMessage($beanId: ID!, $message: String!, $images: [ImageInput!]) { - sendAgentMessage(beanId: $beanId, message: $message, images: $images) +mutation SendAgentMessage($beanId: ID!, $message: String!, $images: [ImageInput!], $attachments: [FileAttachmentInput!]) { + sendAgentMessage(beanId: $beanId, message: $message, images: $images, attachments: $attachments) +} + +query ListFiles($workspaceId: ID, $prefix: String!, $limit: Int) { + listFiles(workspaceId: $workspaceId, prefix: $prefix, limit: $limit) { + path + } } mutation StopAgent($beanId: ID!) { diff --git a/frontend/src/lib/tiptap/FileMentionNode.ts b/frontend/src/lib/tiptap/FileMentionNode.ts new file mode 100644 index 00000000..f4353f5b --- /dev/null +++ b/frontend/src/lib/tiptap/FileMentionNode.ts @@ -0,0 +1,39 @@ +import { Node, mergeAttributes } from '@tiptap/core'; + +/** + * Inline atom node for file mentions in the agent chat composer. + * Renders as a styled pill showing the file path. Non-editable — + * backspace deletes the entire node as a unit. + */ +export const FileMention = Node.create({ + name: 'fileMention', + group: 'inline', + inline: true, + atom: true, + + addAttributes() { + return { + path: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('data-path'), + renderHTML: (attributes: Record) => ({ 'data-path': attributes.path }) + } + }; + }, + + parseHTML() { + return [{ tag: 'span[data-file-mention]' }]; + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + 'span', + mergeAttributes(HTMLAttributes, { + 'data-file-mention': '', + class: 'file-mention-pill', + contenteditable: 'false' + }), + node.attrs.path + ]; + } +}); diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index 0c20658f..0ceda21a 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -106,6 +106,18 @@ a, @apply inline-flex cursor-pointer rounded bg-accent/10 px-1 align-baseline font-mono text-[0.85em] whitespace-nowrap text-accent no-underline transition-colors hover:border-accent/45 hover:bg-accent/20; } +/* ── File mention pill (composer + message display) ── */ +.file-mention-pill { + display: inline; + border-radius: var(--radius-sm); + background-color: color-mix(in srgb, var(--th-accent) 15%, transparent); + padding: 1px 0.375rem; + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.625; + color: var(--th-accent); +} + /* ── Prose: markdown body styling ────────────── */ .prose { @apply leading-relaxed text-text; diff --git a/internal/agent/claude.go b/internal/agent/claude.go index 7e310401..520521e3 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -164,8 +164,9 @@ func (m *Manager) spawnAndRun(beanID string, session *Session) { log.Printf("[agent:%s] spawned claude process (pid=%d, dir=%s)", beanID, cmd.Process.Pid, session.WorkDir) // Send the initial user message, prepending bean context on first spawn + // and any file attachment context from @-mentions lastMsg := session.Messages[len(session.Messages)-1] - initialMsg := lastMsg.Content + initialMsg := lastMsg.ContextPrefix + lastMsg.Content if session.SessionID == "" && m.contextProvider != nil { if ctx := m.contextProvider(beanID); ctx != "" { initialMsg = ctx + "\n\n---\n\n" + initialMsg diff --git a/internal/agent/manager.go b/internal/agent/manager.go index d48aaadd..5634b553 100644 --- a/internal/agent/manager.go +++ b/internal/agent/manager.go @@ -3,6 +3,7 @@ package agent import ( "fmt" "log" + "strings" "sync" ) @@ -160,7 +161,7 @@ func (m *Manager) GetSession(beanID string) *Session { // SendMessage sends a user message to the agent for the given worktree. // If no session exists, one is created. If no process is running, one is spawned. // Images are optional base64-decoded uploads that will be stored and forwarded to Claude. -func (m *Manager) SendMessage(beanID, workDir, message string, images []ImageUpload) error { +func (m *Manager) SendMessage(beanID, workDir, message string, images []ImageUpload, attachmentPaths ...string) error { // Save images to disk before acquiring the lock var imageRefs []ImageRef if m.store != nil && len(images) > 0 { @@ -187,8 +188,16 @@ func (m *Manager) SendMessage(beanID, workDir, message string, images []ImageUpl session.WorkDir = workDir } + // Build context prefix from attached file paths (injected into Claude's + // stdin message but NOT stored in the conversation or shown in the UI) + var contextPrefix string + if len(attachmentPaths) > 0 { + contextPrefix = fmt.Sprintf("[The user has attached the following files/directories for context: %s]\n\n", + strings.Join(attachmentPaths, ", ")) + } + // Append user message and clear turn state - userMsg := Message{Role: RoleUser, Content: message, Images: imageRefs} + userMsg := Message{Role: RoleUser, Content: message, Images: imageRefs, Attachments: attachmentPaths, ContextPrefix: contextPrefix} session.Messages = append(session.Messages, userMsg) session.Error = "" session.PendingInteraction = nil @@ -223,7 +232,7 @@ func (m *Manager) SendMessage(beanID, workDir, message string, images []ImageUpl if hasProc && proc != nil { // Send message to existing process via stdin — Claude Code's stream-json // protocol handles interleaving even if the agent is mid-turn - return m.sendToProcess(proc, beanID, message, imageRefs) + return m.sendToProcess(proc, beanID, contextPrefix+message, imageRefs) } // Spawn a new process diff --git a/internal/agent/store.go b/internal/agent/store.go index 620b1e58..83d1a4be 100644 --- a/internal/agent/store.go +++ b/internal/agent/store.go @@ -37,12 +37,13 @@ type entryImage struct { // entry is a single line in the JSONL file. type entry struct { - Type string `json:"type"` // "message" or "meta" - Role string `json:"role,omitempty"` // for messages: "user" or "assistant" - Content string `json:"content,omitempty"` // for messages - Images []entryImage `json:"images,omitempty"` // for messages with attachments - Diff string `json:"diff,omitempty"` // for tool messages: unified diff output - SessionID string `json:"session_id,omitempty"` // for meta + Type string `json:"type"` // "message" or "meta" + Role string `json:"role,omitempty"` // for messages: "user" or "assistant" + Content string `json:"content,omitempty"` // for messages + Images []entryImage `json:"images,omitempty"` // for messages with image attachments + Diff string `json:"diff,omitempty"` // for tool messages: unified diff output + Attachments []string `json:"attachments,omitempty"` // file paths from @-mentions + SessionID string `json:"session_id,omitempty"` // for meta } // newStore creates the conversations directory if needed. @@ -83,9 +84,10 @@ func (s *store) load(beanID string) ([]Message, string, error) { switch e.Type { case "message": msg := Message{ - Role: MessageRole(e.Role), - Content: e.Content, - Diff: e.Diff, + Role: MessageRole(e.Role), + Content: e.Content, + Diff: e.Diff, + Attachments: e.Attachments, } for _, img := range e.Images { msg.Images = append(msg.Images, ImageRef{ID: img.ID, MediaType: img.MediaType}) @@ -104,10 +106,11 @@ func (s *store) load(beanID string) ([]Message, string, error) { // appendMessage appends a message entry to the JSONL file. func (s *store) appendMessage(beanID string, msg Message) error { e := entry{ - Type: "message", - Role: string(msg.Role), - Content: msg.Content, - Diff: msg.Diff, + Type: "message", + Role: string(msg.Role), + Content: msg.Content, + Diff: msg.Diff, + Attachments: msg.Attachments, } for _, img := range msg.Images { e.Images = append(e.Images, entryImage{ID: img.ID, MediaType: img.MediaType}) diff --git a/internal/agent/types.go b/internal/agent/types.go index 1cc7339d..7a605638 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -40,10 +40,15 @@ type ImageUpload struct { // Message represents a single chat message in an agent conversation. type Message struct { - Role MessageRole - Content string - Images []ImageRef // optional attached images (typically only on user messages) - Diff string // unified diff output (only on tool messages for Write/Edit) + Role MessageRole + Content string + Images []ImageRef // optional attached images (typically only on user messages) + Diff string // unified diff output (only on tool messages for Write/Edit) + Attachments []string // file/directory paths attached via @-mention + + // ContextPrefix is prepended to Content when sending to Claude but is NOT + // persisted to disk or displayed in the UI. Used for @-mention file context. + ContextPrefix string `json:"-"` } // ToolInvocation records a tool call with its name and input summary. diff --git a/internal/graph/agent_helpers.go b/internal/graph/agent_helpers.go index 424b396b..c530afac 100644 --- a/internal/graph/agent_helpers.go +++ b/internal/graph/agent_helpers.go @@ -34,11 +34,16 @@ func agentSessionToModel(s *agent.Session) *model.AgentSession { if m.Diff != "" { diff = &m.Diff } + attachments := m.Attachments + if attachments == nil { + attachments = []string{} + } msgs[i] = &model.AgentMessage{ - Role: role, - Content: m.Content, - Images: images, - Diff: diff, + Role: role, + Content: m.Content, + Images: images, + Attachments: attachments, + Diff: diff, } } diff --git a/internal/graph/generated.go b/internal/graph/generated.go index e6cf9f28..737ee030 100644 --- a/internal/graph/generated.go +++ b/internal/graph/generated.go @@ -65,10 +65,11 @@ type ComplexityRoot struct { } AgentMessage struct { - Content func(childComplexity int) int - Diff func(childComplexity int) int - Images func(childComplexity int) int - Role func(childComplexity int) int + Attachments func(childComplexity int) int + Content func(childComplexity int) int + Diff func(childComplexity int) int + Images func(childComplexity int) int + Role func(childComplexity int) int } AgentMessageImage struct { @@ -151,6 +152,10 @@ type ComplexityRoot struct { Status func(childComplexity int) int } + FileEntry struct { + Path func(childComplexity int) int + } + Mutation struct { AddBlockedBy func(childComplexity int, id string, targetID string, ifMatch *string) int AddBlocking func(childComplexity int, id string, targetID string, ifMatch *string) int @@ -167,7 +172,7 @@ type ComplexityRoot struct { RemoveWorktree func(childComplexity int, id string) int SaveBean func(childComplexity int, id string) int SaveDirtyBeans func(childComplexity int) int - SendAgentMessage func(childComplexity int, beanID string, message string, images []*model.ImageInput) int + SendAgentMessage func(childComplexity int, beanID string, message string, images []*model.ImageInput, attachments []*model.FileAttachmentInput) int SetAgentActMode func(childComplexity int, beanID string, actMode bool) int SetAgentEffort func(childComplexity int, beanID string, effort string) int SetAgentPendingInteraction func(childComplexity int, beanID string, typeArg model.InteractionType, planContent *string) int @@ -210,6 +215,7 @@ type ComplexityRoot struct { FileDiff func(childComplexity int, filePath string, staged bool, path *string) int HasDirtyBeans func(childComplexity int) int IsRunning func(childComplexity int, workspaceID string) int + ListFiles func(childComplexity int, workspaceID *string, prefix string, limit *int) int MainBranch func(childComplexity int) int ProjectName func(childComplexity int) int WorkspacePort func(childComplexity int, workspaceID string) int @@ -284,7 +290,7 @@ type MutationResolver interface { StopRun(ctx context.Context, workspaceID string) (bool, error) CreateWorktree(ctx context.Context, name string) (*model.Worktree, error) RemoveWorktree(ctx context.Context, id string) (bool, error) - SendAgentMessage(ctx context.Context, beanID string, message string, images []*model.ImageInput) (bool, error) + SendAgentMessage(ctx context.Context, beanID string, message string, images []*model.ImageInput, attachments []*model.FileAttachmentInput) (bool, error) StopAgent(ctx context.Context, beanID string) (bool, error) SetAgentPlanMode(ctx context.Context, beanID string, planMode bool) (bool, error) SetAgentActMode(ctx context.Context, beanID string, actMode bool) (bool, error) @@ -318,6 +324,7 @@ type QueryResolver interface { WorkspacePort(ctx context.Context, workspaceID string) (int, error) IsRunning(ctx context.Context, workspaceID string) (bool, error) WorktreeIntegrateMode(ctx context.Context) (string, error) + ListFiles(ctx context.Context, workspaceID *string, prefix string, limit *int) ([]*model.FileEntry, error) } type SubscriptionResolver interface { BeanChanged(ctx context.Context, includeInitial *bool) (<-chan *model.BeanChangeEvent, error) @@ -390,6 +397,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.AgentAction.Label(childComplexity), true + case "AgentMessage.attachments": + if e.complexity.AgentMessage.Attachments == nil { + break + } + + return e.complexity.AgentMessage.Attachments(childComplexity), true case "AgentMessage.content": if e.complexity.AgentMessage.Content == nil { break @@ -774,6 +787,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.FileChange.Status(childComplexity), true + case "FileEntry.path": + if e.complexity.FileEntry.Path == nil { + break + } + + return e.complexity.FileEntry.Path(childComplexity), true + case "Mutation.addBlockedBy": if e.complexity.Mutation.AddBlockedBy == nil { break @@ -944,7 +964,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return 0, false } - return e.complexity.Mutation.SendAgentMessage(childComplexity, args["beanId"].(string), args["message"].(string), args["images"].([]*model.ImageInput)), true + return e.complexity.Mutation.SendAgentMessage(childComplexity, args["beanId"].(string), args["message"].(string), args["images"].([]*model.ImageInput), args["attachments"].([]*model.FileAttachmentInput)), true case "Mutation.setAgentActMode": if e.complexity.Mutation.SetAgentActMode == nil { break @@ -1246,6 +1266,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Query.IsRunning(childComplexity, args["workspaceId"].(string)), true + case "Query.listFiles": + if e.complexity.Query.ListFiles == nil { + break + } + + args, err := ec.field_Query_listFiles_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.ListFiles(childComplexity, args["workspaceId"].(*string), args["prefix"].(string), args["limit"].(*int)), true case "Query.mainBranch": if e.complexity.Query.MainBranch == nil { break @@ -1469,6 +1500,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputBeanFilter, ec.unmarshalInputBodyModification, ec.unmarshalInputCreateBeanInput, + ec.unmarshalInputFileAttachmentInput, ec.unmarshalInputImageInput, ec.unmarshalInputReplaceOperation, ec.unmarshalInputUpdateBeanInput, @@ -1865,6 +1897,11 @@ func (ec *executionContext) field_Mutation_sendAgentMessage_args(ctx context.Con return nil, err } args["images"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "attachments", ec.unmarshalOFileAttachmentInput2ᚕᚖgithubᚗcomᚋhmansᚋbeansᚋpkgᚋbeangraphᚋmodelᚐFileAttachmentInputᚄ) + if err != nil { + return nil, err + } + args["attachments"] = arg3 return args, nil } @@ -2164,6 +2201,27 @@ func (ec *executionContext) field_Query_isRunning_args(ctx context.Context, rawA return args, nil } +func (ec *executionContext) field_Query_listFiles_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "workspaceId", ec.unmarshalOID2ᚖstring) + if err != nil { + return nil, err + } + args["workspaceId"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "prefix", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["prefix"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "limit", ec.unmarshalOInt2ᚖint) + if err != nil { + return nil, err + } + args["limit"] = arg2 + return args, nil +} + func (ec *executionContext) field_Query_workspacePort_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -2545,6 +2603,35 @@ func (ec *executionContext) fieldContext_AgentMessage_images(_ context.Context, return fc, nil } +func (ec *executionContext) _AgentMessage_attachments(ctx context.Context, field graphql.CollectedField, obj *model.AgentMessage) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AgentMessage_attachments, + func(ctx context.Context) (any, error) { + return obj.Attachments, nil + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AgentMessage_attachments(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AgentMessage", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _AgentMessage_diff(ctx context.Context, field graphql.CollectedField, obj *model.AgentMessage) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -2749,6 +2836,8 @@ func (ec *executionContext) fieldContext_AgentSession_messages(_ context.Context return ec.fieldContext_AgentMessage_content(ctx, field) case "images": return ec.fieldContext_AgentMessage_images(ctx, field) + case "attachments": + return ec.fieldContext_AgentMessage_attachments(ctx, field) case "diff": return ec.fieldContext_AgentMessage_diff(ctx, field) } @@ -4568,6 +4657,35 @@ func (ec *executionContext) fieldContext_FileChange_staged(_ context.Context, fi return fc, nil } +func (ec *executionContext) _FileEntry_path(ctx context.Context, field graphql.CollectedField, obj *model.FileEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_FileEntry_path, + func(ctx context.Context) (any, error) { + return obj.Path, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_FileEntry_path(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "FileEntry", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Mutation_createBean(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -5487,7 +5605,7 @@ func (ec *executionContext) _Mutation_sendAgentMessage(ctx context.Context, fiel ec.fieldContext_Mutation_sendAgentMessage, func(ctx context.Context) (any, error) { fc := graphql.GetFieldContext(ctx) - return ec.resolvers.Mutation().SendAgentMessage(ctx, fc.Args["beanId"].(string), fc.Args["message"].(string), fc.Args["images"].([]*model.ImageInput)) + return ec.resolvers.Mutation().SendAgentMessage(ctx, fc.Args["beanId"].(string), fc.Args["message"].(string), fc.Args["images"].([]*model.ImageInput), fc.Args["attachments"].([]*model.FileAttachmentInput)) }, nil, ec.marshalNBoolean2bool, @@ -7210,6 +7328,51 @@ func (ec *executionContext) fieldContext_Query_worktreeIntegrateMode(_ context.C return fc, nil } +func (ec *executionContext) _Query_listFiles(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_listFiles, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.resolvers.Query().ListFiles(ctx, fc.Args["workspaceId"].(*string), fc.Args["prefix"].(string), fc.Args["limit"].(*int)) + }, + nil, + ec.marshalNFileEntry2ᚕᚖgithubᚗcomᚋhmansᚋbeansᚋpkgᚋbeangraphᚋmodelᚐFileEntryᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_listFiles(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "path": + return ec.fieldContext_FileEntry_path(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type FileEntry", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_listFiles_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -9959,6 +10122,33 @@ func (ec *executionContext) unmarshalInputCreateBeanInput(ctx context.Context, o return it, nil } +func (ec *executionContext) unmarshalInputFileAttachmentInput(ctx context.Context, obj any) (model.FileAttachmentInput, error) { + var it model.FileAttachmentInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"path"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "path": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("path")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Path = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputImageInput(ctx context.Context, obj any) (model.ImageInput, error) { var it model.ImageInput asMap := map[string]any{} @@ -10290,6 +10480,11 @@ func (ec *executionContext) _AgentMessage(ctx context.Context, sel ast.Selection if out.Values[i] == graphql.Null { out.Invalids++ } + case "attachments": + out.Values[i] = ec._AgentMessage_attachments(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "diff": out.Values[i] = ec._AgentMessage_diff(ctx, field, obj) default: @@ -11169,6 +11364,45 @@ func (ec *executionContext) _FileChange(ctx context.Context, sel ast.SelectionSe return out } +var fileEntryImplementors = []string{"FileEntry"} + +func (ec *executionContext) _FileEntry(ctx context.Context, sel ast.SelectionSet, obj *model.FileEntry) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, fileEntryImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("FileEntry") + case "path": + out.Values[i] = ec._FileEntry_path(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var mutationImplementors = []string{"Mutation"} func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler { @@ -11940,6 +12174,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "listFiles": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_listFiles(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "__type": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { @@ -12953,6 +13209,11 @@ func (ec *executionContext) unmarshalNCreateBeanInput2githubᚗcomᚋhmansᚋbea return res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalNFileAttachmentInput2ᚖgithubᚗcomᚋhmansᚋbeansᚋpkgᚋbeangraphᚋmodelᚐFileAttachmentInput(ctx context.Context, v any) (*model.FileAttachmentInput, error) { + res, err := ec.unmarshalInputFileAttachmentInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) marshalNFileChange2ᚕᚖgithubᚗcomᚋhmansᚋbeansᚋpkgᚋbeangraphᚋmodelᚐFileChangeᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.FileChange) graphql.Marshaler { ret := make(graphql.Array, len(v)) var wg sync.WaitGroup @@ -13007,6 +13268,60 @@ func (ec *executionContext) marshalNFileChange2ᚖgithubᚗcomᚋhmansᚋbeans return ec._FileChange(ctx, sel, v) } +func (ec *executionContext) marshalNFileEntry2ᚕᚖgithubᚗcomᚋhmansᚋbeansᚋpkgᚋbeangraphᚋmodelᚐFileEntryᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.FileEntry) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNFileEntry2ᚖgithubᚗcomᚋhmansᚋbeansᚋpkgᚋbeangraphᚋmodelᚐFileEntry(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNFileEntry2ᚖgithubᚗcomᚋhmansᚋbeansᚋpkgᚋbeangraphᚋmodelᚐFileEntry(ctx context.Context, sel ast.SelectionSet, v *model.FileEntry) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._FileEntry(ctx, sel, v) +} + func (ec *executionContext) unmarshalNID2string(ctx context.Context, v any) (string, error) { res, err := graphql.UnmarshalID(v) return res, graphql.ErrorOnPath(ctx, err) @@ -13705,6 +14020,42 @@ func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast return res } +func (ec *executionContext) unmarshalOFileAttachmentInput2ᚕᚖgithubᚗcomᚋhmansᚋbeansᚋpkgᚋbeangraphᚋmodelᚐFileAttachmentInputᚄ(ctx context.Context, v any) ([]*model.FileAttachmentInput, error) { + if v == nil { + return nil, nil + } + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]*model.FileAttachmentInput, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNFileAttachmentInput2ᚖgithubᚗcomᚋhmansᚋbeansᚋpkgᚋbeangraphᚋmodelᚐFileAttachmentInput(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) unmarshalOID2ᚖstring(ctx context.Context, v any) (*string, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalID(v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOID2ᚖstring(ctx context.Context, sel ast.SelectionSet, v *string) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := graphql.MarshalID(*v) + return res +} + func (ec *executionContext) unmarshalOImageInput2ᚕᚖgithubᚗcomᚋhmansᚋbeansᚋpkgᚋbeangraphᚋmodelᚐImageInputᚄ(ctx context.Context, v any) ([]*model.ImageInput, error) { if v == nil { return nil, nil @@ -13723,6 +14074,24 @@ func (ec *executionContext) unmarshalOImageInput2ᚕᚖgithubᚗcomᚋhmansᚋbe return res, nil } +func (ec *executionContext) unmarshalOInt2ᚖint(ctx context.Context, v any) (*int, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalInt(v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOInt2ᚖint(ctx context.Context, sel ast.SelectionSet, v *int) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := graphql.MarshalInt(*v) + return res +} + func (ec *executionContext) marshalOPendingInteraction2ᚖgithubᚗcomᚋhmansᚋbeansᚋpkgᚋbeangraphᚋmodelᚐPendingInteraction(ctx context.Context, sel ast.SelectionSet, v *model.PendingInteraction) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index ce2d604b..8abfad19 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -111,6 +111,13 @@ type Query { Defaults to "local". """ worktreeIntegrateMode: String! + + """ + List files tracked by git in a workspace directory. + Performs case-insensitive substring matching — space-separated terms must all + match somewhere in the file path. Used for @-mention autocomplete. + """ + listFiles(workspaceId: ID, prefix: String!, limit: Int): [FileEntry!]! } type Subscription { @@ -269,7 +276,7 @@ type Mutation { Send a message to the agent in a worktree. Starts a session if none exists. Optionally attach images (base64-encoded). """ - sendAgentMessage(beanId: ID!, message: String!, images: [ImageInput!]): Boolean! + sendAgentMessage(beanId: ID!, message: String!, images: [ImageInput!], attachments: [FileAttachmentInput!]): Boolean! """ Stop the running agent in a worktree. @@ -762,6 +769,8 @@ type AgentMessage { content: String! "Attached images (empty for assistant/tool messages)" images: [AgentMessageImage!]! + "File/directory paths attached via @-mention (only present on user messages)" + attachments: [String!]! "Unified diff output (only present on tool messages for Write/Edit tools)" diff: String } @@ -786,6 +795,22 @@ input ImageInput { mediaType: String! } +""" +A file entry in the project, used for @-mention autocomplete. +""" +type FileEntry { + "Relative path from the workspace root" + path: String! +} + +""" +Input for attaching a file or directory as context to an agent message. +""" +input FileAttachmentInput { + "Relative file or directory path" + path: String! +} + """ Role of an agent message sender """ diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 989e5e07..54b4886c 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -261,7 +261,7 @@ func (r *mutationResolver) RemoveWorktree(ctx context.Context, id string) (bool, } // SendAgentMessage is the resolver for the sendAgentMessage field. -func (r *mutationResolver) SendAgentMessage(ctx context.Context, beanID string, message string, images []*model.ImageInput) (bool, error) { +func (r *mutationResolver) SendAgentMessage(ctx context.Context, beanID string, message string, images []*model.ImageInput, attachments []*model.FileAttachmentInput) (bool, error) { if r.AgentMgr == nil { return false, fmt.Errorf("agent manager not available") } @@ -279,6 +279,11 @@ func (r *mutationResolver) SendAgentMessage(ctx context.Context, beanID string, } } + var attachmentPaths []string + for _, a := range attachments { + attachmentPaths = append(attachmentPaths, a.Path) + } + var uploads []agent.ImageUpload for _, img := range images { data, err := base64.StdEncoding.DecodeString(img.Data) @@ -288,7 +293,7 @@ func (r *mutationResolver) SendAgentMessage(ctx context.Context, beanID string, uploads = append(uploads, agent.ImageUpload{Data: data, MediaType: img.MediaType}) } - if err := r.AgentMgr.SendMessage(beanID, workDir, message, uploads); err != nil { + if err := r.AgentMgr.SendMessage(beanID, workDir, message, uploads, attachmentPaths...); err != nil { return false, err } return true, nil @@ -876,6 +881,64 @@ func (r *queryResolver) WorktreeIntegrateMode(ctx context.Context) (string, erro return string(cfg.GetWorktreeIntegrate()), nil } +// ListFiles is the resolver for the listFiles field. +// It does case-insensitive substring matching across all git-tracked file paths. +// Each query term (space-separated) must match somewhere in the path. +func (r *queryResolver) ListFiles(ctx context.Context, workspaceID *string, prefix string, limit *int) ([]*model.FileEntry, error) { + dir := r.ProjectRoot + if workspaceID != nil && *workspaceID != CentralSessionID { + path, err := r.findWorktreePath(*workspaceID) + if err != nil { + return nil, err + } + dir = path + } + + maxResults := 0 // 0 = no limit + if limit != nil && *limit > 0 { + maxResults = *limit + } + + cmd := exec.CommandContext(ctx, "git", "ls-files") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git ls-files: %w", err) + } + + query := strings.ToLower(strings.TrimSpace(prefix)) + terms := strings.Fields(query) + + var results []*model.FileEntry + + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line == "" { + continue + } + + // All terms must match (case-insensitive) somewhere in the path + lower := strings.ToLower(line) + match := true + for _, term := range terms { + if !strings.Contains(lower, term) { + match = false + break + } + } + if !match { + continue + } + + results = append(results, &model.FileEntry{Path: line}) + + if maxResults > 0 && len(results) >= maxResults { + break + } + } + + return results, nil +} + // BeanChanged is the resolver for the beanChanged field. func (r *subscriptionResolver) BeanChanged(ctx context.Context, includeInitial *bool) (<-chan *model.BeanChangeEvent, error) { // Subscribe to bean events from beancore diff --git a/internal/graph/schema.resolvers_test.go b/internal/graph/schema.resolvers_test.go index dd8b09ce..ed91bfb6 100644 --- a/internal/graph/schema.resolvers_test.go +++ b/internal/graph/schema.resolvers_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -3137,3 +3138,117 @@ func TestRemoveBlockingWithETag(t *testing.T) { }) } +func TestListFiles(t *testing.T) { + ctx := context.Background() + + // Create a temporary git repo with some files + tmpDir := t.TempDir() + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = tmpDir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) + } + } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + + // Create files + for _, f := range []string{ + "README.md", + "main.go", + "pkg/bean/bean.go", + "pkg/bean/sort.go", + "pkg/beancore/core.go", + "internal/graph/resolver.go", + "internal/graph/schema.go", + "internal/agent/manager.go", + } { + dir := filepath.Dir(filepath.Join(tmpDir, f)) + os.MkdirAll(dir, 0755) + os.WriteFile(filepath.Join(tmpDir, f), []byte("package test"), 0644) + } + run("add", "-A") + run("commit", "-m", "init") + + resolver := &Resolver{ + CoreResolver: &beangraph.CoreResolver{}, + ProjectRoot: tmpDir, + } + qr := resolver.Query().(*queryResolver) + + t.Run("empty query returns all files", func(t *testing.T) { + results, err := qr.ListFiles(ctx, nil, "", nil) + if err != nil { + t.Fatalf("ListFiles() error: %v", err) + } + if len(results) != 8 { + t.Errorf("expected 8 results, got %d", len(results)) + } + }) + + t.Run("substring matches across full path", func(t *testing.T) { + results, err := qr.ListFiles(ctx, nil, "resolver", nil) + if err != nil { + t.Fatalf("ListFiles() error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Path != "internal/graph/resolver.go" { + t.Errorf("expected internal/graph/resolver.go, got %s", results[0].Path) + } + }) + + t.Run("case insensitive matching", func(t *testing.T) { + results, err := qr.ListFiles(ctx, nil, "README", nil) + if err != nil { + t.Fatalf("ListFiles() error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Path != "README.md" { + t.Errorf("expected README.md, got %s", results[0].Path) + } + }) + + t.Run("multiple terms all must match", func(t *testing.T) { + results, err := qr.ListFiles(ctx, nil, "bean sort", nil) + if err != nil { + t.Fatalf("ListFiles() error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Path != "pkg/bean/sort.go" { + t.Errorf("expected pkg/bean/sort.go, got %s", results[0].Path) + } + }) + + t.Run("limit caps results", func(t *testing.T) { + limit := 2 + results, err := qr.ListFiles(ctx, nil, "", &limit) + if err != nil { + t.Fatalf("ListFiles() error: %v", err) + } + if len(results) > 2 { + t.Errorf("expected at most 2 results, got %d", len(results)) + } + }) + + t.Run("non-matching query returns empty", func(t *testing.T) { + results, err := qr.ListFiles(ctx, nil, "nonexistent", nil) + if err != nil { + t.Fatalf("ListFiles() error: %v", err) + } + if len(results) != 0 { + t.Errorf("expected 0 results, got %d", len(results)) + } + }) +} + diff --git a/pkg/beangraph/model/models_gen.go b/pkg/beangraph/model/models_gen.go index 680ca94b..58a0eb50 100644 --- a/pkg/beangraph/model/models_gen.go +++ b/pkg/beangraph/model/models_gen.go @@ -41,6 +41,8 @@ type AgentMessage struct { Content string `json:"content"` // Attached images (empty for assistant/tool messages) Images []*AgentMessageImage `json:"images"` + // File/directory paths attached via @-mention (only present on user messages) + Attachments []string `json:"attachments"` // Unified diff output (only present on tool messages for Write/Edit tools) Diff *string `json:"diff,omitempty"` } @@ -219,6 +221,12 @@ type CreateBeanInput struct { Prefix *string `json:"prefix,omitempty"` } +// Input for attaching a file or directory as context to an agent message. +type FileAttachmentInput struct { + // Relative file or directory path + Path string `json:"path"` +} + // A changed file in a git working tree type FileChange struct { // File path relative to the repo/worktree root @@ -233,6 +241,12 @@ type FileChange struct { Staged bool `json:"staged"` } +// A file entry in the project, used for @-mention autocomplete. +type FileEntry struct { + // Relative path from the workspace root + Path string `json:"path"` +} + // Input for uploading an image attachment type ImageInput struct { // Base64-encoded image data