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 * ChatView.tsx3 * Zyquo Cloud Web4 *5 * Author: Simon-Pierre Boucher6 * Mail: contact@spboucher.ai7 *8 * The chat area: header (editable title, centered model chip, actions),9 * transcript with scroll-lock + "jump to latest" pill, input bar, and the10 * parameters popover. Suggestions grid when the conversation is empty.11 */1213import { useEffect, useMemo, useRef, useState } from 'react'14import { findModel } from '../features/catalogHelpers'15import { useStore } from '../state/store'16import type { AIModel, Conversation } from '../types'17import InputBar from './InputBar'18import { MessageBubble } from './MessageBubble'19import ModelMenu from './ModelMenu'20import ParamsPanel from './ParamsPanel'21import SuggestionGrid from './SuggestionGrid'22import {23 IconArrowDown,24 IconChevronDown,25 IconColumns,26 IconDownload,27 IconInfo,28 IconMenu,29 ProviderGlyph,30} from './icons'31import { exportConversationMarkdown } from '../features/exporters'3233export default function ChatView({ conversation }: { conversation: Conversation }) {34 const updateConversation = useStore((s) => s.updateConversation)35 const streaming = useStore((s) => s.streaming[conversation.id])36 const setSidebarOpen = useStore((s) => s.setSidebarOpen)37 const setCompareOpen = useStore((s) => s.setCompareOpen)38 const updateSettings = useStore((s) => s.updateSettings)39 const modelMenuOpen = useStore((s) => s.modelMenuOpen)40 const setModelMenuOpen = useStore((s) => s.setModelMenuOpen)41 const density = useStore((s) => s.settings.density)4243 const [editingTitle, setEditingTitle] = useState(false)44 const [titleDraft, setTitleDraft] = useState(conversation.title)45 const [paramsOpen, setParamsOpen] = useState(false)46 const [infoOpen, setInfoOpen] = useState(false)47 const draftSeed = useStore((s) => s.draftSeed)48 const setDraftSeed = useStore((s) => s.setDraftSeed)49 const [draft, setDraft] = useState('')50 const [pinnedToBottom, setPinnedToBottom] = useState(true)5152 // Pick up a prompt seeded from the root empty state's suggestion cards.53 useEffect(() => {54 if (draftSeed !== null) {55 setDraft(draftSeed)56 setDraftSeed(null)57 document.querySelector<HTMLTextAreaElement>('[data-input-editor]')?.focus()58 }59 }, [draftSeed, setDraftSeed])60 const scrollRef = useRef<HTMLDivElement>(null)6162 const model = findModel(conversation.provider, conversation.modelID)6364 // Auto-scroll while pinned to bottom.65 const lastMessage = conversation.messages[conversation.messages.length - 1]66 const scrollFingerprint = `${conversation.messages.length}:${lastMessage?.text.length ?? 0}:${67 lastMessage?.reasoning?.length ?? 068 }`69 useEffect(() => {70 if (pinnedToBottom && scrollRef.current) {71 scrollRef.current.scrollTop = scrollRef.current.scrollHeight72 }73 }, [scrollFingerprint, pinnedToBottom])7475 const onScroll = () => {76 const el = scrollRef.current77 if (!el) return78 setPinnedToBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 60)79 }8081 const totals = useMemo(() => {82 let input = 083 let output = 084 let cost = 085 for (const message of conversation.messages) {86 input += message.usage?.inputTokens ?? 087 output += message.usage?.outputTokens ?? 088 cost += message.estimatedCost ?? 089 }90 return { input, output, cost }91 }, [conversation])9293 const pickModel = (picked: AIModel, scope: 'conversation' | 'default' | 'message') => {94 if (scope === 'default') {95 updateSettings({ defaultModelID: picked.id, defaultProvider: picked.provider })96 }97 updateConversation(conversation.id, { modelID: picked.id, provider: picked.provider })98 }99100 const quote = (text: string) => {101 const quoted = text102 .split('\n')103 .map((line) => `> ${line}`)104 .join('\n')105 setDraft((d) => `${quoted}\n\n${d}`)106 }107108 return (109 <div className={`chat-area density-${density}`}>110 <header className="chat-header">111 <div className="chat-header-left">112 <button113 className="icon-button menu-button"114 title="Conversations"115 onClick={() => setSidebarOpen(true)}116 >117 <IconMenu />118 </button>119 {editingTitle ? (120 <input121 className="chat-title-input"122 value={titleDraft}123 autoFocus124 onChange={(e) => setTitleDraft(e.target.value)}125 onBlur={() => {126 setEditingTitle(false)127 if (titleDraft.trim() !== '') {128 updateConversation(conversation.id, {129 title: titleDraft.trim(),130 hasAutoTitle: false,131 })132 }133 }}134 onKeyDown={(e) => {135 if (e.key === 'Enter') (e.target as HTMLInputElement).blur()136 if (e.key === 'Escape') setEditingTitle(false)137 }}138 />139 ) : (140 <span141 className="chat-title"142 title="Double-click to rename"143 onDoubleClick={() => {144 setTitleDraft(conversation.title)145 setEditingTitle(true)146 }}147 >148 {conversation.title}149 </span>150 )}151 </div>152 <button className="model-chip" onClick={() => setModelMenuOpen(true)} data-model-chip>153 <span className="glyph">154 <ProviderGlyph provider={conversation.provider} />155 </span>156 {model?.displayName ?? conversation.modelID}157 <span className="chevron">158 <IconChevronDown />159 </span>160 </button>161 <div className="chat-header-right">162 <button className="icon-button" title="Compare models" onClick={() => setCompareOpen(true)}>163 <IconColumns />164 </button>165 <button166 className="icon-button"167 title="Export conversation (⌘⇧E)"168 onClick={() => exportConversationMarkdown(conversation)}169 >170 <IconDownload />171 </button>172 <button className="icon-button" title="Conversation info" onClick={() => setInfoOpen(true)}>173 <IconInfo />174 </button>175 </div>176 </header>177178 {conversation.messages.length === 0 ? (179 <SuggestionGrid180 onPick={(prompt) => setDraft(prompt)}181 modelChip={182 <button className="model-chip" onClick={() => setModelMenuOpen(true)}>183 <span className="glyph">184 <ProviderGlyph provider={conversation.provider} />185 </span>186 {model?.displayName ?? conversation.modelID}187 <span className="chevron">188 <IconChevronDown />189 </span>190 </button>191 }192 />193 ) : (194 <div className="transcript" ref={scrollRef} onScroll={onScroll}>195 <div className="transcript-column">196 {conversation.messages.map((message, index) => (197 <MessageBubble198 key={message.id}199 conversationID={conversation.id}200 message={message}201 streaming={streaming?.messageID === message.id}202 isLast={index === conversation.messages.length - 1 && !streaming}203 onQuote={quote}204 />205 ))}206 </div>207 {!pinnedToBottom && streaming && (208 <button209 className="jump-pill"210 onClick={() => {211 setPinnedToBottom(true)212 scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })213 }}214 >215 <IconArrowDown /> Jump to latest216 </button>217 )}218 </div>219 )}220221 <InputBar222 conversation={conversation}223 draft={draft}224 setDraft={setDraft}225 onOpenParams={() => setParamsOpen(true)}226 />227228 {modelMenuOpen && (229 <ModelMenu230 currentID={conversation.modelID}231 onPick={pickModel}232 onClose={() => setModelMenuOpen(false)}233 />234 )}235 {paramsOpen && (236 <ParamsPanel conversation={conversation} onClose={() => setParamsOpen(false)} />237 )}238 {infoOpen && (239 <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && setInfoOpen(false)}>240 <div className="panel" style={{ width: 400 }}>241 <div className="settings-content">242 <h3>Conversation</h3>243 <div className="settings-row">244 <span>Tokens</span>245 <span style={{ marginLeft: 'auto', color: 'var(--z-text-secondary)' }}>246 {totals.input.toLocaleString()} in · {totals.output.toLocaleString()} out247 </span>248 </div>249 <div className="settings-row">250 <span>Estimated cost</span>251 <span style={{ marginLeft: 'auto', color: 'var(--z-text-secondary)' }}>252 ~${totals.cost.toFixed(4)}253 </span>254 </div>255 <div className="settings-section" style={{ marginTop: 12 }}>256 <h3>System prompt</h3>257 <textarea258 className="text-input"259 rows={5}260 placeholder="No system prompt"261 value={conversation.systemPrompt ?? ''}262 onChange={(e) =>263 updateConversation(conversation.id, { systemPrompt: e.target.value })264 }265 />266 </div>267 <div style={{ display: 'flex', justifyContent: 'flex-end' }}>268 <button className="button" onClick={() => setInfoOpen(false)}>269 Done270 </button>271 </div>272 </div>273 </div>274 </div>275 )}276 </div>277 )278}279