diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index e06f9c46..10fd41ed 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -12,6 +12,7 @@ import type { AttachedFile } from '@/stores/types/sessionTypes'; import * as sessionActions from '@/sync/session-actions'; import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context'; import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; +import { useSnippetsStore } from '@/stores/useSnippetsStore'; import { appendInlineComments } from '@/lib/messages/inlineComments'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment'; @@ -19,6 +20,7 @@ import { QueuedMessageChips } from './QueuedMessageChips'; import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete'; import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete'; import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete'; +import { SnippetAutocomplete, type SnippetAutocompleteHandle } from './SnippetAutocomplete'; import { cn, formatDirectoryName, isMacOS } from '@/lib/utils'; import { ModelControls } from './ModelControls'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; @@ -74,6 +76,7 @@ const EMPTY_MESSAGES: Message[] = []; const FILE_MENTION_TOKEN = /^@[^\s]+$/; const INLINE_SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g; const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500; +const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560; const VS_CODE_DROP_DATA_TYPES = [ 'CodeFiles', 'codefiles', @@ -906,6 +909,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const [autocompleteTab, setAutocompleteTab] = React.useState<'commands' | 'agents' | 'files'>('commands'); const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false); const [skillQuery, setSkillQuery] = React.useState(''); + const [showSnippetAutocomplete, setShowSnippetAutocomplete] = React.useState(false); + const [snippetQuery, setSnippetQuery] = React.useState(''); const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null); const [mobileControlsPanel, setMobileControlsPanel] = React.useState(null); // Message history navigation state (up/down arrow to recall previous messages) @@ -924,6 +929,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const mentionRef = React.useRef(null); const commandRef = React.useRef(null); const skillRef = React.useRef(null); + const snippetRef = React.useRef(null); // Ref to track current message value without triggering re-renders in effects const messageRef = React.useRef(message); const draftPersistTimerRef = React.useRef | null>(null); @@ -993,9 +999,35 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const [showAbortStatus, setShowAbortStatus] = React.useState(false); const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept); const composerHighlightRef = React.useRef(null); + const [isNarrowComposer, setIsNarrowComposer] = React.useState(false); const isDesktopExpanded = isExpandedInput && !isMobile; const chatInputRadius = 'var(--radius-xl)'; + const useCompactChatPlaceholder = isMobile || isNarrowComposer; + + React.useEffect(() => { + const element = dropZoneRef.current; + if (!element) return; + + const updateWidth = (width: number) => { + const next = width > 0 && width < COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH; + setIsNarrowComposer((prev) => (prev === next ? prev : next)); + }; + + updateWidth(element.clientWidth); + + if (typeof ResizeObserver === 'undefined') { + const handleResize = () => updateWidth(element.clientWidth); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + } + + const observer = new ResizeObserver((entries) => { + updateWidth(entries[0]?.contentRect.width ?? element.clientWidth); + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); const sendableAttachedFiles = attachedFiles; @@ -1782,6 +1814,16 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } + try { + const expandText = useSnippetsStore.getState().expandText; + primaryText = await expandText(primaryText); + for (const part of additionalParts) { + if (!part.synthetic) part.text = await expandText(part.text); + } + } catch (error) { + console.warn('[ChatInput] Failed to expand snippets, sending original text:', error); + } + // Collect all attachments for error recovery const allAttachments = [ ...primaryAttachments, @@ -1957,6 +1999,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } + if (showSnippetAutocomplete && snippetRef.current) { + if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { + e.preventDefault(); + snippetRef.current.handleKeyDown(e.key); + return; + } + } + if (showFileMention && mentionRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); @@ -1980,7 +2030,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ? 1 : 0; - if (cycleAgentDirection !== 0 && !showCommandAutocomplete && !showSkillAutocomplete && !showFileMention) { + if (cycleAgentDirection !== 0 && !showCommandAutocomplete && !showSkillAutocomplete && !showSnippetAutocomplete && !showFileMention) { e.preventDefault(); e.stopPropagation(); handleCycleAgent(cycleAgentDirection); @@ -1990,7 +2040,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Handle ArrowUp/ArrowDown for message history navigation // ArrowUp: only when cursor at start (position 0) or input is empty // ArrowDown: also works when cursor at end (to cycle forward through history) - const isAnyAutocompleteOpen = showCommandAutocomplete || showSkillAutocomplete || showFileMention; + const isAnyAutocompleteOpen = showCommandAutocomplete || showSkillAutocomplete || showSnippetAutocomplete || showFileMention; const cursorAtStart = textareaRef.current?.selectionStart === 0 && textareaRef.current?.selectionEnd === 0; const cursorAtEnd = textareaRef.current?.selectionStart === message.length && textareaRef.current?.selectionEnd === message.length; const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart); @@ -2118,7 +2168,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return; } - if (!showCommandAutocomplete && !showSkillAutocomplete && !showFileMention) { + if (!showCommandAutocomplete && !showSkillAutocomplete && !showSnippetAutocomplete && !showFileMention) { setAutocompleteOverlayPosition(null); return; } @@ -2143,7 +2193,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const spaceBelow = containerRect.height - caretY - popupMargin; const place: 'above' | 'below' = spaceBelow >= estimatedPopupHeight || spaceBelow >= spaceAbove ? 'below' : 'above'; - const desiredWidth = showFileMention ? 520 : showCommandAutocomplete ? 450 : 360; + const desiredWidth = showFileMention ? 520 : showCommandAutocomplete || showSnippetAutocomplete ? 450 : 360; const clampedLeft = Math.max( popupMargin, Math.min(caretX - 24, containerRect.width - desiredWidth - popupMargin) @@ -2163,6 +2213,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo message.length, showCommandAutocomplete, showFileMention, + showSnippetAutocomplete, showSkillAutocomplete, ]); @@ -2173,6 +2224,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo message, showCommandAutocomplete, showSkillAutocomplete, + showSnippetAutocomplete, showFileMention, isDesktopExpanded, ]); @@ -2280,6 +2332,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setShowCommandAutocomplete(false); setShowFileMention(false); setShowSkillAutocomplete(false); + setShowSnippetAutocomplete(false); return; } @@ -2298,6 +2351,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setShowCommandAutocomplete(true); setShowFileMention(false); setShowSkillAutocomplete(false); + setShowSnippetAutocomplete(false); return; } } @@ -2324,6 +2378,21 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setShowSkillAutocomplete(false); setSkillQuery(''); + const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); + if (lastHashSymbol !== -1) { + const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null; + const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1); + const isWordBoundary = !charBefore || /\s/.test(charBefore); + if (isWordBoundary && !textAfterHash.includes(' ') && !textAfterHash.includes('\n')) { + setSnippetQuery(textAfterHash); + setShowSnippetAutocomplete(true); + setShowFileMention(false); + return; + } + } + + setShowSnippetAutocomplete(false); + const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); if (lastAtSymbol !== -1) { const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null; @@ -2703,6 +2772,28 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo textareaRef.current?.focus(); }; + const handleSnippetSelect = (_snippet: unknown, trigger: string) => { + const textarea = textareaRef.current; + const cursorPosition = textarea?.selectionStart ?? message.length; + const textBeforeCursor = message.substring(0, cursorPosition); + const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); + const startIndex = lastHashSymbol !== -1 ? lastHashSymbol : cursorPosition; + const newMessage = `${message.substring(0, startIndex)}#${trigger} ${message.substring(cursorPosition)}`; + setMessage(newMessage); + const nextCursor = startIndex + trigger.length + 2; + requestAnimationFrame(() => { + if (textareaRef.current) { + textareaRef.current.selectionStart = nextCursor; + textareaRef.current.selectionEnd = nextCursor; + } + adjustTextareaHeight(); + updateAutocompleteState(newMessage, nextCursor); + }); + setShowSnippetAutocomplete(false); + setSnippetQuery(''); + textareaRef.current?.focus(); + }; + const handleCommandSelect = (command: CommandInfo) => { setMessage(`/${command.name} `); @@ -3896,6 +3987,25 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo /> )} + {showSnippetAutocomplete && ( + setShowSnippetAutocomplete(false)} + style={isDesktopExpanded && autocompleteOverlayPosition + ? { + left: `${autocompleteOverlayPosition.left}px`, + top: `${autocompleteOverlayPosition.top}px`, + bottom: 'auto', + width: `min(450px, calc(100% - ${autocompleteOverlayPosition.left + 8}px))`, + maxHeight: `${autocompleteOverlayPosition.maxHeight}px`, + transform: autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined, + } + : undefined} + /> + )} + {showFileMention && ( = ({ onOpenSettings, scrollTo placeholder={currentSessionId || newSessionDraftOpen ? inputMode === 'shell' ? t('chat.chatInput.placeholder.shell') - : t('chat.chatInput.placeholder.chat') + : t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat') : t('chat.chatInput.placeholder.selectSession')} disabled={!currentSessionId && !newSessionDraftOpen} autoCorrect={isMobile ? "on" : "off"} diff --git a/packages/ui/src/components/chat/SnippetAutocomplete.tsx b/packages/ui/src/components/chat/SnippetAutocomplete.tsx new file mode 100644 index 00000000..b85165db --- /dev/null +++ b/packages/ui/src/components/chat/SnippetAutocomplete.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { cn, fuzzyMatch } from '@/lib/utils'; +import { useSnippetsStore } from '@/stores/useSnippetsStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import type { Snippet } from '@/types/snippet'; + +export interface SnippetAutocompleteHandle { + handleKeyDown: (key: string) => void; +} + +interface SnippetAutocompleteProps { + searchQuery: string; + onSnippetSelect: (snippet: Snippet, trigger: string) => void; + onClose: () => void; + style?: React.CSSProperties; +} + +function snippetPreview(snippet: Snippet): string { + return (snippet.description || snippet.content).replace(/\s+/g, ' ').trim().slice(0, 120); +} + +export const SnippetAutocomplete = React.forwardRef(({ + searchQuery, + onSnippetSelect, + onClose, + style, +}, ref) => { + const { t } = useI18n(); + const containerRef = React.useRef(null); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const selectedIndexRef = React.useRef(0); + const [filteredSnippets, setFilteredSnippets] = React.useState([]); + const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); + const snippets = useSnippetsStore((s) => s.snippets); + const loadSnippets = useSnippetsStore((s) => s.loadSnippets); + const setSnippetDraft = useSnippetsStore((s) => s.setSnippetDraft); + const setSelectedSnippet = useSnippetsStore((s) => s.setSelectedSnippet); + const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); + const setSettingsPage = useUIStore((s) => s.setSettingsPage); + + React.useEffect(() => { + void loadSnippets(); + }, [loadSnippets]); + + React.useEffect(() => { + const query = searchQuery.trim(); + const matches = query.length + ? snippets.filter((snippet) => fuzzyMatch(snippet.name, query) || snippet.aliases.some((alias) => fuzzyMatch(alias, query))) + : snippets; + const sortedMatches = [...matches].sort((a, b) => { + if (a.source === 'project' && b.source !== 'project') return -1; + if (a.source !== 'project' && b.source === 'project') return 1; + return a.name.localeCompare(b.name); + }); + setFilteredSnippets(sortedMatches); + setSelectedIndex(sortedMatches.length ? 1 : 0); + }, [searchQuery, snippets]); + + React.useEffect(() => { + selectedIndexRef.current = selectedIndex; + itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' }); + }, [selectedIndex]); + + React.useEffect(() => { + const handlePointerDown = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node | null; + if (target && containerRef.current && !containerRef.current.contains(target)) onClose(); + }; + document.addEventListener('pointerdown', handlePointerDown, true); + return () => document.removeEventListener('pointerdown', handlePointerDown, true); + }, [onClose]); + + const chooseSnippet = React.useCallback((snippet: Snippet) => { + const query = searchQuery.trim(); + const trigger = snippet.aliases.includes(query) ? query : snippet.name; + onSnippetSelect(snippet, trigger); + }, [onSnippetSelect, searchQuery]); + + const openNewSnippetSettings = React.useCallback(() => { + const existing = new Set(snippets.map((snippet) => snippet.name)); + let name = 'new-snippet'; + let counter = 1; + while (existing.has(name)) { + name = `new-snippet-${counter++}`; + } + setSnippetDraft({ name, scope: 'global' }); + setSelectedSnippet(name); + setSettingsPage('snippets'); + setSettingsDialogOpen(true); + onClose(); + }, [onClose, setSelectedSnippet, setSettingsDialogOpen, setSettingsPage, setSnippetDraft, snippets]); + + React.useImperativeHandle(ref, () => ({ + handleKeyDown: (key: string) => { + if (key === 'Escape') { + onClose(); + return; + } + const itemCount = filteredSnippets.length + 1; + if (key === 'ArrowDown') { + setSelectedIndex((prev) => (prev + 1) % itemCount); + return; + } + if (key === 'ArrowUp') { + setSelectedIndex((prev) => (prev - 1 + itemCount) % itemCount); + return; + } + if (key === 'Enter' || key === 'Tab') { + if (selectedIndexRef.current === 0) { + openNewSnippetSettings(); + return; + } + const snippet = filteredSnippets[selectedIndexRef.current - 1]; + if (snippet) chooseSnippet(snippet); + } + }, + }), [chooseSnippet, filteredSnippets, onClose, openNewSnippetSettings]); + + return ( +
+ +
{ itemRefs.current[0] = el; }} + className={cn('flex items-center gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', selectedIndex === 0 && 'bg-interactive-selection')} + onClick={openNewSnippetSettings} + onMouseMove={() => setSelectedIndex(0)} + > + + {t('chat.snippetAutocomplete.action.addNew')} +
+ {filteredSnippets.length ? filteredSnippets.map((snippet, index) => ( +
{ itemRefs.current[index + 1] = el; }} + className={cn('flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', index + 1 === selectedIndex && 'bg-interactive-selection')} + onClick={() => chooseSnippet(snippet)} + onMouseMove={() => setSelectedIndex(index + 1)} + > +
+
+ #{snippet.name} + {t(`snippets.source.${snippet.source}`)} +
+
{snippetPreview(snippet)}
+
+
+ )) : ( +
{t('chat.snippetAutocomplete.empty')}
+ )} +
+
{t('chat.snippetAutocomplete.footer')}
+
+ ); +}); + +SnippetAutocomplete.displayName = 'SnippetAutocomplete'; diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index 862e545f..23387a55 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -1,7 +1,7 @@ // This file is auto-generated by scripts/generate-icon-sprite.mjs // Do not edit manually. Run the script to update. -export const iconSpriteData: Record = { +export const iconSpriteData = { "add-circle": ``, "add": ``, "ai-agent-fill": ``, @@ -44,6 +44,7 @@ export const iconSpriteData: Record = { "chat-ai-3": ``, "chat-history": ``, "chat-new": ``, + "chat-thread": ``, "check": ``, "checkbox-blank-circle-fill": ``, "checkbox-blank": ``, @@ -225,4 +226,4 @@ export const iconSpriteData: Record = { "voice-recognition": ``, "volume-up": ``, "window": ``, -}; +} as const satisfies Record; diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 23263c0e..f98a461e 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -35,7 +35,6 @@ import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device'; import { cn, hasModifier } from '@/lib/utils'; import { McpDropdownContent } from '@/components/mcp/McpDropdown'; -import { McpIcon } from '@/components/icons/McpIcon'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota'; import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; @@ -1533,7 +1532,7 @@ export const Header: React.FC = ({ } base.push( { value: 'usage', label: t('layout.services.usage'), icon: "timer" }, - { value: 'mcp', label: 'MCP', icon: McpIcon as unknown as IconName } + { value: 'mcp', label: 'MCP', icon: "plug-2" } ); return base; }, [isDesktopApp, t]); diff --git a/packages/ui/src/components/multirun/MultiRunFusionDialog.tsx b/packages/ui/src/components/multirun/MultiRunFusionDialog.tsx index f4802fab..b0e98355 100644 --- a/packages/ui/src/components/multirun/MultiRunFusionDialog.tsx +++ b/packages/ui/src/components/multirun/MultiRunFusionDialog.tsx @@ -113,6 +113,7 @@ export function MultiRunFusionDialog({ .map((candidate): FusionSource | null => { const candidateParsed = parseMultiRunSessionTitle(candidate.title); if (!candidateParsed || candidateParsed.groupSlug !== parsed.groupSlug || candidateParsed.fusion) return null; + if ((candidateParsed.runGroup ?? null) !== (parsed.runGroup ?? null)) return null; const directory = useSessionUIStore.getState().getDirectoryForSession(candidate.id) ?? resolveGlobalSessionDirectory(candidate); const projectDirectory = getSessionProjectDirectory(candidate.id, directory); @@ -154,7 +155,7 @@ export function MultiRunFusionDialog({ } const directory = sources[0]?.projectDirectory ?? sources[0]?.directory ?? null; - const fusionTitle = getFusionSessionTitle(parsed.groupSlug, providerID, modelID); + const fusionTitle = getFusionSessionTitle(parsed.groupSlug, providerID, modelID, parsed.runGroup); const [visiblePrompt, instructionsPrompt] = await Promise.all([ renderMagicPrompt('session.fusion.visible'), renderMagicPrompt('session.fusion.instructions'), diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 776f339c..8a5ddefc 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -13,13 +13,15 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useMultiRunStore } from '@/stores/useMultiRunStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { usePromptTemplatesStore } from '@/stores/usePromptTemplatesStore'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import type { ProjectRef } from '@/lib/openchamberConfig'; import type { CreateMultiRunParams, MultiRunGroup } from '@/types/multirun'; import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect'; import { BranchSelector, useBranchOptions } from './BranchSelector'; import { AgentSelector } from './AgentSelector'; +import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from '@/components/chat/CommandAutocomplete'; +import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete'; +import { SnippetAutocomplete, type SnippetAutocompleteHandle } from '@/components/chat/SnippetAutocomplete'; import { Icon } from "@/components/icon/Icon"; import { isDesktopShell } from '@/lib/desktop'; import { useTabletStandalonePwaRuntime } from '@/lib/device'; @@ -42,7 +44,6 @@ interface MultiRunAttachedFile { interface RunGroupState { id: string; - templateId: string; prompt: string; models: ModelSelectionWithId[]; } @@ -91,7 +92,7 @@ export const MultiRunLauncher: React.FC = ({ const { t } = useI18n(); const [name, setName] = React.useState(''); const [runGroups, setRunGroups] = React.useState(() => [ - { id: generateInstanceId(), templateId: '', prompt: '', models: [] }, + { id: generateInstanceId(), prompt: '', models: [] }, ]); const [selectedAgent, setSelectedAgent] = React.useState(''); const [attachedFiles, setAttachedFiles] = React.useState([]); @@ -102,12 +103,6 @@ export const MultiRunLauncher: React.FC = ({ const [isolateRuns, setIsolateRuns] = React.useState(true); const fileInputRef = React.useRef(null); - const templates = usePromptTemplatesStore((s) => s.templates); - - React.useEffect(() => { - usePromptTemplatesStore.getState().loadTemplates(); - }, []); - const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); const homeDirectory = useDirectoryStore((state) => state.homeDirectory ?? null); @@ -304,17 +299,9 @@ export const MultiRunLauncher: React.FC = ({ }, []); const addGroup = React.useCallback(() => { - setRunGroups((prev) => [...prev, { id: generateInstanceId(), templateId: '', prompt: '', models: [] }]); + setRunGroups((prev) => [...prev, { id: generateInstanceId(), prompt: '', models: [] }]); }, []); - const handleTemplateChange = React.useCallback((groupId: string, templateId: string) => { - const template = templateId ? templates.find((t) => t.id === templateId) : null; - updateGroup(groupId, { - templateId, - prompt: template ? template.body : '', - }); - }, [templates, updateGroup]); - const handleFileSelect = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files) return; @@ -390,7 +377,6 @@ export const MultiRunLauncher: React.FC = ({ const groups: MultiRunGroup[] = validGroups.map((g) => ({ prompt: g.prompt.trim(), models: g.models.map((m) => ({ providerID: m.providerID, modelID: m.modelID, displayName: m.displayName, variant: m.variant })), - templateId: g.templateId || undefined, })); const params: CreateMultiRunParams = { @@ -646,11 +632,9 @@ export const MultiRunLauncher: React.FC = ({ key={group.id} group={group} groupIndex={groupIndex} - templates={templates} canRemove={runGroups.length > 1} onUpdate={updateGroup} onRemove={removeGroup} - onTemplateChange={handleTemplateChange} /> ))} @@ -712,23 +696,29 @@ export const MultiRunLauncher: React.FC = ({ interface RunGroupCardProps { group: RunGroupState; groupIndex: number; - templates: { id: string; name: string; body: string; isDefault: boolean }[]; canRemove: boolean; onUpdate: (groupId: string, updates: Partial) => void; onRemove: (groupId: string) => void; - onTemplateChange: (groupId: string, templateId: string) => void; } const RunGroupCard: React.FC = ({ group, groupIndex, - templates, canRemove, onUpdate, onRemove, - onTemplateChange, }) => { const { t } = useI18n(); + const [showFileMention, setShowFileMention] = React.useState(false); + const [mentionQuery, setMentionQuery] = React.useState(''); + const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false); + const [commandQuery, setCommandQuery] = React.useState(''); + const [showSnippetAutocomplete, setShowSnippetAutocomplete] = React.useState(false); + const [snippetQuery, setSnippetQuery] = React.useState(''); + const promptTextareaRef = React.useRef(null); + const mentionRef = React.useRef(null); + const commandRef = React.useRef(null); + const snippetRef = React.useRef(null); const handleAddModel = React.useCallback((model: ModelSelectionWithId) => { if (group.models.length >= MAX_MODELS_PER_GROUP) return; @@ -743,6 +733,182 @@ const RunGroupCard: React.FC = ({ onUpdate(group.id, { models: group.models.map((item, i) => (i === index ? model : item)) }); }, [group.id, group.models, onUpdate]); + const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => { + if (value.startsWith('/')) { + const firstSpace = value.indexOf(' '); + const firstNewline = value.indexOf('\n'); + const commandEnd = Math.min( + firstSpace === -1 ? value.length : firstSpace, + firstNewline === -1 ? value.length : firstNewline, + ); + + if (cursorPosition <= commandEnd && firstSpace === -1) { + setCommandQuery(value.substring(1, commandEnd)); + setShowCommandAutocomplete(true); + setShowFileMention(false); + setShowSnippetAutocomplete(false); + return; + } + } + + setShowCommandAutocomplete(false); + + const textBeforeCursor = value.substring(0, cursorPosition); + const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); + if (lastHashSymbol !== -1) { + const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null; + const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1); + const isWordBoundary = !charBefore || /\s/.test(charBefore); + if (isWordBoundary && !textAfterHash.includes(' ') && !textAfterHash.includes('\n')) { + setSnippetQuery(textAfterHash); + setShowSnippetAutocomplete(true); + setShowFileMention(false); + return; + } + } + + setShowSnippetAutocomplete(false); + + const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); + if (lastAtSymbol !== -1) { + const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null; + const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1); + const isWordBoundary = !charBefore || /\s/.test(charBefore); + if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { + setMentionQuery(textAfterAt); + setShowFileMention(true); + } else { + setShowFileMention(false); + } + return; + } + + setShowFileMention(false); + }, []); + + const setPrompt = React.useCallback((value: string) => { + onUpdate(group.id, { prompt: value }); + }, [group.id, onUpdate]); + + const handleFileSelect = React.useCallback((file: { name: string; path: string; relativePath?: string }) => { + const prompt = group.prompt; + const textarea = promptTextareaRef.current; + const cursorPosition = textarea?.selectionStart ?? prompt.length; + const textBeforeCursor = prompt.substring(0, cursorPosition); + const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); + const mentionPath = (file.relativePath && file.relativePath.trim().length > 0) + ? file.relativePath.trim() + : (file.path || file.name); + + const startIndex = lastAtSymbol !== -1 ? lastAtSymbol : cursorPosition; + const nextPrompt = `${prompt.substring(0, startIndex)}@${mentionPath} ${prompt.substring(cursorPosition)}`; + const nextCursor = startIndex + mentionPath.length + 2; + + setPrompt(nextPrompt); + setShowFileMention(false); + setMentionQuery(''); + + requestAnimationFrame(() => { + const currentTextarea = promptTextareaRef.current; + if (currentTextarea) { + currentTextarea.selectionStart = nextCursor; + currentTextarea.selectionEnd = nextCursor; + currentTextarea.focus(); + } + updateAutocompleteState(nextPrompt, nextCursor); + }); + }, [group.prompt, setPrompt, updateAutocompleteState]); + + const handleAgentSelect = React.useCallback((agentName: string) => { + const prompt = group.prompt; + const textarea = promptTextareaRef.current; + const cursorPosition = textarea?.selectionStart ?? prompt.length; + const textBeforeCursor = prompt.substring(0, cursorPosition); + const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); + const startIndex = lastAtSymbol !== -1 ? lastAtSymbol : cursorPosition; + const nextPrompt = `${prompt.substring(0, startIndex)}@${agentName} ${prompt.substring(cursorPosition)}`; + const nextCursor = startIndex + agentName.length + 2; + + setPrompt(nextPrompt); + setShowFileMention(false); + setMentionQuery(''); + + requestAnimationFrame(() => { + const currentTextarea = promptTextareaRef.current; + if (currentTextarea) { + currentTextarea.selectionStart = nextCursor; + currentTextarea.selectionEnd = nextCursor; + currentTextarea.focus(); + } + updateAutocompleteState(nextPrompt, nextCursor); + }); + }, [group.prompt, setPrompt, updateAutocompleteState]); + + const handleCommandSelect = React.useCallback((command: CommandInfo) => { + const nextPrompt = `/${command.name} `; + setPrompt(nextPrompt); + setShowCommandAutocomplete(false); + setCommandQuery(''); + setShowSnippetAutocomplete(false); + + requestAnimationFrame(() => { + const currentTextarea = promptTextareaRef.current; + if (currentTextarea) { + currentTextarea.focus(); + currentTextarea.selectionStart = currentTextarea.value.length; + currentTextarea.selectionEnd = currentTextarea.value.length; + } + updateAutocompleteState(nextPrompt, nextPrompt.length); + }); + }, [setPrompt, updateAutocompleteState]); + + const handleSnippetSelect = React.useCallback((_snippet: unknown, trigger: string) => { + const prompt = group.prompt; + const textarea = promptTextareaRef.current; + const cursorPosition = textarea?.selectionStart ?? prompt.length; + const textBeforeCursor = prompt.substring(0, cursorPosition); + const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); + const startIndex = lastHashSymbol !== -1 ? lastHashSymbol : cursorPosition; + const nextPrompt = `${prompt.substring(0, startIndex)}#${trigger} ${prompt.substring(cursorPosition)}`; + const nextCursor = startIndex + trigger.length + 2; + setPrompt(nextPrompt); + setShowSnippetAutocomplete(false); + setSnippetQuery(''); + requestAnimationFrame(() => { + const currentTextarea = promptTextareaRef.current; + if (currentTextarea) { + currentTextarea.selectionStart = nextCursor; + currentTextarea.selectionEnd = nextCursor; + currentTextarea.focus(); + } + updateAutocompleteState(nextPrompt, nextCursor); + }); + }, [group.prompt, setPrompt, updateAutocompleteState]); + + const handlePromptKeyDown = React.useCallback((event: React.KeyboardEvent) => { + if (showCommandAutocomplete && commandRef.current) { + if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') { + event.preventDefault(); + commandRef.current.handleKeyDown(event.key); + return; + } + } + + if (showFileMention && mentionRef.current) { + if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') { + event.preventDefault(); + mentionRef.current.handleKeyDown(event.key); + } + } + + if (showSnippetAutocomplete && snippetRef.current) { + if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') { + event.preventDefault(); + snippetRef.current.handleKeyDown(event.key); + } + } + }, [showCommandAutocomplete, showFileMention, showSnippetAutocomplete]); + return (
@@ -761,33 +927,55 @@ const RunGroupCard: React.FC = ({ )}
-
- {t('multirun.launcher.groups.template.label')} - -
-
{t('multirun.launcher.groups.prompt.label')} -