mattrguerra/cortexmainPrivate on GitHub, published here in full

Sidebar.tsx

Language TypeScript4.0 KB113 lines

src/components/Sidebar.tsx

  1. import type { ConversationSummary } from '../ipc/gen/ConversationSummary'
  2. import { NANO, relativeDay, usd } from '../lib/format'
  3. import { mod } from '../lib/theme'
  4. import { useStore } from '../state/store'
  5. function groups(list: ConversationSummary[]) {
  6. const out: [string, ConversationSummary[]][] = []
  7. const pinned = list.filter((c) => c.pinned)
  8. if (pinned.length) out.push(['Pinned', pinned])
  9. for (const c of list.filter((c) => !c.pinned)) {
  10. const label = relativeDay(c.updated_at)
  11. const g = out.find(([l]) => l === label)
  12. if (g) g[1].push(c)
  13. else out.push([label, [c]])
  14. }
  15. return out
  16. }
  17. export function SpendMeter() {
  18. const spend = useStore((s) => s.spend)
  19. const setPanel = useStore((s) => s.setPanel)
  20. if (!spend) return null
  21. const budget = spend.budget_usd
  22. const pct = budget ? Math.min(100, (spend.month_nanousd / NANO / budget) * 100) : 0
  23. return (
  24. <button className={`spend level-${spend.budget_level}`} onClick={() => setPanel('usage')} data-testid="spend-meter">
  25. <span className="spend-row">
  26. <span>This month</span>
  27. <strong>
  28. {usd(spend.month_nanousd)}
  29. {spend.month_estimated ? '*' : ''}
  30. </strong>
  31. </span>
  32. {budget != null && budget > 0 && (
  33. <>
  34. <span
  35. className="bar"
  36. role="meter"
  37. aria-valuemin={0}
  38. aria-valuemax={100}
  39. aria-valuenow={Math.round(pct)}
  40. aria-label="Share of monthly budget"
  41. >
  42. <span style={{ width: `${pct}%` }} />
  43. </span>
  44. <span className="spend-sub">of ${budget.toFixed(2)} budget</span>
  45. </>
  46. )}
  47. </button>
  48. )
  49. }
  50. export function Sidebar() {
  51. const conversations = useStore((s) => s.conversations)
  52. const activeId = useStore((s) => s.view?.conversation.id)
  53. const open = useStore((s) => s.open)
  54. const newChat = useStore((s) => s.newChat)
  55. const setPanel = useStore((s) => s.setPanel)
  56. const info = useStore((s) => s.info)
  57. return (
  58. <aside className="sidebar glass" aria-label="Conversations">
  59. <div className={`sidebar-top ${info?.platform === 'macos' ? 'traffic' : ''}`} data-tauri-drag-region>
  60. <button className="primary new-chat" onClick={newChat} title={`New chat (${mod}N)`}>
  61. New chat
  62. </button>
  63. <button className="search-trigger" onClick={() => setPanel('search')} title={`Search (${mod}K)`}>
  64. <svg viewBox="0 0 20 20" width="14" height="14" aria-hidden>
  65. <circle cx="8.5" cy="8.5" r="5.5" fill="none" stroke="currentColor" strokeWidth="1.7" />
  66. <path d="M13 13l4 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
  67. </svg>
  68. <span>Search</span>
  69. <kbd>{mod}K</kbd>
  70. </button>
  71. </div>
  72. <nav className="conv-list">
  73. {groups(conversations).map(([label, list]) => (
  74. <section key={label}>
  75. <h2>{label}</h2>
  76. <ul>
  77. {list.map((c) => (
  78. <li key={c.id}>
  79. <button
  80. className={`conv ${c.id === activeId ? 'active' : ''}`}
  81. onClick={() => void open(c.id)}
  82. aria-current={c.id === activeId}
  83. >
  84. <span className="conv-title">{c.title}</span>
  85. <span className="conv-cost">
  86. {c.cost_nanousd > 0 ? usd(c.cost_nanousd) : c.has_usage ? 'free' : ''}
  87. {c.cost_estimated ? '*' : ''}
  88. </span>
  89. </button>
  90. </li>
  91. ))}
  92. </ul>
  93. </section>
  94. ))}
  95. {conversations.length === 0 && <p className="muted small pad">No chats yet.</p>}
  96. </nav>
  97. <div className="sidebar-foot">
  98. <SpendMeter />
  99. <div className="row">
  100. <button className="ghost small" onClick={() => setPanel('usage')}>
  101. Usage
  102. </button>
  103. <button className="ghost small" onClick={() => setPanel('settings')}>
  104. Settings
  105. </button>
  106. </div>
  107. </div>
  108. </aside>
  109. )
  110. }