feat: replace prompt templates with snippets
Replace the prompt-template workflow with snippet support that is compatible with opencode snippet conventions. Snippets are now stored and loaded from global and project snippet directories, including legacy pluralized paths, with frontmatter metadata for aliases and descriptions. Snippet expansion supports recursive references plus prepend and append sections, while inject sections are treated as unsupported no-ops so OpenChamber remains compatible without requiring an external plugin. Add the snippets settings experience and remove the old prompt-template settings surface. The new settings page and sidebar support creating, editing, deleting, selecting, and describing snippets, with localized copy across every supported locale. The settings navigation now exposes Snippets with a dedicated icon and metadata. Wire snippets into all prompt-entry surfaces that need them. Chat, multi-run groups, and scheduled task prompts now offer hash-trigger snippet autocomplete and expand snippets before sending work to OpenCode. Chat also uses an adaptive compact placeholder on mobile or narrow composer widths so helper trigger guidance stays readable in constrained layouts. Keep multi-run aligned with grouped prompts. Multi-run sessions now use a shared title builder that handles both legacy titles and the newer g1, g2 prompt-group title format. Fusion parsing now recognizes grouped multi-run titles, scopes fusion sources to the same prompt group, and creates fusion sessions under the matching group so outputs from different prompts are not mixed accidentally. Harden the icon sprite pipeline. The sprite generator now discovers icon names used through typed icon maps, JSX icon props, IconName returns, and generated-value flows without scanning unrelated string literals or the generated sprite itself. The generated sprite is strictly typed so invalid icon names are caught by type checking, and existing invalid or unsafe icon references were cleaned up across settings, provider, Git identity, scheduled task, voice, header, and sidebar surfaces. Update backend configuration routes and documentation for snippets. The OpenCode config route layer now exposes snippet CRUD and expansion endpoints, accepts JSON bodies for snippet writes, and removes the old prompt-template provider. Scheduled task runtime expansion now uses snippets before dispatching messages. Add regression coverage for snippet storage and expansion, config-route JSON handling, and multi-run title parsing. Validated with full type checking, full linting, targeted multi-run title tests, and targeted OpenCode snippet/config route tests.
This commit is contained in:
@@ -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<ChatInputProps> = ({ 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<MobileControlsPanel>(null);
|
||||
// Message history navigation state (up/down arrow to recall previous messages)
|
||||
@@ -924,6 +929,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
|
||||
const snippetRef = React.useRef<SnippetAutocompleteHandle>(null);
|
||||
// Ref to track current message value without triggering re-renders in effects
|
||||
const messageRef = React.useRef(message);
|
||||
const draftPersistTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -993,9 +999,35 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
|
||||
const composerHighlightRef = React.useRef<HTMLDivElement | null>(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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showCommandAutocomplete && !showSkillAutocomplete && !showFileMention) {
|
||||
if (!showCommandAutocomplete && !showSkillAutocomplete && !showSnippetAutocomplete && !showFileMention) {
|
||||
setAutocompleteOverlayPosition(null);
|
||||
return;
|
||||
}
|
||||
@@ -2143,7 +2193,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
message.length,
|
||||
showCommandAutocomplete,
|
||||
showFileMention,
|
||||
showSnippetAutocomplete,
|
||||
showSkillAutocomplete,
|
||||
]);
|
||||
|
||||
@@ -2173,6 +2224,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
message,
|
||||
showCommandAutocomplete,
|
||||
showSkillAutocomplete,
|
||||
showSnippetAutocomplete,
|
||||
showFileMention,
|
||||
isDesktopExpanded,
|
||||
]);
|
||||
@@ -2280,6 +2332,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
setShowCommandAutocomplete(false);
|
||||
setShowFileMention(false);
|
||||
setShowSkillAutocomplete(false);
|
||||
setShowSnippetAutocomplete(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2298,6 +2351,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
setShowCommandAutocomplete(true);
|
||||
setShowFileMention(false);
|
||||
setShowSkillAutocomplete(false);
|
||||
setShowSnippetAutocomplete(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2324,6 +2378,21 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSnippetAutocomplete && (
|
||||
<SnippetAutocomplete
|
||||
ref={snippetRef}
|
||||
searchQuery={snippetQuery}
|
||||
onSnippetSelect={handleSnippetSelect}
|
||||
onClose={() => 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 && (
|
||||
|
||||
<FileMentionAutocomplete
|
||||
@@ -3986,7 +4096,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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"}
|
||||
|
||||
@@ -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<SnippetAutocompleteHandle, SnippetAutocompleteProps>(({
|
||||
searchQuery,
|
||||
onSnippetSelect,
|
||||
onClose,
|
||||
style,
|
||||
}, ref) => {
|
||||
const { t } = useI18n();
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const selectedIndexRef = React.useRef(0);
|
||||
const [filteredSnippets, setFilteredSnippets] = React.useState<Snippet[]>([]);
|
||||
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 (
|
||||
<div ref={containerRef} className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col" style={style}>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
|
||||
<div
|
||||
ref={(el) => { 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)}
|
||||
>
|
||||
<Icon name="add" className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">{t('chat.snippetAutocomplete.action.addNew')}</span>
|
||||
</div>
|
||||
{filteredSnippets.length ? filteredSnippets.map((snippet, index) => (
|
||||
<div
|
||||
key={`${snippet.source}:${snippet.filePath}`}
|
||||
ref={(el) => { 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)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold truncate">#{snippet.name}</span>
|
||||
<span className="text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0 bg-[var(--surface-muted)] text-muted-foreground border-[var(--interactive-border)]/60">{t(`snippets.source.${snippet.source}`)}</span>
|
||||
</div>
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">{snippetPreview(snippet)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">{t('chat.snippetAutocomplete.empty')}</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">{t('chat.snippetAutocomplete.footer')}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SnippetAutocomplete.displayName = 'SnippetAutocomplete';
|
||||
@@ -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<string, string> = {
|
||||
export const iconSpriteData = {
|
||||
"add-circle": `<path d="M11 11V7H13V11H17V13H13V17H11V13H7V11H11ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20Z" fill="currentColor"/>`,
|
||||
"add": `<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z" fill="currentColor"/>`,
|
||||
"ai-agent-fill": `<path d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 15C9.71266 15 7.65042 15.961 6.19238 17.5C7.65042 19.039 9.71266 20 12 20C14.2871 20 16.3486 19.0387 17.8066 17.5C16.3486 15.9613 14.2871 15 12 15ZM12.4707 5.31934C12.2943 4.89337 11.7058 4.89339 11.5293 5.31934L11.2764 5.93066C10.8445 6.97341 10.0384 7.80621 9.02539 8.25684L8.30762 8.57617C7.89751 8.75905 7.89744 9.35625 8.30762 9.53906L9.06738 9.87695C10.0551 10.3163 10.8476 11.1193 11.2871 12.1279L11.5332 12.6934C11.7138 13.1073 12.2863 13.1073 12.4668 12.6934L12.7139 12.1279C13.1534 11.1194 13.9449 10.3163 14.9326 9.87695L15.6924 9.53906C16.1026 9.35624 16.1025 8.75907 15.6924 8.57617L14.9746 8.25684C13.9616 7.8062 13.1556 6.9734 12.7236 5.93066L12.4707 5.31934Z" fill="currentColor"/>`,
|
||||
@@ -44,6 +44,7 @@ export const iconSpriteData: Record<string, string> = {
|
||||
"chat-ai-3": `<path d="M12 1.99996C12.8632 1.99996 13.701 2.10973 14.5 2.31539L14 4.25192C13.3608 4.0874 12.6906 3.99997 12 3.99997C7.58174 3.99997 4.00002 7.58172 4 12C4 13.3344 4.3255 14.6174 4.93945 15.7656L5.28906 16.4189L4.63379 19.3662L7.58105 18.7109L8.23438 19.0605C9.38255 19.6745 10.6656 20 12 20C16.4183 20 20 16.4183 20 12C20 11.6771 19.9805 11.3587 19.9434 11.0459L21.9297 10.8095C21.976 11.1999 22 11.5972 22 12C22 17.5228 17.5228 22 12 22C10.2975 22 8.69425 21.5746 7.29102 20.8242L2 22L3.17578 16.709C2.42541 15.3057 2 13.7025 2 12C2.00002 6.47714 6.47717 1.99996 12 1.99996ZM19.5293 1.3193C19.7058 0.893513 20.2942 0.8935 20.4707 1.3193L20.7236 1.93063C21.1555 2.97343 21.9615 3.80614 22.9746 4.2568L23.6914 4.57614C24.1022 4.75882 24.1022 5.35635 23.6914 5.53903L22.9326 5.87692C21.945 6.3162 21.1534 7.11943 20.7139 8.1279L20.4668 8.69333C20.2863 9.10747 19.7136 9.10747 19.5332 8.69333L19.2861 8.1279C18.8466 7.11942 18.0551 6.3162 17.0674 5.87692L16.3076 5.53903C15.8974 5.35618 15.8974 4.75895 16.3076 4.57614L17.0254 4.2568C18.0384 3.80614 18.8445 2.97343 19.2764 1.93063L19.5293 1.3193Z" fill="currentColor"/>`,
|
||||
"chat-history": `<path d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C10.298 22 8.69525 21.5748 7.29229 20.8248L2 22L3.17629 16.7097C2.42562 15.3063 2 13.7028 2 12C2 6.47715 6.47715 2 12 2ZM12 4C7.58172 4 4 7.58172 4 12C4 13.3347 4.32563 14.6181 4.93987 15.7664L5.28952 16.4201L4.63445 19.3663L7.58189 18.7118L8.23518 19.061C9.38315 19.6747 10.6659 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4ZM13 7V12H17V14H11V7H13Z" fill="currentColor"/>`,
|
||||
"chat-new": `<path d="M14 3V5H4V18.3851L5.76282 17H20V10H22V18C22 18.5523 21.5523 19 21 19H6.45455L2 22.5V4C2 3.44772 2.44772 3 3 3H14ZM19 3V0H21V3H24V5H21V8H19V5H16V3H19Z" fill="currentColor"/>`,
|
||||
"chat-thread": `<path d="M2 22L7.29117 20.8242C8.6944 21.5746 10.2975 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 13.7025 2.42544 15.3056 3.17581 16.7088L2 22ZM8.23428 19.0605L7.58075 18.711L4.63416 19.3658L5.28896 16.4192L4.93949 15.7657C4.32549 14.6175 4 13.3345 4 12C4 7.58172 7.58172 4 12 4C16.4183 4 20 7.58172 20 12C20 16.4183 16.4183 20 12 20C10.6655 20 9.38248 19.6745 8.23428 19.0605ZM15.4487 7H13.4411L13.2661 9.00024H11.2584L11.4334 7H9.42577L9.25077 9.00024H7V11.0002H9.0758L8.90082 13.0002H7V15.0002H8.72584L8.55089 17H10.5585L10.7335 15.0002H12.7411L12.5662 17H14.5738L14.7488 15.0002H17V13.0002H14.9237L15.0987 11.0002H17V9.00024H15.2737L15.4487 7ZM11.0834 11.0002H13.0911L12.9161 13.0002H10.9085L11.0834 11.0002Z" fill="currentColor"/>`,
|
||||
"check": `<path d="M9.9997 15.1709L19.1921 5.97852L20.6063 7.39273L9.9997 17.9993L3.63574 11.6354L5.04996 10.2212L9.9997 15.1709Z" fill="currentColor"/>`,
|
||||
"checkbox-blank-circle-fill": `<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="currentColor"/>`,
|
||||
"checkbox-blank": `<path d="M4 3H20C20.5523 3 21 3.44772 21 4V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3ZM5 5V19H19V5H5Z" fill="currentColor"/>`,
|
||||
@@ -225,4 +226,4 @@ export const iconSpriteData: Record<string, string> = {
|
||||
"voice-recognition": `<path d="M4.99805 15V19H8.99805V21H2.99805V15H4.99805ZM20.998 15V21H14.998V19H18.998V15H20.998ZM12.998 6V18H10.998V6H12.998ZM8.99805 9V15H6.99805V9H8.99805ZM16.998 9V15H14.998V9H16.998ZM8.99805 3V5H4.99805V9H2.99805V3H8.99805ZM20.998 3V9H18.998V5H14.998V3H20.998Z" fill="currentColor"/>`,
|
||||
"volume-up": `<path d="M6.60282 10.0001L10 7.22056V16.7796L6.60282 14.0001H3V10.0001H6.60282ZM2 16.0001H5.88889L11.1834 20.3319C11.2727 20.405 11.3846 20.4449 11.5 20.4449C11.7761 20.4449 12 20.2211 12 19.9449V4.05519C12 3.93977 11.9601 3.8279 11.887 3.73857C11.7121 3.52485 11.3971 3.49335 11.1834 3.66821L5.88889 8.00007H2C1.44772 8.00007 1 8.44778 1 9.00007V15.0001C1 15.5524 1.44772 16.0001 2 16.0001ZM23 12C23 15.292 21.5539 18.2463 19.2622 20.2622L17.8445 18.8444C19.7758 17.1937 21 14.7398 21 12C21 9.26016 19.7758 6.80629 17.8445 5.15557L19.2622 3.73779C21.5539 5.75368 23 8.70795 23 12ZM18 12C18 10.0883 17.106 8.38548 15.7133 7.28673L14.2842 8.71584C15.3213 9.43855 16 10.64 16 12C16 13.36 15.3213 14.5614 14.2842 15.2841L15.7133 16.7132C17.106 15.6145 18 13.9116 18 12Z" fill="currentColor"/>`,
|
||||
"window": `<path d="M21 3C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21ZM20 11H4V19H20V11ZM20 5H4V9H20V5ZM11 6V8H9V6H11ZM7 6V8H5V6H7Z" fill="currentColor"/>`,
|
||||
};
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
@@ -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<HeaderProps> = ({
|
||||
}
|
||||
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]);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<MultiRunLauncherProps> = ({
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = React.useState('');
|
||||
const [runGroups, setRunGroups] = React.useState<RunGroupState[]>(() => [
|
||||
{ id: generateInstanceId(), templateId: '', prompt: '', models: [] },
|
||||
{ id: generateInstanceId(), prompt: '', models: [] },
|
||||
]);
|
||||
const [selectedAgent, setSelectedAgent] = React.useState<string>('');
|
||||
const [attachedFiles, setAttachedFiles] = React.useState<MultiRunAttachedFile[]>([]);
|
||||
@@ -102,12 +103,6 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
const [isolateRuns, setIsolateRuns] = React.useState(true);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(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<MultiRunLauncherProps> = ({
|
||||
}, []);
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
@@ -390,7 +377,6 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
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<MultiRunLauncherProps> = ({
|
||||
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<MultiRunLauncherProps> = ({
|
||||
interface RunGroupCardProps {
|
||||
group: RunGroupState;
|
||||
groupIndex: number;
|
||||
templates: { id: string; name: string; body: string; isDefault: boolean }[];
|
||||
canRemove: boolean;
|
||||
onUpdate: (groupId: string, updates: Partial<RunGroupState>) => void;
|
||||
onRemove: (groupId: string) => void;
|
||||
onTemplateChange: (groupId: string, templateId: string) => void;
|
||||
}
|
||||
|
||||
const RunGroupCard: React.FC<RunGroupCardProps> = ({
|
||||
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<HTMLTextAreaElement>(null);
|
||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||
const snippetRef = React.useRef<SnippetAutocompleteHandle>(null);
|
||||
|
||||
const handleAddModel = React.useCallback((model: ModelSelectionWithId) => {
|
||||
if (group.models.length >= MAX_MODELS_PER_GROUP) return;
|
||||
@@ -743,6 +733,182 @@ const RunGroupCard: React.FC<RunGroupCardProps> = ({
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<div className="rounded-lg border border-border p-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -761,33 +927,55 @@ const RunGroupCard: React.FC<RunGroupCardProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>{t('multirun.launcher.groups.template.label')}</FieldLabel>
|
||||
<Select
|
||||
value={group.templateId || '__custom__'}
|
||||
onValueChange={(v) => onTemplateChange(group.id, v === '__custom__' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('multirun.launcher.groups.template.placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__custom__">{t('multirun.launcher.groups.template.custom')}</SelectItem>
|
||||
{templates.map((tpl) => (
|
||||
<SelectItem key={tpl.id} value={tpl.id}>{tpl.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel required>{t('multirun.launcher.groups.prompt.label')}</FieldLabel>
|
||||
<Textarea
|
||||
value={group.prompt}
|
||||
onChange={(e) => onUpdate(group.id, { prompt: e.target.value })}
|
||||
placeholder={t('multirun.launcher.groups.prompt.placeholder')}
|
||||
className="typography-meta min-h-[80px] max-h-[200px] resize-none overflow-y-auto field-sizing-content"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={promptTextareaRef}
|
||||
value={group.prompt}
|
||||
onChange={(event) => {
|
||||
const nextPrompt = event.target.value;
|
||||
setPrompt(nextPrompt);
|
||||
const cursorPosition = event.target.selectionStart ?? nextPrompt.length;
|
||||
updateAutocompleteState(nextPrompt, cursorPosition);
|
||||
}}
|
||||
onKeyDown={handlePromptKeyDown}
|
||||
placeholder={t('multirun.launcher.groups.prompt.placeholder')}
|
||||
className="typography-meta min-h-[80px] max-h-[200px] resize-none overflow-y-auto field-sizing-content"
|
||||
required
|
||||
/>
|
||||
|
||||
{showCommandAutocomplete ? (
|
||||
<CommandAutocomplete
|
||||
ref={commandRef}
|
||||
searchQuery={commandQuery}
|
||||
onCommandSelect={handleCommandSelect}
|
||||
onClose={() => setShowCommandAutocomplete(false)}
|
||||
style={{ left: 0, top: 'auto', bottom: 'calc(100% + 6px)', marginBottom: 0, maxWidth: '100%' }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showFileMention ? (
|
||||
<FileMentionAutocomplete
|
||||
ref={mentionRef}
|
||||
searchQuery={mentionQuery}
|
||||
onFileSelect={handleFileSelect}
|
||||
onAgentSelect={handleAgentSelect}
|
||||
onClose={() => setShowFileMention(false)}
|
||||
style={{ left: 0, top: 'auto', bottom: 'calc(100% + 6px)', marginBottom: 0, maxWidth: '100%' }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showSnippetAutocomplete ? (
|
||||
<SnippetAutocomplete
|
||||
ref={snippetRef}
|
||||
searchQuery={snippetQuery}
|
||||
onSnippetSelect={handleSnippetSelect}
|
||||
onClose={() => setShowSnippetAutocomplete(false)}
|
||||
style={{ left: 0, top: 'auto', bottom: 'calc(100% + 6px)', marginBottom: 0, maxWidth: '100%' }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -24,7 +25,7 @@ const PROFILE_COLORS = [
|
||||
{ key: 'type', label: 'Yellow', cssVar: 'var(--syntax-type)' },
|
||||
];
|
||||
|
||||
const PROFILE_ICONS = [
|
||||
const PROFILE_ICONS: Array<{ key: string; Icon: IconName; label: string }> = [
|
||||
{ key: 'branch', Icon: 'git-branch', label: 'Branch' },
|
||||
{ key: 'briefcase', Icon: 'briefcase', label: 'Work' },
|
||||
{ key: 'house', Icon: 'home', label: 'Personal' },
|
||||
|
||||
@@ -22,10 +22,11 @@ import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings
|
||||
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const ICON_MAP: Record<string, string> = {
|
||||
const ICON_MAP: Record<string, IconName> = {
|
||||
branch: 'git-branch',
|
||||
briefcase: 'briefcase',
|
||||
house: 'home',
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
import { usePromptTemplatesStore } from '@/stores/usePromptTemplatesStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { RiFileTextLine } from '@remixicon/react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const PromptTemplatesPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
selectedTemplateId,
|
||||
templates,
|
||||
updateTemplate,
|
||||
createTemplate,
|
||||
getTemplateById,
|
||||
} = usePromptTemplatesStore(useShallow((s) => ({
|
||||
selectedTemplateId: s.selectedTemplateId,
|
||||
templates: s.templates,
|
||||
updateTemplate: s.updateTemplate,
|
||||
createTemplate: s.createTemplate,
|
||||
getTemplateById: s.getTemplateById,
|
||||
})));
|
||||
|
||||
const selectedTemplate = selectedTemplateId ? getTemplateById(selectedTemplateId) : null;
|
||||
const isNew = Boolean(selectedTemplateId && !selectedTemplate && templates.length > 0);
|
||||
|
||||
const [name, setName] = React.useState('');
|
||||
const [body, setBody] = React.useState('');
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const initialStateRef = React.useRef<{ name: string; body: string } | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedTemplate) {
|
||||
setName(selectedTemplate.name);
|
||||
setBody(selectedTemplate.body);
|
||||
initialStateRef.current = { name: selectedTemplate.name, body: selectedTemplate.body };
|
||||
} else if (isNew && selectedTemplateId) {
|
||||
setName(selectedTemplateId);
|
||||
setBody('');
|
||||
initialStateRef.current = { name: selectedTemplateId, body: '' };
|
||||
}
|
||||
}, [selectedTemplate, isNew, selectedTemplateId, templates]);
|
||||
|
||||
const isDirty = React.useMemo(() => {
|
||||
const initial = initialStateRef.current;
|
||||
if (!initial) return false;
|
||||
return name !== initial.name || body !== initial.body;
|
||||
}, [name, body]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedTemplateId) return;
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const trimmedBody = body.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
toast.error(t('settings.promptTemplates.page.toast.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (selectedTemplate) {
|
||||
const updates: { name?: string; body?: string } = {};
|
||||
if (trimmedName !== selectedTemplate.name) updates.name = trimmedName;
|
||||
if (trimmedBody !== selectedTemplate.body) updates.body = trimmedBody;
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
const success = await updateTemplate(selectedTemplateId, updates);
|
||||
if (success) {
|
||||
toast.success(t('settings.promptTemplates.page.toast.updated'));
|
||||
initialStateRef.current = { name: trimmedName, body: trimmedBody };
|
||||
} else {
|
||||
toast.error(t('settings.promptTemplates.page.toast.updateFailed'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const success = await createTemplate(selectedTemplateId, trimmedName, trimmedBody);
|
||||
if (success) {
|
||||
toast.success(t('settings.promptTemplates.page.toast.created'));
|
||||
initialStateRef.current = { name: trimmedName, body: trimmedBody };
|
||||
} else {
|
||||
toast.error(t('settings.promptTemplates.page.toast.createFailed'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving prompt template:', error);
|
||||
toast.error(t('settings.promptTemplates.page.toast.saveUnexpectedError'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedTemplateId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiFileTextLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">{t('settings.promptTemplates.page.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.promptTemplates.page.empty.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{selectedTemplate ? selectedTemplate.name : t('settings.promptTemplates.page.title.new')}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
{selectedTemplate ? t('settings.promptTemplates.page.subtitle.edit') : t('settings.promptTemplates.page.subtitle.new')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.promptTemplates.page.section.identity')}
|
||||
</h3>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.promptTemplates.page.field.name')}</span>
|
||||
<div className="mt-1.5">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('settings.promptTemplates.page.field.namePlaceholder')}
|
||||
className="h-7 w-full max-w-sm px-2"
|
||||
disabled={selectedTemplate?.isDefault === true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.promptTemplates.page.section.template')}
|
||||
</h3>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder={t('settings.promptTemplates.page.field.templatePlaceholder')}
|
||||
rows={12}
|
||||
className="w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent resize-y"
|
||||
/>
|
||||
</section>
|
||||
<div className="mt-2 px-2">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.promptTemplates.page.templateHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !isDirty}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
@@ -1,316 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { isMobileDeviceViaCSS } from '@/lib/device';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { RiAddLine, RiMore2Line, RiDeleteBinLine, RiFileCopyLine, RiEditLine, RiFileTextLine } from '@remixicon/react';
|
||||
import { usePromptTemplatesStore } from '@/stores/usePromptTemplatesStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { PromptTemplate } from '@/types/prompt-template';
|
||||
|
||||
interface PromptTemplatesSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const PromptTemplatesSidebar: React.FC<PromptTemplatesSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const [confirmDeleteTemplate, setConfirmDeleteTemplate] = React.useState<PromptTemplate | null>(null);
|
||||
const [isDeletePending, setIsDeletePending] = React.useState(false);
|
||||
const [openMenuId, setOpenMenuId] = React.useState<string | null>(null);
|
||||
const [renameDialogTemplate, setRenameDialogTemplate] = React.useState<PromptTemplate | null>(null);
|
||||
const [renameNewName, setRenameNewName] = React.useState('');
|
||||
|
||||
const {
|
||||
selectedTemplateId,
|
||||
templates,
|
||||
setSelectedTemplate,
|
||||
deleteTemplate,
|
||||
updateTemplate,
|
||||
loadTemplates,
|
||||
} = usePromptTemplatesStore(useShallow((s) => ({
|
||||
selectedTemplateId: s.selectedTemplateId,
|
||||
templates: s.templates,
|
||||
setSelectedTemplate: s.setSelectedTemplate,
|
||||
deleteTemplate: s.deleteTemplate,
|
||||
updateTemplate: s.updateTemplate,
|
||||
loadTemplates: s.loadTemplates,
|
||||
})));
|
||||
|
||||
React.useEffect(() => {
|
||||
loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
const handleCreateNew = async () => {
|
||||
const baseName = 'new-template';
|
||||
let newName = baseName;
|
||||
let counter = 1;
|
||||
const existingIds = new Set(templates.map((t) => t.id));
|
||||
while (existingIds.has(newName.replace(/\s+/g, '-').toLowerCase())) {
|
||||
newName = `${baseName}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
const slug = newName.replace(/\s+/g, '-').toLowerCase();
|
||||
const success = await usePromptTemplatesStore.getState().createTemplate(slug, newName, '');
|
||||
if (!success) {
|
||||
toast.error(t('settings.promptTemplates.page.toast.createFailed'));
|
||||
return;
|
||||
}
|
||||
usePromptTemplatesStore.getState().setSelectedTemplate(slug);
|
||||
onItemSelect?.();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirmDeleteTemplate) return;
|
||||
setIsDeletePending(true);
|
||||
const success = await deleteTemplate(confirmDeleteTemplate.id);
|
||||
if (success) {
|
||||
toast.success(t('settings.promptTemplates.sidebar.toast.deleted', { name: confirmDeleteTemplate.name }));
|
||||
setConfirmDeleteTemplate(null);
|
||||
} else {
|
||||
toast.error(t('settings.promptTemplates.sidebar.toast.deleteFailed'));
|
||||
}
|
||||
setIsDeletePending(false);
|
||||
};
|
||||
|
||||
const handleDuplicate = async (template: PromptTemplate) => {
|
||||
let copyName = `${template.name} Copy`;
|
||||
let copyId = `${template.id}-copy`;
|
||||
let counter = 1;
|
||||
const existingIds = new Set(templates.map((t) => t.id));
|
||||
while (existingIds.has(copyId)) {
|
||||
copyName = `${template.name} Copy ${counter}`;
|
||||
copyId = `${template.id}-copy-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
const success = await usePromptTemplatesStore.getState().createTemplate(copyId, copyName, template.body);
|
||||
if (!success) {
|
||||
toast.error(t('settings.promptTemplates.page.toast.createFailed'));
|
||||
return;
|
||||
}
|
||||
setSelectedTemplate(copyId);
|
||||
onItemSelect?.();
|
||||
};
|
||||
|
||||
const handleOpenRename = (template: PromptTemplate) => {
|
||||
setRenameNewName(template.name);
|
||||
setRenameDialogTemplate(template);
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!renameDialogTemplate) return;
|
||||
const trimmed = renameNewName.trim();
|
||||
if (!trimmed) {
|
||||
toast.error(t('settings.promptTemplates.sidebar.toast.nameRequired'));
|
||||
return;
|
||||
}
|
||||
if (trimmed === renameDialogTemplate.name) {
|
||||
setRenameDialogTemplate(null);
|
||||
return;
|
||||
}
|
||||
const success = await updateTemplate(renameDialogTemplate.id, { name: trimmed });
|
||||
if (success) {
|
||||
toast.success(t('settings.promptTemplates.sidebar.toast.renamed'));
|
||||
} else {
|
||||
toast.error(t('settings.promptTemplates.sidebar.toast.renameFailed'));
|
||||
}
|
||||
setRenameDialogTemplate(null);
|
||||
};
|
||||
|
||||
const sortedTemplates = React.useMemo(
|
||||
() => [...templates].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[templates],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', 'bg-background')}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.promptTemplates.sidebar.title')}</h2>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.promptTemplates.sidebar.total', { count: templates.length })}</span>
|
||||
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 -my-1 text-muted-foreground" onClick={handleCreateNew}>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
|
||||
{sortedTemplates.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiFileTextLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">{t('settings.promptTemplates.sidebar.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.promptTemplates.sidebar.empty.description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedTemplates.map((template) => (
|
||||
<TemplateListItem
|
||||
key={template.id}
|
||||
template={template}
|
||||
isSelected={selectedTemplateId === template.id}
|
||||
onSelect={() => {
|
||||
setSelectedTemplate(template.id);
|
||||
onItemSelect?.();
|
||||
}}
|
||||
onDelete={() => setConfirmDeleteTemplate(template)}
|
||||
onRename={() => handleOpenRename(template)}
|
||||
onDuplicate={() => handleDuplicate(template)}
|
||||
isMenuOpen={openMenuId === template.id}
|
||||
onMenuOpenChange={(open) => setOpenMenuId(open ? template.id : null)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
|
||||
<Dialog
|
||||
open={confirmDeleteTemplate !== null}
|
||||
onOpenChange={(open) => { if (!open && !isDeletePending) setConfirmDeleteTemplate(null); }}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.promptTemplates.sidebar.dialog.deleteTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.promptTemplates.sidebar.dialog.deleteDescription', { name: confirmDeleteTemplate?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmDeleteTemplate(null)} disabled={isDeletePending}>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleDelete} disabled={isDeletePending}>
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={renameDialogTemplate !== null} onOpenChange={(open) => !open && setRenameDialogTemplate(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.promptTemplates.sidebar.renameDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.promptTemplates.sidebar.renameDialog.description', { name: renameDialogTemplate?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameNewName}
|
||||
onChange={(e) => setRenameNewName(e.target.value)}
|
||||
placeholder={t('settings.promptTemplates.sidebar.renameDialog.placeholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); }}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="ghost" onClick={() => setRenameDialogTemplate(null)}>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRename}>
|
||||
{t('settings.common.actions.rename')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TemplateListItemProps {
|
||||
template: PromptTemplate;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete?: () => void;
|
||||
onRename?: () => void;
|
||||
onDuplicate: () => void;
|
||||
isMenuOpen: boolean;
|
||||
onMenuOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const TemplateListItem: React.FC<TemplateListItemProps> = ({
|
||||
template,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
onDuplicate,
|
||||
isMenuOpen,
|
||||
onMenuOpenChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMobile = isMobileDeviceViaCSS();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
|
||||
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover',
|
||||
)}
|
||||
onContextMenu={!isMobile ? (e) => { e.preventDefault(); onMenuOpenChange(true); } : undefined}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-ui-label font-normal truncate text-foreground">
|
||||
{template.name}
|
||||
</span>
|
||||
{template.isDefault && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{t('settings.promptTemplates.sidebar.badge.default')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{template.body && (
|
||||
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
|
||||
{template.body.substring(0, 80)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!template.isDefault && (
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
{onRename && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRename(); }}>
|
||||
<RiEditLine className="h-4 w-4 mr-px" />
|
||||
{t('settings.common.actions.rename')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onDuplicate(); }}>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
{t('settings.common.actions.duplicate')}
|
||||
</DropdownMenuItem>
|
||||
{onDelete && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onDelete(); }} className="text-destructive focus:text-destructive">
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { toast } from '@/components/ui';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
@@ -1055,7 +1056,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
const contextTokens = formatTokens(metadata?.limit?.context);
|
||||
const outputTokens = formatTokens(metadata?.limit?.output);
|
||||
|
||||
const capabilityIcons: Array<{ key: string; icon: string; label: string }> = [];
|
||||
const capabilityIcons: Array<{ key: string; icon: IconName; label: string }> = [];
|
||||
if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: "tools", label: t('settings.providers.page.models.capability.toolCalling') });
|
||||
if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: "brain-ai-3", label: t('settings.providers.page.models.capability.reasoning') });
|
||||
if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: "file-image", label: t('settings.providers.page.models.capability.imageInput') });
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SettingsSidebarItemAction {
|
||||
/** Label shown in dropdown menu */
|
||||
label: string;
|
||||
/** Icon component to show before label */
|
||||
icon?: string;
|
||||
icon?: IconName;
|
||||
/** Callback when action is clicked */
|
||||
onClick: () => void;
|
||||
/** If true, uses destructive styling (red text) */
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useSnippetsStore, type SnippetScope } from '@/stores/useSnippetsStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const SnippetsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { selectedSnippetName, snippets, snippetDraft, setSnippetDraft, updateSnippet, createSnippet } = useSnippetsStore(useShallow((s) => ({
|
||||
selectedSnippetName: s.selectedSnippetName,
|
||||
snippets: s.snippets,
|
||||
snippetDraft: s.snippetDraft,
|
||||
setSnippetDraft: s.setSnippetDraft,
|
||||
updateSnippet: s.updateSnippet,
|
||||
createSnippet: s.createSnippet,
|
||||
})));
|
||||
|
||||
const selectedSnippet = React.useMemo(
|
||||
() => selectedSnippetName
|
||||
? snippets.find((snippet) => snippet.name === selectedSnippetName || snippet.aliases.includes(selectedSnippetName)) ?? null
|
||||
: null,
|
||||
[selectedSnippetName, snippets],
|
||||
);
|
||||
const isNew = Boolean(snippetDraft && snippetDraft.name === selectedSnippetName && !selectedSnippet);
|
||||
const [draftName, setDraftName] = React.useState('');
|
||||
const [draftScope, setDraftScope] = React.useState<SnippetScope>('global');
|
||||
const [description, setDescription] = React.useState('');
|
||||
const [aliases, setAliases] = React.useState('');
|
||||
const [content, setContent] = React.useState('');
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const initialStateRef = React.useRef<{ draftName: string; draftScope: SnippetScope; description: string; aliases: string; content: string } | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isNew && snippetDraft) {
|
||||
const next = {
|
||||
draftName: snippetDraft.name || '',
|
||||
draftScope: snippetDraft.scope || 'global',
|
||||
description: snippetDraft.description || '',
|
||||
aliases: (snippetDraft.aliases || []).join(', '),
|
||||
content: snippetDraft.content || '',
|
||||
};
|
||||
setDraftName(next.draftName);
|
||||
setDraftScope(next.draftScope);
|
||||
setDescription(next.description);
|
||||
setAliases(next.aliases);
|
||||
setContent(next.content);
|
||||
initialStateRef.current = next;
|
||||
} else if (selectedSnippet) {
|
||||
const next = {
|
||||
draftName: '',
|
||||
draftScope: 'global' as SnippetScope,
|
||||
description: selectedSnippet.description ?? '',
|
||||
aliases: selectedSnippet.aliases.join(', '),
|
||||
content: selectedSnippet.content,
|
||||
};
|
||||
setDescription(next.description);
|
||||
setAliases(next.aliases);
|
||||
setContent(next.content);
|
||||
initialStateRef.current = next;
|
||||
}
|
||||
}, [selectedSnippet, isNew, selectedSnippetName, snippetDraft]);
|
||||
|
||||
const isDirty = React.useMemo(() => {
|
||||
const initial = initialStateRef.current;
|
||||
if (!initial) return false;
|
||||
if (isNew && draftName !== initial.draftName) return true;
|
||||
if (isNew && draftScope !== initial.draftScope) return true;
|
||||
return description !== initial.description || aliases !== initial.aliases || content !== initial.content;
|
||||
}, [aliases, content, description, draftName, draftScope, isNew]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const snippetName = isNew ? draftName.trim().replace(/\s+/g, '-') : selectedSnippetName?.trim();
|
||||
if (!snippetName) {
|
||||
toast.error(t('settings.snippets.page.toast.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
toast.error(t('settings.snippets.page.toast.contentRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedAliases = aliases.split(',').map((alias) => alias.trim()).filter(Boolean);
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const success = isNew
|
||||
? await createSnippet(snippetName, content, { aliases: parsedAliases, description, scope: draftScope })
|
||||
: await updateSnippet(snippetName, { content, aliases: parsedAliases, description });
|
||||
if (!success) {
|
||||
toast.error(t('settings.snippets.page.toast.saveFailed'));
|
||||
return;
|
||||
}
|
||||
toast.success(t('settings.snippets.page.toast.saved'));
|
||||
if (isNew) setSnippetDraft(null);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedSnippetName) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Icon name="file-text" className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">{t('settings.snippets.page.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.snippets.page.empty.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
|
||||
<div className="mb-4 min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{isNew ? t('settings.snippets.page.title.new') : `#${selectedSnippetName}`}
|
||||
</h2>
|
||||
{selectedSnippet ? <p className="typography-meta text-muted-foreground truncate">{selectedSnippet.filePath}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 space-y-3 px-2">
|
||||
<div>
|
||||
{isNew ? (
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground">#</span>
|
||||
<Input value={draftName} onChange={(e) => setDraftName(e.target.value)} placeholder={t('settings.snippets.page.field.namePlaceholder')} className="h-7 w-44 px-2" />
|
||||
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as SnippetScope)}>
|
||||
<SelectTrigger className="w-fit min-w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="global">{t('settings.common.scope.global')}</SelectItem>
|
||||
<SelectItem value="project">{t('settings.common.scope.project')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')}</span>
|
||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('settings.snippets.page.field.descriptionPlaceholder')} className="mt-1.5 h-7 w-full max-w-sm px-2" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.snippets.page.field.aliases')}</span>
|
||||
<Input value={aliases} onChange={(e) => setAliases(e.target.value)} placeholder={t('settings.snippets.page.field.aliasesPlaceholder')} className="mt-1.5 h-7 w-full max-w-sm px-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 px-2">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.snippets.page.field.content')}</span>
|
||||
<Textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('settings.snippets.page.field.contentPlaceholder')} rows={12} className="mt-1.5 w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent" />
|
||||
<p className="mt-2 typography-meta text-muted-foreground">{t('settings.snippets.page.hint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1">
|
||||
<Button onClick={handleSave} disabled={isSaving || !isDirty} size="xs" className="!font-normal">
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Snippet } from '@/types/snippet';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface SnippetsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const SnippetsSidebar: React.FC<SnippetsSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const [confirmDeleteSnippet, setConfirmDeleteSnippet] = React.useState<Snippet | null>(null);
|
||||
const [openMenuName, setOpenMenuName] = React.useState<string | null>(null);
|
||||
const { selectedSnippetName, snippets, setSelectedSnippet, setSnippetDraft, deleteSnippet, loadSnippets } = useSnippetsStore(useShallow((s) => ({
|
||||
selectedSnippetName: s.selectedSnippetName,
|
||||
snippets: s.snippets,
|
||||
setSelectedSnippet: s.setSelectedSnippet,
|
||||
setSnippetDraft: s.setSnippetDraft,
|
||||
deleteSnippet: s.deleteSnippet,
|
||||
loadSnippets: s.loadSnippets,
|
||||
})));
|
||||
|
||||
React.useEffect(() => {
|
||||
loadSnippets();
|
||||
}, [loadSnippets]);
|
||||
|
||||
const handleCreateNew = async () => {
|
||||
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);
|
||||
onItemSelect?.();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirmDeleteSnippet) return;
|
||||
const success = await deleteSnippet(confirmDeleteSnippet.name);
|
||||
if (success) {
|
||||
toast.success(t('settings.snippets.sidebar.toast.deleted'));
|
||||
setConfirmDeleteSnippet(null);
|
||||
} else {
|
||||
toast.error(t('settings.snippets.sidebar.toast.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const sortedSnippets = React.useMemo(() => [...snippets].sort((a, b) => a.name.localeCompare(b.name)), [snippets]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', 'bg-background')}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.snippets.sidebar.title')}</h2>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.snippets.sidebar.total', { count: snippets.length })}</span>
|
||||
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 -my-1 text-muted-foreground" onClick={handleCreateNew} aria-label={t('settings.snippets.sidebar.actions.create')}>
|
||||
<Icon name="add" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
|
||||
{sortedSnippets.map((snippet) => (
|
||||
<div key={`${snippet.source}:${snippet.filePath}`} className={cn('group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none', selectedSnippetName === snippet.name ? 'bg-interactive-selection' : 'hover:bg-interactive-hover')}>
|
||||
<button onClick={() => { setSelectedSnippet(snippet.name); onItemSelect?.(); }} className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-ui-label font-normal truncate text-foreground">#{snippet.name}</span>
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">{t(`snippets.source.${snippet.source}`)}</span>
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
|
||||
{snippet.description || snippet.content.replace(/\s+/g, ' ').substring(0, 80)}
|
||||
</div>
|
||||
</button>
|
||||
<DropdownMenu open={openMenuName === snippet.name} onOpenChange={(open) => setOpenMenuName(open ? snippet.name : null)}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100" aria-label={t('settings.snippets.sidebar.actions.more', { name: snippet.name })}>
|
||||
<Icon name="more-2" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); setConfirmDeleteSnippet(snippet); }} className="text-destructive focus:text-destructive">
|
||||
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableOverlay>
|
||||
|
||||
<Dialog open={confirmDeleteSnippet !== null} onOpenChange={(open) => { if (!open) setConfirmDeleteSnippet(null); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.snippets.sidebar.dialog.deleteTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.snippets.sidebar.dialog.deleteDescription', { name: confirmDeleteSnippet?.name ?? '' })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmDeleteSnippet(null)}>{t('settings.common.actions.cancel')}</Button>
|
||||
<Button size="sm" onClick={handleDelete}>{t('settings.common.actions.delete')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { AgentSelector } from '@/components/sections/commands/AgentSelector';
|
||||
import { isPrimaryMode } from '@/components/chat/mobileControlsUtils';
|
||||
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 { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -618,6 +619,8 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
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 [calendarMonth, setCalendarMonth] = React.useState<Date>(() => {
|
||||
const initialDate = parseISODateToLocal(task?.schedule?.date || '') || new Date();
|
||||
return new Date(initialDate.getFullYear(), initialDate.getMonth(), 1);
|
||||
@@ -626,6 +629,7 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
const promptTextareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||
const snippetRef = React.useRef<SnippetAutocompleteHandle>(null);
|
||||
const localeUse24Hour = React.useMemo(() => getUses24Hour(locale), [locale]);
|
||||
const localeWeekStartsOn = React.useMemo(() => getWeekStartsOn(locale), [locale]);
|
||||
const use24Hour = React.useMemo(() => {
|
||||
@@ -828,6 +832,7 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
setCommandQuery(value.substring(1, commandEnd));
|
||||
setShowCommandAutocomplete(true);
|
||||
setShowFileMention(false);
|
||||
setShowSnippetAutocomplete(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -835,6 +840,21 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
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;
|
||||
@@ -921,6 +941,7 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
setPromptValue(nextPrompt);
|
||||
setShowCommandAutocomplete(false);
|
||||
setCommandQuery('');
|
||||
setShowSnippetAutocomplete(false);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const currentTextarea = promptTextareaRef.current;
|
||||
@@ -933,6 +954,31 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
});
|
||||
}, [setPromptValue, updateAutocompleteState]);
|
||||
|
||||
const handleSnippetSelect = React.useCallback((_snippet: unknown, trigger: string) => {
|
||||
const promptValue = draft.execution.prompt;
|
||||
const textarea = promptTextareaRef.current;
|
||||
const cursorPosition = textarea?.selectionStart ?? promptValue.length;
|
||||
const textBeforeCursor = promptValue.substring(0, cursorPosition);
|
||||
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
|
||||
const startIndex = lastHashSymbol !== -1 ? lastHashSymbol : cursorPosition;
|
||||
const nextPrompt = `${promptValue.substring(0, startIndex)}#${trigger} ${promptValue.substring(cursorPosition)}`;
|
||||
const nextCursor = startIndex + trigger.length + 2;
|
||||
|
||||
setPromptValue(nextPrompt);
|
||||
setShowSnippetAutocomplete(false);
|
||||
setSnippetQuery('');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const currentTextarea = promptTextareaRef.current;
|
||||
if (currentTextarea) {
|
||||
currentTextarea.selectionStart = nextCursor;
|
||||
currentTextarea.selectionEnd = nextCursor;
|
||||
currentTextarea.focus();
|
||||
}
|
||||
updateAutocompleteState(nextPrompt, nextCursor);
|
||||
});
|
||||
}, [draft.execution.prompt, setPromptValue, updateAutocompleteState]);
|
||||
|
||||
const handlePromptKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showCommandAutocomplete && commandRef.current) {
|
||||
if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') {
|
||||
@@ -948,7 +994,14 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
mentionRef.current.handleKeyDown(event.key);
|
||||
}
|
||||
}
|
||||
}, [showCommandAutocomplete, showFileMention]);
|
||||
|
||||
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]);
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
const validationError = validateDraft(draft, t);
|
||||
@@ -1407,6 +1460,22 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showSnippetAutocomplete ? (
|
||||
<SnippetAutocomplete
|
||||
ref={snippetRef}
|
||||
searchQuery={snippetQuery}
|
||||
onSnippetSelect={handleSnippetSelect}
|
||||
onClose={() => setShowSnippetAutocomplete(false)}
|
||||
style={{
|
||||
left: 0,
|
||||
top: 'auto',
|
||||
bottom: 'calc(100% + 6px)',
|
||||
marginBottom: 0,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -147,7 +148,7 @@ const STATUS_META: Record<
|
||||
ScheduledTaskStatus,
|
||||
{
|
||||
tone: StatusTone;
|
||||
Icon: string;
|
||||
Icon: IconName;
|
||||
spin?: boolean;
|
||||
}
|
||||
> = {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useAgentsStore } from '@/stores/useAgentsStore';
|
||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
|
||||
import { usePromptTemplatesStore } from '@/stores/usePromptTemplatesStore';
|
||||
import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -29,8 +29,8 @@ import { UsageSidebar } from '@/components/sections/usage/UsageSidebar';
|
||||
import { UsagePage } from '@/components/sections/usage/UsagePage';
|
||||
import { MagicPromptsSidebar } from '@/components/sections/magic-prompts/MagicPromptsSidebar';
|
||||
import { MagicPromptsPage } from '@/components/sections/magic-prompts/MagicPromptsPage';
|
||||
import { PromptTemplatesSidebar } from '@/components/sections/prompt-templates/PromptTemplatesSidebar';
|
||||
import { PromptTemplatesPage } from '@/components/sections/prompt-templates/PromptTemplatesPage';
|
||||
import { SnippetsSidebar } from '@/components/sections/snippets/SnippetsSidebar';
|
||||
import { SnippetsPage } from '@/components/sections/snippets/SnippetsPage';
|
||||
import { GitPage } from '@/components/sections/git-identities/GitPage';
|
||||
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
|
||||
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
|
||||
@@ -76,7 +76,7 @@ const pageOrder: SettingsPageSlug[] = [
|
||||
'shortcuts',
|
||||
'git',
|
||||
'magic-prompts',
|
||||
'prompt-templates',
|
||||
'snippets',
|
||||
'projects',
|
||||
'remote-instances',
|
||||
'agents',
|
||||
@@ -91,6 +91,8 @@ const pageOrder: SettingsPageSlug[] = [
|
||||
'tunnel',
|
||||
];
|
||||
|
||||
const SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const;
|
||||
|
||||
function buildRuntimeContext(isDesktop: boolean): SettingsRuntimeContext {
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const isWeb = !isDesktop && isWebRuntime();
|
||||
@@ -117,8 +119,8 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
||||
return 'chat-ai-3';
|
||||
case 'magic-prompts':
|
||||
return 'ai-generate-2';
|
||||
case 'prompt-templates':
|
||||
return 'file-text';
|
||||
case 'snippets':
|
||||
return SNIPPETS_SETTINGS_ICON.icon;
|
||||
case 'notifications':
|
||||
return 'notification-3';
|
||||
case 'shortcuts':
|
||||
@@ -363,8 +365,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
void useSkillsStore.getState().loadSkills();
|
||||
void useSkillsCatalogStore.getState().loadCatalog();
|
||||
}
|
||||
if (settingsSlug === 'prompt-templates') {
|
||||
void usePromptTemplatesStore.getState().loadTemplates();
|
||||
if (settingsSlug === 'snippets') {
|
||||
void useSnippetsStore.getState().loadSnippets();
|
||||
}
|
||||
}, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, settingsSlug]);
|
||||
|
||||
@@ -432,8 +434,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return t('settings.page.sessions.title');
|
||||
case 'magic-prompts':
|
||||
return t('settings.page.magicPrompts.title');
|
||||
case 'prompt-templates':
|
||||
return t('settings.page.promptTemplates.title');
|
||||
case 'snippets':
|
||||
return t('settings.page.snippets.title');
|
||||
case 'notifications':
|
||||
return t('settings.page.notifications.title');
|
||||
case 'voice':
|
||||
@@ -477,8 +479,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return <UsageSidebar onItemSelect={opts.onItemSelect} />;
|
||||
case 'magic-prompts':
|
||||
return <MagicPromptsSidebar onItemSelect={opts.onItemSelect} />;
|
||||
case 'prompt-templates':
|
||||
return <PromptTemplatesSidebar onItemSelect={opts.onItemSelect} />;
|
||||
case 'snippets':
|
||||
return <SnippetsSidebar onItemSelect={opts.onItemSelect} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -515,8 +517,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return <UsagePage />;
|
||||
case 'magic-prompts':
|
||||
return <MagicPromptsPage />;
|
||||
case 'prompt-templates':
|
||||
return <PromptTemplatesPage />;
|
||||
case 'snippets':
|
||||
return <SnippetsPage />;
|
||||
case 'git':
|
||||
return <GitPage />;
|
||||
case 'appearance':
|
||||
|
||||
@@ -21,6 +21,7 @@ import React from 'react';
|
||||
import type { BrowserVoiceStatus } from '@/hooks/useBrowserVoice';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
|
||||
export interface VoiceStatusIndicatorProps {
|
||||
/** Current voice status */
|
||||
@@ -53,7 +54,7 @@ const sizeClasses = {
|
||||
const statusConfig: Record<
|
||||
BrowserVoiceStatus,
|
||||
{
|
||||
icon: string;
|
||||
icon: IconName;
|
||||
color: string;
|
||||
labelKey:
|
||||
| 'voice.status.idle'
|
||||
|
||||
Reference in New Issue
Block a user