mattrguerra/cortexmainPrivate on GitHub, published here in full

ChatHeader.tsx

Language TypeScript7.3 KB216 lines

src/components/ChatHeader.tsx

  1. import { useState } from 'react'
  2. import { priceLabel, shortModel, usd } from '../lib/format'
  3. import { useCurrentModel, useStore } from '../state/store'
  4. import { ModelPicker } from './ModelPicker'
  5. export function ChatHeader() {
  6. const view = useStore((s) => s.view)
  7. const current = useCurrentModel()
  8. const presets = useStore((s) => s.presets)
  9. const applyPreset = useStore((s) => s.applyPreset)
  10. const pending = useStore((s) => s.pending)
  11. const client = useStore((s) => s.client)
  12. const refreshConversations = useStore((s) => s.refreshConversations)
  13. const open = useStore((s) => s.open)
  14. const newChat = useStore((s) => s.newChat)
  15. const toast = useStore((s) => s.toast)
  16. const [picker, setPicker] = useState(false)
  17. const [menu, setMenu] = useState<null | 'preset' | 'more'>(null)
  18. const [renaming, setRenaming] = useState(false)
  19. const [title, setTitle] = useState('')
  20. const conv = view?.conversation
  21. const total = view?.messages.reduce((s, m) => s + m.cost_nanousd, 0) ?? 0
  22. const estimated = view?.messages.some((m) => m.usage_source === 'estimated')
  23. const presetId = conv ? conv.preset_id : pending.presetId
  24. const preset = presets.find((p) => p.id === presetId)
  25. const price = current.info?.prices
  26. const act = async (f: () => Promise<unknown>, done?: string) => {
  27. setMenu(null)
  28. try {
  29. await f()
  30. if (done) toast(done)
  31. await refreshConversations()
  32. } catch (err) {
  33. toast(String(err), 'error')
  34. }
  35. }
  36. return (
  37. <header className="chat-header" data-tauri-drag-region>
  38. <div className="title-wrap" data-tauri-drag-region>
  39. {renaming && conv ? (
  40. <input
  41. className="title-input"
  42. autoFocus
  43. value={title}
  44. onChange={(e) => setTitle(e.target.value)}
  45. onBlur={() => setRenaming(false)}
  46. onKeyDown={async (e) => {
  47. if (e.key === 'Enter') {
  48. await client?.renameConversation(conv.id, title)
  49. setRenaming(false)
  50. await open(conv.id)
  51. await refreshConversations()
  52. }
  53. if (e.key === 'Escape') setRenaming(false)
  54. }}
  55. aria-label="Chat title"
  56. />
  57. ) : (
  58. <h1
  59. className="chat-title"
  60. onDoubleClick={() => {
  61. if (conv) {
  62. setTitle(conv.title)
  63. setRenaming(true)
  64. }
  65. }}
  66. title={conv ? 'Double-click to rename' : undefined}
  67. >
  68. {conv?.title ?? 'New chat'}
  69. </h1>
  70. )}
  71. {conv && total > 0 && (
  72. <span className="chat-total" data-testid="chat-total" title="This conversation so far">
  73. {usd(total)}
  74. {estimated ? ' est.' : ''}
  75. </span>
  76. )}
  77. </div>
  78. <div className="header-actions">
  79. <div className="anchor">
  80. <button
  81. className="model-button glass-tint"
  82. onClick={() => setPicker((v) => !v)}
  83. aria-haspopup="dialog"
  84. aria-expanded={picker}
  85. data-testid="model-button"
  86. >
  87. <span className="model-button-name">
  88. {current.modelId ? shortModel(current.modelId) : 'Choose a model'}
  89. </span>
  90. {price && <span className="model-button-price">{priceLabel(price)}</span>}
  91. </button>
  92. {picker && <ModelPicker onClose={() => setPicker(false)} />}
  93. </div>
  94. <div className="anchor">
  95. <button
  96. className="ghost"
  97. onClick={() => setMenu(menu === 'preset' ? null : 'preset')}
  98. aria-haspopup="menu"
  99. aria-expanded={menu === 'preset'}
  100. >
  101. {preset ? preset.name : 'Preset'}
  102. </button>
  103. {menu === 'preset' && (
  104. <div className="popover menu glass-strong" role="menu">
  105. <button
  106. role="menuitem"
  107. className={!preset ? 'checked' : ''}
  108. onClick={() => {
  109. setMenu(null)
  110. void applyPreset(null)
  111. }}
  112. >
  113. No preset
  114. </button>
  115. {presets.map((p) => (
  116. <button
  117. role="menuitem"
  118. key={p.id}
  119. className={p.id === presetId ? 'checked' : ''}
  120. title={p.system_prompt}
  121. onClick={() => {
  122. setMenu(null)
  123. void applyPreset(p)
  124. }}
  125. >
  126. {p.name}
  127. </button>
  128. ))}
  129. <hr />
  130. <button
  131. role="menuitem"
  132. onClick={() => {
  133. setMenu(null)
  134. useStore.getState().setPanel('settings')
  135. }}
  136. >
  137. Edit presets...
  138. </button>
  139. </div>
  140. )}
  141. </div>
  142. {conv && (
  143. <div className="anchor">
  144. <button
  145. className="ghost icon"
  146. onClick={() => setMenu(menu === 'more' ? null : 'more')}
  147. aria-label="More actions"
  148. aria-haspopup="menu"
  149. aria-expanded={menu === 'more'}
  150. >
  151. <svg viewBox="0 0 20 20" width="16" height="16" aria-hidden>
  152. <circle cx="4.5" cy="10" r="1.6" fill="currentColor" />
  153. <circle cx="10" cy="10" r="1.6" fill="currentColor" />
  154. <circle cx="15.5" cy="10" r="1.6" fill="currentColor" />
  155. </svg>
  156. </button>
  157. {menu === 'more' && client && (
  158. <div className="popover menu right glass-strong" role="menu">
  159. <button
  160. role="menuitem"
  161. onClick={() =>
  162. act(async () => {
  163. const r = await client.exportConversation(conv.id)
  164. toast(`Exported to ${r.path}`)
  165. })
  166. }
  167. >
  168. Export to vault
  169. </button>
  170. <button
  171. role="menuitem"
  172. onClick={() => act(() => client.setPinned(conv.id, !conv.pinned).then(() => open(conv.id)))}
  173. >
  174. {conv.pinned ? 'Unpin' : 'Pin'}
  175. </button>
  176. <button
  177. role="menuitem"
  178. onClick={() => {
  179. setTitle(conv.title)
  180. setRenaming(true)
  181. setMenu(null)
  182. }}
  183. >
  184. Rename
  185. </button>
  186. <button
  187. role="menuitem"
  188. onClick={() => act(() => client.setArchived(conv.id, true).then(newChat), 'Archived.')}
  189. >
  190. Archive
  191. </button>
  192. <hr />
  193. <button
  194. role="menuitem"
  195. className="danger"
  196. onClick={() => {
  197. if (window.confirm(`Delete "${conv.title}"? This cannot be undone.`))
  198. void act(() => client.deleteConversation(conv.id).then(newChat), 'Deleted.')
  199. else setMenu(null)
  200. }}
  201. >
  202. Delete
  203. </button>
  204. </div>
  205. )}
  206. </div>
  207. )}
  208. </div>
  209. </header>
  210. )
  211. }