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 * Sidebar.tsx3 * Zyquo Cloud Web4 *5 * Author: Simon-Pierre Boucher6 * Mail: contact@spboucher.ai7 *8 * Conversation sidebar: wordmark, search (⌘F), New Chat, grouped rows9 * (Pinned / Today / Yesterday / Previous 7 Days / Older), footer with10 * Settings + usage summary. Collapses to a drawer on phones.11 */1213import { useMemo, useRef } from 'react'14import { useStore } from '../state/store'15import { matchesSearch } from '../storage/conversations'16import type { Conversation } from '../types'17import ZyquoGlyph from './ZyquoGlyph'18import { IconGear, IconPin, IconPlus, IconSearch, IconTrash } from './icons'1920const GROUP_ORDER = ['Pinned', 'Today', 'Yesterday', 'Previous 7 Days', 'Older'] as const2122function groupFor(conversation: Conversation, now: Date): (typeof GROUP_ORDER)[number] {23 if (conversation.pinned) return 'Pinned'24 const updated = new Date(conversation.updatedAt)25 const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()26 if (updated.getTime() >= startOfToday) return 'Today'27 if (updated.getTime() >= startOfToday - 86_400_000) return 'Yesterday'28 if (updated.getTime() >= startOfToday - 7 * 86_400_000) return 'Previous 7 Days'29 return 'Older'30}3132function relativeTime(timestamp: number): string {33 const delta = Date.now() - timestamp34 const minutes = Math.floor(delta / 60_000)35 if (minutes < 1) return 'now'36 if (minutes < 60) return `${minutes}m`37 const hours = Math.floor(minutes / 60)38 if (hours < 24) return `${hours}h`39 const days = Math.floor(hours / 24)40 if (days < 7) return `${days}d`41 return new Date(timestamp).toLocaleDateString()42}4344export default function Sidebar() {45 const conversations = useStore((s) => s.conversations)46 const selectedID = useStore((s) => s.selectedID)47 const searchText = useStore((s) => s.searchText)48 const setSearchText = useStore((s) => s.setSearchText)49 const select = useStore((s) => s.select)50 const newConversation = useStore((s) => s.newConversation)51 const removeConversation = useStore((s) => s.removeConversation)52 const updateConversation = useStore((s) => s.updateConversation)53 const openSettings = useStore((s) => s.openSettings)54 const sidebarOpen = useStore((s) => s.sidebarOpen)55 const searchRef = useRef<HTMLInputElement>(null)5657 const groups = useMemo(() => {58 const now = new Date()59 const filtered = conversations.filter((c) => matchesSearch(c, searchText))60 const byGroup = new Map<string, Conversation[]>()61 for (const conversation of filtered) {62 const group = groupFor(conversation, now)63 const list = byGroup.get(group) ?? []64 list.push(conversation)65 byGroup.set(group, list)66 }67 return GROUP_ORDER.filter((g) => byGroup.has(g)).map((g) => ({68 name: g,69 items: byGroup.get(g) ?? [],70 }))71 }, [conversations, searchText])7273 const usage = useMemo(() => {74 let tokens = 075 let cost = 076 for (const conversation of conversations) {77 for (const message of conversation.messages) {78 tokens += (message.usage?.inputTokens ?? 0) + (message.usage?.outputTokens ?? 0)79 cost += message.estimatedCost ?? 080 }81 }82 return { tokens, cost }83 }, [conversations])8485 return (86 <aside className={`sidebar${sidebarOpen ? ' open' : ''}`}>87 <div className="sidebar-wordmark">88 <ZyquoGlyph size={20} />89 <span>Zyquo Cloud</span>90 </div>91 <div className="sidebar-search">92 <IconSearch />93 <input94 ref={searchRef}95 data-search-input96 placeholder="Search"97 value={searchText}98 onChange={(e) => setSearchText(e.target.value)}99 />100 </div>101 <button className="new-chat-button" onClick={() => newConversation()}>102 <IconPlus size={12} /> New Chat103 </button>104 <div className="conversation-list">105 {groups.map((group) => (106 <div key={group.name}>107 <div className="conversation-group-header">{group.name}</div>108 {group.items.map((conversation) => (109 <div110 key={conversation.id}111 role="button"112 tabIndex={0}113 className={`conversation-row${conversation.id === selectedID ? ' selected' : ''}`}114 onClick={() => select(conversation.id)}115 onKeyDown={(e) => e.key === 'Enter' && select(conversation.id)}116 >117 <div className="conversation-row-main">118 <div className="conversation-row-title">{conversation.title}</div>119 <div className="conversation-row-sub">120 {conversation.modelID} · {relativeTime(conversation.updatedAt)}121 </div>122 </div>123 {conversation.pinned && (124 <span className="pin-indicator">125 <IconPin size={9} />126 </span>127 )}128 <span className="conversation-row-actions">129 <button130 className="icon-button"131 title={conversation.pinned ? 'Unpin' : 'Pin'}132 onClick={(e) => {133 e.stopPropagation()134 updateConversation(conversation.id, { pinned: !conversation.pinned })135 }}136 >137 <IconPin size={10} />138 </button>139 <button140 className="icon-button danger"141 title="Delete"142 onClick={(e) => {143 e.stopPropagation()144 removeConversation(conversation.id)145 }}146 >147 <IconTrash size={10} />148 </button>149 </span>150 </div>151 ))}152 </div>153 ))}154 {groups.length === 0 && (155 <div className="panel-empty">156 {searchText ? 'No conversations match.' : 'No conversations yet.'}157 </div>158 )}159 </div>160 <div className="sidebar-footer">161 <button className="icon-button" title="Settings (⌘,)" onClick={() => openSettings()}>162 <IconGear />163 </button>164 {usage.tokens > 0 && (165 <span>166 {usage.tokens >= 1_000_000167 ? `${(usage.tokens / 1_000_000).toFixed(1)}M tok`168 : `${(usage.tokens / 1_000).toFixed(1)}K tok`}169 {usage.cost >= 0.01170 ? ` · ~$${usage.cost.toFixed(2)}`171 : usage.cost > 0172 ? ' · <$0.01'173 : ''}174 </span>175 )}176 </div>177 </aside>178 )179}180