spb/zyquo-cloud-web Public MIT
Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.
TypeScript 81.9%
CSS 8.9%
JavaScript 7.5%
Shell 1.1%
HTML 0.6%
1/*2 * CommandPalette.tsx3 * Zyquo Cloud Web4 *5 * Author: Simon-Pierre Boucher6 * Mail: contact@spboucher.ai7 *8 * ⌘K command palette — everything in one place: actions (new chat, switch9 * model, compare, settings, focus mode, summarize), jump to conversation,10 * apply persona, insert template, switch model. Keyboard-driven.11 */1213import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'14import { CATALOG } from '../features/catalogHelpers'15import { PERSONAS } from '../features/personasData'16import { PROMPT_TEMPLATES } from '../features/promptLibraryData'17import { summarizeConversation } from '../features/conversationTools'18import { PROVIDER_META } from '../providers/registry'19import { uid, useStore } from '../state/store'20import { contextBadge, type AIModel } from '../types'21import {22 IconColumns,23 IconCommand,24 IconDoc,25 IconGear,26 IconLightbulb,27 IconPlus,28 IconSearch,29 ProviderGlyph,30} from './icons'3132interface PaletteItem {33 id: string34 section: string35 icon: ReactNode36 title: string37 subtitle?: string38 run: () => void39}4041export default function CommandPalette({ onClose }: { onClose: () => void }) {42 const [query, setQuery] = useState('')43 const [highlight, setHighlight] = useState(0)44 const inputRef = useRef<HTMLInputElement>(null)45 const conversations = useStore((s) => s.conversations)46 const selectedID = useStore((s) => s.selectedID)4748 useEffect(() => inputRef.current?.focus(), [])4950 const items: PaletteItem[] = useMemo(() => {51 const state = useStore.getState()52 const q = query.toLowerCase().trim()53 const match = (...targets: (string | undefined)[]) =>54 q === '' || targets.some((t) => t?.toLowerCase().includes(q))5556 const out: PaletteItem[] = []5758 // Actions59 const actions: PaletteItem[] = [60 {61 id: 'act-new',62 section: 'Actions',63 icon: <IconPlus size={12} />,64 title: 'New chat',65 subtitle: '⌘N',66 run: () => state.newConversation(),67 },68 {69 id: 'act-compare',70 section: 'Actions',71 icon: <IconColumns size={12} />,72 title: 'Compare models…',73 run: () => state.setCompareOpen(true),74 },75 {76 id: 'act-model',77 section: 'Actions',78 icon: <IconCommand size={12} />,79 title: 'Switch model…',80 run: () => state.setModelMenuOpen(true),81 },82 {83 id: 'act-settings',84 section: 'Actions',85 icon: <IconGear size={12} />,86 title: 'Open Settings',87 subtitle: '⌘,',88 run: () => state.openSettings(),89 },90 {91 id: 'act-focus',92 section: 'Actions',93 icon: <IconCommand size={12} />,94 title: state.settings.focusMode ? 'Exit focus mode' : 'Focus mode',95 run: () => state.updateSettings({ focusMode: !state.settings.focusMode }),96 },97 ...(selectedID98 ? [99 {100 id: 'act-summarize',101 section: 'Actions',102 icon: <IconDoc size={12} />,103 title: 'Summarize this conversation',104 run: () => void summarizeConversation(selectedID),105 },106 {107 id: 'act-duplicate',108 section: 'Actions',109 icon: <IconCopyIconFallback />,110 title: 'Duplicate this conversation',111 run: () => {112 const conversation = state.conversations.find((c) => c.id === selectedID)113 if (!conversation) return114 state.restoreConversation({115 ...conversation,116 id: uid(),117 title: `${conversation.title} (copy)`,118 messages: conversation.messages.map((m) => ({ ...m, id: uid() })),119 createdAt: Date.now(),120 updatedAt: Date.now(),121 pinned: false,122 })123 },124 },125 ]126 : []),127 ]128 out.push(...actions.filter((a) => match(a.title)))129130 // Conversations131 out.push(132 ...conversations133 .filter((c) => match(c.title))134 .slice(0, 6)135 .map((c) => ({136 id: `conv-${c.id}`,137 section: 'Conversations',138 icon: <IconSearch size={12} />,139 title: c.title,140 subtitle: c.modelID,141 run: () => state.select(c.id),142 }))143 )144145 // Models146 const models = (q === '' ? CATALOG.filter((m) => m.isRecommended) : CATALOG).filter((m) =>147 match(m.displayName, m.id, PROVIDER_META[m.provider].displayName)148 )149 out.push(150 ...models.slice(0, 8).map((m: AIModel) => ({151 id: `model-${m.provider}-${m.id}`,152 section: 'Models',153 icon: <ProviderGlyph provider={m.provider} size={12} />,154 title: m.displayName,155 subtitle: `${PROVIDER_META[m.provider].displayName} · ${contextBadge(m)}`,156 run: () => {157 if (state.selectedID) {158 state.updateConversation(state.selectedID, { modelID: m.id, provider: m.provider })159 } else {160 state.newConversation({ model: m })161 }162 },163 }))164 )165166 // Personas167 out.push(168 ...PERSONAS.filter((p) => match(p.name, p.systemPrompt))169 .slice(0, 6)170 .map((p) => ({171 id: `persona-${p.id}`,172 section: 'Personas',173 icon: <IconLightbulb size={12} />,174 title: p.name,175 subtitle: p.systemPrompt.slice(0, 70),176 run: () => {177 if (state.selectedID) {178 state.updateConversation(state.selectedID, {179 systemPrompt: p.systemPrompt,180 personaID: p.id,181 })182 } else {183 state.newConversation({ systemPrompt: p.systemPrompt, personaID: p.id })184 }185 state.toast(`Persona: ${p.name}`, 'success')186 },187 }))188 )189190 // Templates191 out.push(192 ...PROMPT_TEMPLATES.filter((t) => match(t.title, t.category))193 .slice(0, 10)194 .map((t) => ({195 id: `tpl-${t.id}`,196 section: 'Prompt Library',197 icon: <IconDoc size={12} />,198 title: t.title,199 subtitle: t.category,200 run: () => {201 if (!state.selectedID) state.newConversation()202 state.setDraftSeed(t.body.replace('{{input}}', ''))203 },204 }))205 )206207 return out208 }, [query, conversations, selectedID])209210 useEffect(() => setHighlight(0), [query])211212 const runItem = (item: PaletteItem) => {213 onClose()214 item.run()215 }216217 const onKeyDown = (e: React.KeyboardEvent) => {218 if (e.key === 'ArrowDown') {219 e.preventDefault()220 setHighlight((h) => Math.min(h + 1, items.length - 1))221 } else if (e.key === 'ArrowUp') {222 e.preventDefault()223 setHighlight((h) => Math.max(h - 1, 0))224 } else if (e.key === 'Enter') {225 e.preventDefault()226 const item = items[highlight]227 if (item) runItem(item)228 } else if (e.key === 'Escape') {229 onClose()230 }231 }232233 let lastSection = ''234 return (235 <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>236 <div className="panel" style={{ width: 560 }} onKeyDown={onKeyDown}>237 <div className="panel-search">238 <IconCommand size={14} />239 <input240 ref={inputRef}241 placeholder="Switch model, run template, jump to chat…"242 value={query}243 onChange={(e) => setQuery(e.target.value)}244 />245 </div>246 <div className="panel-list" style={{ maxHeight: 380 }}>247 {items.length === 0 && <div className="panel-empty">Nothing matches.</div>}248 {items.map((item, index) => {249 const header =250 item.section !== lastSection ? (251 <div className="panel-section-header" key={`h-${item.section}`}>252 {item.section}253 </div>254 ) : null255 lastSection = item.section256 return (257 <div key={item.id}>258 {header}259 <button260 className={`model-row${index === highlight ? ' highlighted' : ''}`}261 onClick={() => runItem(item)}262 onMouseMove={() => setHighlight(index)}263 >264 <span className="glyph">{item.icon}</span>265 <span className="model-row-name">{item.title}</span>266 {item.subtitle && (267 <span className="model-row-provider" style={{ marginLeft: 'auto' }}>268 {item.subtitle.length > 46 ? `${item.subtitle.slice(0, 46)}…` : item.subtitle}269 </span>270 )}271 </button>272 </div>273 )274 })}275 </div>276 <div className="panel-footer">277 <span>278 <kbd>↑↓</kbd> navigate279 </span>280 <span>281 <kbd>↵</kbd> run282 </span>283 <span>284 <kbd>esc</kbd> close285 </span>286 </div>287 </div>288 </div>289 )290}291292function IconCopyIconFallback() {293 return <IconDoc size={12} />294}295