|
3 | 3 | import { Editor, Extension } from '@tiptap/core'; |
4 | 4 | import StarterKit from '@tiptap/starter-kit'; |
5 | 5 | import Placeholder from '@tiptap/extension-placeholder'; |
| 6 | + import { ListFilesDocument } from '$lib/graphql/generated'; |
| 7 | + import { client } from '$lib/graphqlClient'; |
6 | 8 |
|
7 | 9 | const MAX_IMAGE_SIZE = 5 * 1024 * 1024; |
8 | 10 | const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; |
9 | 11 |
|
10 | 12 | interface Props { |
11 | 13 | beanId: string; |
| 14 | + workspaceId: string; |
12 | 15 | isRunning: boolean; |
13 | 16 | hasMessages: boolean; |
14 | 17 | agentMode: 'plan' | 'act'; |
15 | 18 | effort: string; |
16 | 19 | systemStatus: string | null; |
17 | 20 | subagentActivities: SubagentActivity[]; |
18 | 21 | quickReplies: string[]; |
19 | | - onSend: (message: string, images?: { data: string; mediaType: string }[]) => void; |
| 22 | + onSend: (message: string, images?: { data: string; mediaType: string }[], attachments?: { path: string }[]) => void; |
20 | 23 | onStop: () => void; |
21 | 24 | onSetMode: (mode: 'plan' | 'act') => void; |
22 | 25 | onSetEffort: (effort: string) => void; |
|
26 | 29 |
|
27 | 30 | let { |
28 | 31 | beanId, |
| 32 | + workspaceId, |
29 | 33 | isRunning, |
30 | 34 | hasMessages, |
31 | 35 | agentMode, |
|
49 | 53 | let editorEl: HTMLDivElement | undefined = $state(); |
50 | 54 | let editor: Editor | undefined = $state(); |
51 | 55 |
|
52 | | - // Create a tiptap extension for keyboard shortcuts that need access to component state. |
53 | | - // We use closures so the handlers always read the latest reactive values. |
| 56 | + // @-mention autocomplete state |
| 57 | + let pendingAttachments = $state<{ path: string; isDir: boolean }[]>([]); |
| 58 | + let showMention = $state(false); |
| 59 | + let mentionResults = $state<{ path: string; isDir: boolean }[]>([]); |
| 60 | + let mentionSelectedIndex = $state(0); |
| 61 | + let mentionStartIndex = $state(-1); |
| 62 | + let mentionDebounceTimer: ReturnType<typeof setTimeout> | undefined; |
| 63 | +
|
| 64 | + function removeAttachment(index: number) { |
| 65 | + pendingAttachments = pendingAttachments.filter((_, i) => i !== index); |
| 66 | + } |
| 67 | +
|
| 68 | + function closeMention() { |
| 69 | + showMention = false; |
| 70 | + mentionResults = []; |
| 71 | + mentionSelectedIndex = 0; |
| 72 | + mentionStartIndex = -1; |
| 73 | + } |
| 74 | +
|
| 75 | + async function queryFiles(query: string) { |
| 76 | + const result = await client.query(ListFilesDocument, { |
| 77 | + workspaceId, |
| 78 | + prefix: query, |
| 79 | + limit: 20 |
| 80 | + }).toPromise(); |
| 81 | + if (result.data?.listFiles) { |
| 82 | + mentionResults = result.data.listFiles; |
| 83 | + mentionSelectedIndex = 0; |
| 84 | + } |
| 85 | + } |
| 86 | +
|
| 87 | + function scrollSelectedIntoView() { |
| 88 | + const container = document.querySelector('[data-mention-list]'); |
| 89 | + const selected = container?.querySelector('[data-selected]'); |
| 90 | + selected?.scrollIntoView({ block: 'nearest' }); |
| 91 | + } |
| 92 | +
|
| 93 | + // Detect @-mention triggers in the editor content. |
| 94 | + // For single-paragraph content, PM position = text index + 1. |
| 95 | + function handleMentionDetection(e: Editor) { |
| 96 | + const text = e.getText(); |
| 97 | + const { from } = e.state.selection; |
| 98 | + const cursorTextIndex = from - 1; |
| 99 | +
|
| 100 | + if (showMention) { |
| 101 | + if (cursorTextIndex <= mentionStartIndex || text[mentionStartIndex] !== '@') { |
| 102 | + closeMention(); |
| 103 | + return; |
| 104 | + } |
| 105 | + const query = text.slice(mentionStartIndex + 1, cursorTextIndex); |
| 106 | + if (mentionDebounceTimer) clearTimeout(mentionDebounceTimer); |
| 107 | + mentionDebounceTimer = setTimeout(() => queryFiles(query), 100); |
| 108 | + } else { |
| 109 | + if (cursorTextIndex > 0 && text[cursorTextIndex - 1] === '@') { |
| 110 | + const charBefore = cursorTextIndex >= 2 ? text[cursorTextIndex - 2] : undefined; |
| 111 | + if (charBefore === undefined || /\s/.test(charBefore)) { |
| 112 | + mentionStartIndex = cursorTextIndex - 1; |
| 113 | + showMention = true; |
| 114 | + if (mentionDebounceTimer) clearTimeout(mentionDebounceTimer); |
| 115 | + mentionDebounceTimer = setTimeout(() => queryFiles(''), 100); |
| 116 | + } |
| 117 | + } |
| 118 | + } |
| 119 | + } |
| 120 | +
|
| 121 | + function selectMentionItem(item: { path: string; isDir: boolean }) { |
| 122 | + if (!editor) return; |
| 123 | +
|
| 124 | + // Delete @query from editor (PM pos = text index + 1) |
| 125 | + const pmStart = mentionStartIndex + 1; |
| 126 | + const pmEnd = editor.state.selection.from; |
| 127 | + editor.chain().deleteRange({ from: pmStart, to: pmEnd }).run(); |
| 128 | +
|
| 129 | + if (!pendingAttachments.some(a => a.path === item.path)) { |
| 130 | + pendingAttachments = [...pendingAttachments, { path: item.path, isDir: item.isDir }]; |
| 131 | + } |
| 132 | + closeMention(); |
| 133 | + editor.commands.focus(); |
| 134 | + } |
| 135 | +
|
| 136 | + // TipTap extension for keyboard shortcuts (closures read latest reactive state) |
54 | 137 | function createComposerKeymap() { |
55 | 138 | return Extension.create({ |
56 | 139 | name: 'composerKeymap', |
57 | 140 | addKeyboardShortcuts() { |
58 | 141 | return { |
| 142 | + ArrowDown: () => { |
| 143 | + if (showMention && mentionResults.length > 0) { |
| 144 | + mentionSelectedIndex = (mentionSelectedIndex + 1) % mentionResults.length; |
| 145 | + setTimeout(scrollSelectedIntoView, 0); |
| 146 | + return true; |
| 147 | + } |
| 148 | + return false; |
| 149 | + }, |
| 150 | + ArrowUp: () => { |
| 151 | + if (showMention && mentionResults.length > 0) { |
| 152 | + mentionSelectedIndex = (mentionSelectedIndex - 1 + mentionResults.length) % mentionResults.length; |
| 153 | + setTimeout(scrollSelectedIntoView, 0); |
| 154 | + return true; |
| 155 | + } |
| 156 | + return false; |
| 157 | + }, |
59 | 158 | Enter: () => { |
| 159 | + if (showMention && mentionResults.length > 0) { |
| 160 | + selectMentionItem(mentionResults[mentionSelectedIndex]); |
| 161 | + return true; |
| 162 | + } |
60 | 163 | send(); |
61 | 164 | return true; |
62 | 165 | }, |
63 | | - 'Shift-Tab': () => { |
64 | | - if (!isRunning) { |
65 | | - onSetMode(agentMode === 'plan' ? 'act' : 'plan'); |
| 166 | + Tab: () => { |
| 167 | + if (showMention && mentionResults.length > 0) { |
| 168 | + selectMentionItem(mentionResults[mentionSelectedIndex]); |
| 169 | + return true; |
66 | 170 | } |
67 | | - return true; |
| 171 | + return false; |
68 | 172 | }, |
69 | 173 | Escape: () => { |
| 174 | + if (showMention) { |
| 175 | + closeMention(); |
| 176 | + return true; |
| 177 | + } |
70 | 178 | if (isRunning) { |
71 | 179 | onStop(); |
| 180 | + return true; |
| 181 | + } |
| 182 | + return false; |
| 183 | + }, |
| 184 | + 'Shift-Tab': () => { |
| 185 | + if (!isRunning) { |
| 186 | + onSetMode(agentMode === 'plan' ? 'act' : 'plan'); |
72 | 187 | } |
73 | 188 | return true; |
74 | 189 | } |
|
77 | 192 | }); |
78 | 193 | } |
79 | 194 |
|
80 | | - // Initialize the tiptap editor when the DOM element is available |
| 195 | + // Initialize TipTap editor |
81 | 196 | $effect(() => { |
82 | 197 | if (!editorEl) return; |
83 | 198 |
|
|
87 | 202 | element: editorEl, |
88 | 203 | extensions: [ |
89 | 204 | StarterKit.configure({ |
90 | | - // Disable features we don't need in a chat composer |
91 | 205 | heading: false, |
92 | 206 | blockquote: false, |
93 | 207 | codeBlock: false, |
|
115 | 229 | const file = item.getAsFile(); |
116 | 230 | if (file) addImageFile(file); |
117 | 231 | } |
118 | | - // If there's also text content, let tiptap handle the text paste |
119 | 232 | const hasText = items.some((item) => item.type === 'text/plain'); |
120 | 233 | return !hasText; |
121 | 234 | } |
122 | 235 | }, |
123 | 236 | onUpdate: ({ editor: e }) => { |
124 | 237 | inputText = e.getText(); |
| 238 | + handleMentionDetection(e); |
125 | 239 | } |
126 | 240 | }); |
127 | 241 |
|
|
208 | 322 |
|
209 | 323 | function send() { |
210 | 324 | const text = inputText.trim(); |
211 | | - if (!text && pendingImages.length === 0) return; |
| 325 | + if (!text && pendingImages.length === 0 && pendingAttachments.length === 0) return; |
212 | 326 | const images = |
213 | 327 | pendingImages.length > 0 |
214 | 328 | ? pendingImages.map(({ data, mediaType }) => ({ data, mediaType })) |
215 | 329 | : undefined; |
| 330 | + const attachments = |
| 331 | + pendingAttachments.length > 0 |
| 332 | + ? pendingAttachments.map(({ path }) => ({ path })) |
| 333 | + : undefined; |
216 | 334 | for (const img of pendingImages) URL.revokeObjectURL(img.preview); |
217 | 335 | pendingImages = []; |
| 336 | + pendingAttachments = []; |
218 | 337 | inputText = ''; |
219 | 338 | editor?.commands.clearContent(true); |
220 | | - onSend(text, images); |
| 339 | + closeMention(); |
| 340 | + onSend(text, images, attachments); |
221 | 341 | } |
222 | 342 | </script> |
223 | 343 |
|
|
261 | 381 | ondragleave={handleDragLeave} |
262 | 382 | ondrop={handleDrop} |
263 | 383 | > |
| 384 | + {#if showMention && mentionResults.length > 0} |
| 385 | + <div data-mention-list class="absolute bottom-full left-0 z-50 mb-1 max-h-48 w-full overflow-y-auto rounded border border-border bg-surface shadow-lg"> |
| 386 | + {#each mentionResults as item, i (item.path)} |
| 387 | + <button |
| 388 | + type="button" |
| 389 | + data-selected={i === mentionSelectedIndex ? '' : undefined} |
| 390 | + class={[ |
| 391 | + 'flex w-full cursor-pointer items-center gap-2 px-2 py-1 text-left text-xs', |
| 392 | + i === mentionSelectedIndex ? 'bg-accent/10 text-accent' : 'text-text-muted hover:bg-surface-alt' |
| 393 | + ]} |
| 394 | + onmousedown={(e) => { e.preventDefault(); selectMentionItem(item); }} |
| 395 | + > |
| 396 | + <span class={[item.isDir ? 'icon-[uil--folder]' : 'icon-[uil--file]', 'size-3.5 shrink-0']}></span> |
| 397 | + <span class="truncate">{item.path}</span> |
| 398 | + </button> |
| 399 | + {/each} |
| 400 | + </div> |
| 401 | + {/if} |
264 | 402 | <div bind:this={editorEl} class="composer-editor-wrapper"></div> |
| 403 | + {#if pendingAttachments.length > 0} |
| 404 | + <div class="flex flex-wrap gap-1 px-2 py-1"> |
| 405 | + {#each pendingAttachments as att, i (att.path)} |
| 406 | + <span class="inline-flex items-center gap-1 rounded bg-accent/10 px-1.5 py-0.5 text-xs text-accent"> |
| 407 | + <span class={[att.isDir ? 'icon-[uil--folder]' : 'icon-[uil--file]', 'size-3']}></span> |
| 408 | + {att.path} |
| 409 | + <button type="button" class="cursor-pointer" onclick={() => removeAttachment(i)} aria-label="Remove {att.path}"> |
| 410 | + <span class="icon-[uil--times] size-3"></span> |
| 411 | + </button> |
| 412 | + </span> |
| 413 | + {/each} |
| 414 | + </div> |
| 415 | + {/if} |
265 | 416 | <div class="flex items-center gap-1 px-2 pb-1.5"> |
266 | 417 | <input |
267 | 418 | bind:this={fileInputEl} |
|
291 | 442 | {/if} |
292 | 443 | <button |
293 | 444 | onclick={send} |
294 | | - disabled={!inputText.trim() && pendingImages.length === 0} |
| 445 | + disabled={!inputText.trim() && pendingImages.length === 0 && pendingAttachments.length === 0} |
295 | 446 | class="cursor-pointer rounded p-1 text-text-muted transition-colors hover:bg-surface hover:text-text |
296 | 447 | disabled:cursor-not-allowed disabled:opacity-30" |
297 | 448 | aria-label="Send message" |
|
0 commit comments