feat(local-llm): Sprint 4 — UX enhancements (F2, F3, F9, F11)
New features: - F2: Model search/filter — search input above models list (shown when 4+ models installed). Filters by name, family, and quantization level. Press / to focus the search input. - F3: Prompt history — saves last 20 prompts to localStorage with model name and timestamp. History dropdown in prompt modal with one-click re-run. Toggle via clock icon in textarea. - F9: Modelfile viewer — expanded model details now fetch and display the Modelfile via the show action. Collapsible <details> element with syntax-highlighted pre block. - F11: Keyboard shortcuts panel — press ? to toggle. Shows all shortcuts: ? (help), R (refresh), / (search), Esc (close/cancel), Cmd+Enter (send). Shortcuts only fire when not in an input field.
This commit is contained in:
parent
40c40756ed
commit
9c2f5f3396
@ -26,6 +26,10 @@ import {
|
||||
Plus,
|
||||
Send,
|
||||
Terminal,
|
||||
Search,
|
||||
History,
|
||||
Keyboard,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import type {
|
||||
OllamaData,
|
||||
@ -58,6 +62,10 @@ export default function Dashboard() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [streamMetrics, setStreamMetrics] = useState<StreamMetrics | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [modelSearch, setModelSearch] = useState('');
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const [modelfileData, setModelfileData] = useState<Record<string, string>>({});
|
||||
const responseRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
@ -91,10 +99,49 @@ export default function Dashboard() {
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchAll, promptLoading, pullLoading]);
|
||||
|
||||
// Escape key closes modals (respects streaming state)
|
||||
// Prompt history helpers (F3)
|
||||
const getPromptHistory = (): Array<{ prompt: string; model: string; ts: number }> => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('llm-prompt-history') || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const saveToHistory = (prompt: string, model: string) => {
|
||||
const history = getPromptHistory().filter(h => h.prompt !== prompt || h.model !== model);
|
||||
history.unshift({ prompt, model, ts: Date.now() });
|
||||
localStorage.setItem('llm-prompt-history', JSON.stringify(history.slice(0, 20)));
|
||||
};
|
||||
|
||||
// Fetch modelfile for expanded model (F9)
|
||||
const fetchModelfile = async (modelName: string) => {
|
||||
if (modelfileData[modelName]) return;
|
||||
try {
|
||||
const res = await fetch('/api/ollama', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'show', model: modelName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.modelfile) {
|
||||
setModelfileData(prev => ({ ...prev, [modelName]: data.modelfile }));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts (F11) + Escape handler
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const inInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA';
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (showShortcuts) {
|
||||
setShowShortcuts(false);
|
||||
return;
|
||||
}
|
||||
if (promptLoading) {
|
||||
abortRef.current?.abort();
|
||||
setPromptLoading(false);
|
||||
@ -104,11 +151,27 @@ export default function Dashboard() {
|
||||
setPromptText('');
|
||||
}
|
||||
setDeleteConfirm(null);
|
||||
return;
|
||||
}
|
||||
if (inInput) return;
|
||||
if (e.key === '?') {
|
||||
setShowShortcuts(s => !s);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'r' || e.key === 'R') {
|
||||
fetchAll();
|
||||
return;
|
||||
}
|
||||
if (e.key === '/') {
|
||||
e.preventDefault();
|
||||
setModelSearch('');
|
||||
document.querySelector<HTMLInputElement>('[data-model-search]')?.focus();
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [promptLoading]);
|
||||
}, [promptLoading, showShortcuts, fetchAll]);
|
||||
|
||||
const handleModelAction = async (action: string, model: string) => {
|
||||
setActionLoading(`${action}-${model}`);
|
||||
@ -193,6 +256,7 @@ export default function Dashboard() {
|
||||
// Streaming prompt
|
||||
const handlePrompt = async () => {
|
||||
if (!promptModel || !promptText.trim()) return;
|
||||
saveToHistory(promptText.trim(), promptModel);
|
||||
setPromptLoading(true);
|
||||
setPromptResponse('');
|
||||
setStreamMetrics(null);
|
||||
@ -433,6 +497,30 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Search (F2) */}
|
||||
{ollama?.status === 'online' && ollama.models.length > 3 && (
|
||||
<div className="relative mb-3">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
/>
|
||||
<input
|
||||
data-model-search
|
||||
type="text"
|
||||
value={modelSearch}
|
||||
onChange={e => setModelSearch(e.target.value)}
|
||||
placeholder="Filter models... (press / to focus)"
|
||||
className="w-full pl-9 pr-3 py-2 rounded-lg text-sm font-mono focus:outline-none focus:ring-2"
|
||||
style={{
|
||||
background: 'var(--surface-muted)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
color: 'var(--text-primary)',
|
||||
caretColor: 'var(--accent-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pull Model Input */}
|
||||
{ollama?.status === 'online' && (
|
||||
<div className="flex gap-2 mb-4">
|
||||
@ -508,192 +596,230 @@ export default function Dashboard() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{ollama.models.map(model => {
|
||||
const running = isRunning(model.name);
|
||||
const expanded = expandedModel === model.name;
|
||||
return (
|
||||
<div
|
||||
key={model.name}
|
||||
className="rounded-lg p-4 transition-colors"
|
||||
style={{
|
||||
background: running ? 'rgba(52, 211, 153, 0.05)' : 'var(--surface-muted)',
|
||||
border: running
|
||||
? '1px solid rgba(52, 211, 153, 0.2)'
|
||||
: '1px solid transparent',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center ${running ? 'bg-[rgba(52,211,153,0.15)]' : 'bg-[var(--surface-card)]'}`}
|
||||
>
|
||||
{running ? (
|
||||
<Zap className="w-4 h-4" style={{ color: 'var(--success)' }} />
|
||||
) : (
|
||||
<Brain className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
|
||||
)}
|
||||
{ollama.models
|
||||
.filter(
|
||||
m =>
|
||||
!modelSearch ||
|
||||
m.name.toLowerCase().includes(modelSearch.toLowerCase()) ||
|
||||
m.details?.family?.toLowerCase().includes(modelSearch.toLowerCase()) ||
|
||||
m.details?.quantization_level?.toLowerCase().includes(modelSearch.toLowerCase())
|
||||
)
|
||||
.map(model => {
|
||||
const running = isRunning(model.name);
|
||||
const expanded = expandedModel === model.name;
|
||||
return (
|
||||
<div
|
||||
key={model.name}
|
||||
className="rounded-lg p-4 transition-colors"
|
||||
style={{
|
||||
background: running ? 'rgba(52, 211, 153, 0.05)' : 'var(--surface-muted)',
|
||||
border: running
|
||||
? '1px solid rgba(52, 211, 153, 0.2)'
|
||||
: '1px solid transparent',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center ${running ? 'bg-[rgba(52,211,153,0.15)]' : 'bg-[var(--surface-card)]'}`}
|
||||
>
|
||||
{running ? (
|
||||
<Zap className="w-4 h-4" style={{ color: 'var(--success)' }} />
|
||||
) : (
|
||||
<Brain
|
||||
className="w-4 h-4"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold truncate">
|
||||
{model.name}
|
||||
</span>
|
||||
{running && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
||||
style={{
|
||||
background: 'rgba(52, 211, 153, 0.15)',
|
||||
color: 'var(--success)',
|
||||
}}
|
||||
>
|
||||
LOADED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-3 text-xs mt-0.5"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<span>{formatBytes(model.size)}</span>
|
||||
{model.details?.parameter_size && (
|
||||
<span>{model.details.parameter_size}</span>
|
||||
)}
|
||||
{model.details?.quantization_level && (
|
||||
<span>{model.details.quantization_level}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold truncate">
|
||||
{model.name}
|
||||
</span>
|
||||
{running && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
||||
<div className="flex items-center gap-2 ml-3">
|
||||
{running ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPromptModel(model.name);
|
||||
setPromptResponse('');
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--accent-primary)', color: 'white' }}
|
||||
>
|
||||
<Zap className="w-3 h-3" /> Prompt
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModelAction('unload', model.name)}
|
||||
disabled={actionLoading === `unload-${model.name}`}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: 'rgba(52, 211, 153, 0.15)',
|
||||
color: 'var(--success)',
|
||||
background: 'rgba(255, 110, 110, 0.1)',
|
||||
color: 'var(--danger)',
|
||||
}}
|
||||
>
|
||||
LOADED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-3 text-xs mt-0.5"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<span>{formatBytes(model.size)}</span>
|
||||
{model.details?.parameter_size && (
|
||||
<span>{model.details.parameter_size}</span>
|
||||
)}
|
||||
{model.details?.quantization_level && (
|
||||
<span>{model.details.quantization_level}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-3">
|
||||
{running ? (
|
||||
<>
|
||||
{actionLoading === `unload-${model.name}` ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Square className="w-3 h-3" />
|
||||
)}
|
||||
Unload
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setPromptModel(model.name);
|
||||
setPromptResponse('');
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--accent-primary)', color: 'white' }}
|
||||
>
|
||||
<Zap className="w-3 h-3" /> Prompt
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModelAction('unload', model.name)}
|
||||
disabled={actionLoading === `unload-${model.name}`}
|
||||
onClick={() => handleModelAction('load', model.name)}
|
||||
disabled={actionLoading === `load-${model.name}`}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: 'rgba(255, 110, 110, 0.1)',
|
||||
color: 'var(--danger)',
|
||||
background: 'rgba(52, 211, 153, 0.1)',
|
||||
color: 'var(--success)',
|
||||
}}
|
||||
>
|
||||
{actionLoading === `unload-${model.name}` ? (
|
||||
{actionLoading === `load-${model.name}` ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Square className="w-3 h-3" />
|
||||
<Play className="w-3 h-3" />
|
||||
)}
|
||||
Unload
|
||||
Load
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleModelAction('load', model.name)}
|
||||
disabled={actionLoading === `load-${model.name}`}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: 'rgba(52, 211, 153, 0.1)',
|
||||
color: 'var(--success)',
|
||||
}}
|
||||
>
|
||||
{actionLoading === `load-${model.name}` ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-3 h-3" />
|
||||
)}
|
||||
Load
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setExpandedModel(expanded ? null : model.name)}
|
||||
className="p-1.5 rounded-md transition-colors"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Running model VRAM info */}
|
||||
{running &&
|
||||
(() => {
|
||||
const info = getRunningInfo(model.name);
|
||||
return info ? (
|
||||
<div
|
||||
className="flex items-center gap-4 mt-2 text-[11px]"
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = expanded ? null : model.name;
|
||||
setExpandedModel(next);
|
||||
if (next) fetchModelfile(model.name);
|
||||
}}
|
||||
className="p-1.5 rounded-md transition-colors"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{info.size_vram > 0 && <span>VRAM: {formatBytes(info.size_vram)}</span>}
|
||||
{info.expires_at && (
|
||||
<span>Expires: {new Date(info.expires_at).toLocaleTimeString()}</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
{expanded && (
|
||||
<div
|
||||
className="mt-3 pt-3 text-xs font-mono space-y-1"
|
||||
style={{
|
||||
borderTop: '1px solid var(--border-subtle)',
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
<p>Digest: {model.digest?.substring(0, 16)}...</p>
|
||||
<p>Modified: {new Date(model.modified_at).toLocaleString()}</p>
|
||||
{model.details?.family && <p>Family: {model.details.family}</p>}
|
||||
<div
|
||||
className="mt-3 pt-2"
|
||||
style={{ borderTop: '1px solid var(--border-subtle)' }}
|
||||
>
|
||||
{deleteConfirm === model.name ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs" style={{ color: 'var(--danger)' }}>
|
||||
Delete this model?
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleModelAction('delete', model.name)}
|
||||
disabled={actionLoading === `delete-${model.name}`}
|
||||
className="px-2 py-1 rounded text-xs font-medium"
|
||||
style={{ background: 'var(--danger)', color: 'white' }}
|
||||
>
|
||||
{actionLoading === `delete-${model.name}`
|
||||
? 'Deleting...'
|
||||
: 'Yes, delete'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="px-2 py-1 rounded text-xs"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(model.name)}
|
||||
className="flex items-center gap-1.5 text-xs transition-colors"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" /> Remove from disk
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Running model VRAM info */}
|
||||
{running &&
|
||||
(() => {
|
||||
const info = getRunningInfo(model.name);
|
||||
return info ? (
|
||||
<div
|
||||
className="flex items-center gap-4 mt-2 text-[11px]"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{info.size_vram > 0 && (
|
||||
<span>VRAM: {formatBytes(info.size_vram)}</span>
|
||||
)}
|
||||
{info.expires_at && (
|
||||
<span>
|
||||
Expires: {new Date(info.expires_at).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
{expanded && (
|
||||
<div
|
||||
className="mt-3 pt-3 text-xs font-mono space-y-1"
|
||||
style={{
|
||||
borderTop: '1px solid var(--border-subtle)',
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
<p>Digest: {model.digest?.substring(0, 16)}...</p>
|
||||
<p>Modified: {new Date(model.modified_at).toLocaleString()}</p>
|
||||
{model.details?.family && <p>Family: {model.details.family}</p>}
|
||||
{modelfileData[model.name] && (
|
||||
<details className="mt-2">
|
||||
<summary
|
||||
className="cursor-pointer flex items-center gap-1"
|
||||
style={{ color: 'var(--accent-secondary)' }}
|
||||
>
|
||||
<FileText className="w-3 h-3" /> Modelfile
|
||||
</summary>
|
||||
<pre
|
||||
className="mt-1 p-2 rounded text-[11px] max-h-40 overflow-auto whitespace-pre-wrap"
|
||||
style={{
|
||||
background: 'var(--bg-canvas)',
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
{modelfileData[model.name]}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div
|
||||
className="mt-3 pt-2"
|
||||
style={{ borderTop: '1px solid var(--border-subtle)' }}
|
||||
>
|
||||
{deleteConfirm === model.name ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs" style={{ color: 'var(--danger)' }}>
|
||||
Delete this model?
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleModelAction('delete', model.name)}
|
||||
disabled={actionLoading === `delete-${model.name}`}
|
||||
className="px-2 py-1 rounded text-xs font-medium"
|
||||
style={{ background: 'var(--danger)', color: 'white' }}
|
||||
>
|
||||
{actionLoading === `delete-${model.name}`
|
||||
? 'Deleting...'
|
||||
: 'Yes, delete'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="px-2 py-1 rounded text-xs"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(model.name)}
|
||||
className="flex items-center gap-1.5 text-xs transition-colors"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" /> Remove from disk
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{ollama.models.length === 0 && (
|
||||
<p className="text-center py-8 text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||
No models installed. Run "ollama pull <model>" to get started.
|
||||
@ -987,24 +1113,73 @@ export default function Dashboard() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={promptText}
|
||||
onChange={e => setPromptText(e.target.value)}
|
||||
placeholder="Type your prompt here..."
|
||||
rows={2}
|
||||
className="flex-1 p-3 rounded-lg text-sm resize-none focus:outline-none focus:ring-2"
|
||||
{/* Prompt History (F3) */}
|
||||
{showHistory && (
|
||||
<div
|
||||
className="mb-3 max-h-40 overflow-y-auto rounded-lg"
|
||||
style={{
|
||||
background: 'var(--surface-card)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
color: 'var(--text-primary)',
|
||||
caretColor: 'var(--accent-primary)',
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && e.metaKey) handlePrompt();
|
||||
}}
|
||||
disabled={promptLoading}
|
||||
/>
|
||||
>
|
||||
{getPromptHistory().length === 0 ? (
|
||||
<p className="text-xs p-3 text-center" style={{ color: 'var(--text-tertiary)' }}>
|
||||
No history yet
|
||||
</p>
|
||||
) : (
|
||||
getPromptHistory().map((h, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setPromptText(h.prompt);
|
||||
setShowHistory(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs hover:bg-[var(--surface-muted)] transition-colors flex items-center justify-between gap-2"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
<span className="truncate flex-1">{h.prompt}</span>
|
||||
<span
|
||||
className="text-[10px] shrink-0"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{h.model}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
value={promptText}
|
||||
onChange={e => setPromptText(e.target.value)}
|
||||
placeholder="Type your prompt here..."
|
||||
rows={2}
|
||||
className="w-full p-3 pr-10 rounded-lg text-sm resize-none focus:outline-none focus:ring-2"
|
||||
style={{
|
||||
background: 'var(--surface-card)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
color: 'var(--text-primary)',
|
||||
caretColor: 'var(--accent-primary)',
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && e.metaKey) handlePrompt();
|
||||
}}
|
||||
disabled={promptLoading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowHistory(s => !s)}
|
||||
className="absolute right-2 top-2 p-1 rounded transition-colors"
|
||||
style={{ color: showHistory ? 'var(--accent-primary)' : 'var(--text-tertiary)' }}
|
||||
title="Prompt history"
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handlePrompt}
|
||||
disabled={promptLoading || !promptText.trim()}
|
||||
@ -1070,6 +1245,57 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard Shortcuts Modal (F11) */}
|
||||
{showShortcuts && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ background: 'rgba(0,0,0,0.6)' }}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) setShowShortcuts(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="card w-full max-w-sm p-6"
|
||||
style={{ background: 'var(--bg-elevated)', border: '1px solid var(--border-default)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Keyboard className="w-5 h-5" style={{ color: 'var(--accent-primary)' }} />
|
||||
Keyboard Shortcuts
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowShortcuts(false)}
|
||||
className="p-1.5 rounded-lg"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
['?', 'Toggle this panel'],
|
||||
['R', 'Refresh all data'],
|
||||
['/', 'Focus model search'],
|
||||
['Esc', 'Close modal / cancel stream'],
|
||||
['⌘+Enter', 'Send prompt'],
|
||||
].map(([key, desc]) => (
|
||||
<div key={key} className="flex items-center justify-between py-1.5">
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{desc}
|
||||
</span>
|
||||
<kbd
|
||||
className="text-xs font-mono px-2 py-1 rounded"
|
||||
style={{ background: 'var(--surface-muted)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user