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