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 * SlashMenu.tsx3 * Zyquo Cloud Web4 *5 * Author: Simon-Pierre Boucher6 * Mail: contact@spboucher.ai7 *8 * Slash-command menu: typing "/" at the start of the input surfaces prompt9 * templates and quick tools (/summarize, /translate, /rewrite, /explain) and10 * personas. Selecting a template substitutes {{input}} with the rest of the11 * draft (native PromptLibraryStore.apply behavior).12 */1314import { useEffect, useMemo, useRef, useState } from 'react'15import { PERSONAS } from '../features/personasData'16import { PROMPT_TEMPLATES } from '../features/promptLibraryData'17import { summarizeConversation } from '../features/conversationTools'18import { useStore } from '../state/store'19import { IconCommand, IconDoc, IconLightbulb } from './icons'2021interface SlashItem {22 id: string23 section: string24 title: string25 subtitle: string26 apply: () => void27}2829/** Applies a template body: {{input}} ← the user's remaining draft text. */30export function applyTemplate(body: string, input: string): string {31 if (body.includes('{{input}}')) return body.replace('{{input}}', input)32 return input === '' ? body : `${body}\n\n${input}`33}3435const QUICK_TOOLS = [36 { key: 'summarize', title: '/summarize', body: 'Summarize the following clearly and concisely, preserving the key facts:\n\n{{input}}' },37 { key: 'translate', title: '/translate', body: 'Translate the following text to English (or to French if it is already English), keeping the tone natural:\n\n{{input}}' },38 { key: 'rewrite', title: '/rewrite', body: 'Rewrite the following for clarity and flow, preserving meaning and voice:\n\n{{input}}' },39 { key: 'explain', title: '/explain', body: 'Explain the following simply, step by step, for a smart non-expert:\n\n{{input}}' },40]4142export default function SlashMenu({43 query,44 input,45 conversationID,46 onApply,47 onClose,48}: {49 /** Text after the leading slash, up to the first space. */50 query: string51 /** The draft text after the slash command (used as {{input}}). */52 input: string53 conversationID: string54 onApply: (text: string) => void55 onClose: () => void56}) {57 const [highlight, setHighlight] = useState(0)58 const ref = useRef<HTMLDivElement>(null)5960 const items: SlashItem[] = useMemo(() => {61 const q = query.toLowerCase()62 const match = (...targets: string[]) =>63 q === '' || targets.some((t) => t.toLowerCase().includes(q))64 const out: SlashItem[] = []65 for (const tool of QUICK_TOOLS) {66 if (!match(tool.title, tool.key)) continue67 if (tool.key === 'summarize' && input.trim() === '') {68 out.push({69 id: `tool-${tool.key}`,70 section: 'Tools',71 title: tool.title,72 subtitle: 'Summarize this conversation',73 apply: () => {74 onApply('')75 void summarizeConversation(conversationID)76 },77 })78 } else {79 out.push({80 id: `tool-${tool.key}`,81 section: 'Tools',82 title: tool.title,83 subtitle: 'Quick tool',84 apply: () => onApply(applyTemplate(tool.body, input.trim())),85 })86 }87 }88 for (const template of PROMPT_TEMPLATES) {89 if (!match(template.title, template.category)) continue90 out.push({91 id: `tpl-${template.id}`,92 section: template.category,93 title: template.title,94 subtitle: template.category,95 apply: () => onApply(applyTemplate(template.body, input.trim())),96 })97 }98 for (const persona of PERSONAS) {99 if (!match(persona.name)) continue100 out.push({101 id: `persona-${persona.id}`,102 section: 'Personas',103 title: persona.name,104 subtitle: 'Apply persona to this chat',105 apply: () => {106 const state = useStore.getState()107 state.updateConversation(conversationID, {108 systemPrompt: persona.systemPrompt,109 personaID: persona.id,110 })111 state.toast(`Persona: ${persona.name}`, 'success')112 onApply(input)113 },114 })115 }116 return out.slice(0, 12)117 }, [query, input, conversationID, onApply])118119 useEffect(() => setHighlight(0), [query])120121 // Expose keyboard handling to the parent textarea via a custom event target.122 useEffect(() => {123 const onKey = (e: KeyboardEvent) => {124 if (e.key === 'ArrowDown') {125 e.preventDefault()126 setHighlight((h) => Math.min(h + 1, items.length - 1))127 } else if (e.key === 'ArrowUp') {128 e.preventDefault()129 setHighlight((h) => Math.max(h - 1, 0))130 } else if (e.key === 'Enter' || e.key === 'Tab') {131 e.preventDefault()132 items[highlight]?.apply()133 } else if (e.key === 'Escape') {134 e.preventDefault()135 onClose()136 }137 }138 window.addEventListener('keydown', onKey, { capture: true })139 return () => window.removeEventListener('keydown', onKey, { capture: true })140 }, [items, highlight, onClose])141142 if (items.length === 0) return null143144 let lastSection = ''145 return (146 <div147 ref={ref}148 style={{149 position: 'absolute',150 bottom: '100%',151 left: 0,152 right: 0,153 marginBottom: 8,154 background: 'var(--z-surface)',155 border: 'var(--z-hairline) solid var(--z-border)',156 borderRadius: 'var(--z-radius-medium)',157 boxShadow: 'var(--z-shadow-panel)',158 maxHeight: 300,159 overflowY: 'auto',160 padding: 'var(--z-space-xxs)',161 zIndex: 20,162 }}163 >164 {items.map((item, index) => {165 const header =166 item.section !== lastSection ? (167 <div className="panel-section-header" key={`h-${item.section}-${index}`}>168 {item.section}169 </div>170 ) : null171 lastSection = item.section172 return (173 <div key={item.id}>174 {header}175 <button176 className={`model-row${index === highlight ? ' highlighted' : ''}`}177 onClick={() => item.apply()}178 onMouseMove={() => setHighlight(index)}179 >180 <span className="glyph">181 {item.section === 'Tools' ? (182 <IconCommand size={11} />183 ) : item.section === 'Personas' ? (184 <IconLightbulb size={11} />185 ) : (186 <IconDoc size={11} />187 )}188 </span>189 <span className="model-row-name">{item.title}</span>190 <span className="model-row-provider" style={{ marginLeft: 'auto' }}>191 {item.subtitle}192 </span>193 </button>194 </div>195 )196 })}197 </div>198 )199}200