SPB Git

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%
8.9 KB · 295 lines tsx
Raw Blame History
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