@polderlabs/bizar 4.4.13 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
  3. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  4. package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
  5. package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
  6. package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
  7. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
  8. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
  9. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
  10. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
  11. package/bizar-dash/dist/index.html +3 -3
  12. package/bizar-dash/dist/mobile.html +2 -2
  13. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  14. package/bizar-dash/skills/bizar/SKILL.md +116 -0
  15. package/bizar-dash/skills/chat/SKILL.md +74 -0
  16. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  17. package/bizar-dash/skills/lightrag/SKILL.md +86 -0
  18. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  19. package/bizar-dash/skills/obsidian/SKILL.md +68 -0
  20. package/bizar-dash/skills/providers/SKILL.md +75 -0
  21. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  22. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  23. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  24. package/bizar-dash/skills/usage/SKILL.md +62 -0
  25. package/bizar-dash/src/server/api.mjs +18 -0
  26. package/bizar-dash/src/server/headroom.mjs +645 -0
  27. package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
  28. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  29. package/bizar-dash/src/server/memory-store.mjs +189 -0
  30. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  31. package/bizar-dash/src/server/minimax.mjs +196 -5
  32. package/bizar-dash/src/server/providers-store.mjs +956 -0
  33. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  34. package/bizar-dash/src/server/routes/config.mjs +52 -1
  35. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  36. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  37. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +668 -1
  39. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  40. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  41. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  42. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  43. package/bizar-dash/src/server/routes/update.mjs +340 -0
  44. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  45. package/bizar-dash/src/server/serve-info.mjs +135 -4
  46. package/bizar-dash/src/server/server.mjs +20 -0
  47. package/bizar-dash/src/server/skills-store.mjs +152 -262
  48. package/bizar-dash/src/web/App.tsx +120 -29
  49. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  50. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  51. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  52. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  53. package/bizar-dash/src/web/components/Topbar.tsx +2 -1
  54. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  55. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  56. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  57. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  58. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  59. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  60. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  61. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  62. package/bizar-dash/src/web/lib/api.ts +43 -0
  63. package/bizar-dash/src/web/lib/types.ts +16 -0
  64. package/bizar-dash/src/web/main.tsx +2 -0
  65. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  66. package/bizar-dash/src/web/styles/chat.css +135 -1
  67. package/bizar-dash/src/web/styles/main.css +46 -0
  68. package/bizar-dash/src/web/styles/memory.css +955 -0
  69. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  70. package/bizar-dash/src/web/styles/settings.css +418 -0
  71. package/bizar-dash/src/web/styles/skills.css +302 -0
  72. package/bizar-dash/src/web/styles/tasks.css +288 -0
  73. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  74. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  75. package/bizar-dash/src/web/views/Memory.tsx +140 -0
  76. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  77. package/bizar-dash/src/web/views/Overview.tsx +3 -0
  78. package/bizar-dash/src/web/views/Settings.tsx +36 -0
  79. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  80. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  82. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  83. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  84. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
  85. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  86. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  87. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  88. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  89. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  90. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  91. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  92. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  93. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  94. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  95. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  96. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  97. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  98. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  99. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  100. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  101. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  102. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  103. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  104. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  105. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  106. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  107. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  108. package/bizar-dash/tests/update-check.test.mjs +127 -0
  109. package/bizar-dash/tests/update-run.test.mjs +266 -0
  110. package/cli/bin.mjs +247 -1
  111. package/cli/provision.mjs +118 -4
  112. package/config/agents/_shared/SKILLS.md +109 -0
  113. package/package.json +1 -1
  114. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  115. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  116. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  117. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  118. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -0,0 +1,213 @@
1
+ // src/web/components/SettingsSearch.tsx — fuzzy search across Settings sections with scroll-to highlight
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { Search, X } from 'lucide-react';
4
+ import Fuse from 'fuse.js';
5
+ import { cn } from '../lib/utils';
6
+
7
+ export type SettingsSection = {
8
+ id: string;
9
+ label: string;
10
+ icon?: string;
11
+ fields: SettingsField[];
12
+ };
13
+
14
+ export type SettingsField = {
15
+ key: string; // e.g. "theme.accent"
16
+ label: string; // e.g. "Accent color"
17
+ value?: string; // current value (masked if secret)
18
+ section: string; // parent section id
19
+ };
20
+
21
+ type Props = {
22
+ sections: SettingsSection[];
23
+ onJump: (sectionId: string, fieldKey?: string) => void;
24
+ };
25
+
26
+ type Match = {
27
+ item: SettingsField | SettingsSection;
28
+ score: number;
29
+ type: 'section' | 'field';
30
+ sectionId: string;
31
+ fieldKey?: string;
32
+ };
33
+
34
+ function fuseSearch(items: SettingsSection[], query: string): Match[] {
35
+ if (!query.trim()) return [];
36
+
37
+ const sectionFuse = new Fuse(items, {
38
+ keys: ['label'],
39
+ threshold: 0.4,
40
+ includeScore: true,
41
+ });
42
+
43
+ const fieldFuse = new Fuse(
44
+ items.flatMap((s) => s.fields.map((f) => ({ ...f, _sectionId: s.id }))),
45
+ { keys: ['label', 'key'], threshold: 0.4, includeScore: true },
46
+ );
47
+
48
+ const results: Match[] = [];
49
+
50
+ for (const r of sectionFuse.search(query)) {
51
+ results.push({
52
+ item: r.item,
53
+ score: r.score ?? 1,
54
+ type: 'section',
55
+ sectionId: r.item.id,
56
+ });
57
+ }
58
+
59
+ for (const r of fieldFuse.search(query)) {
60
+ results.push({
61
+ item: r.item as SettingsField,
62
+ score: r.score ?? 1,
63
+ type: 'field',
64
+ sectionId: (r.item as SettingsField & { _sectionId: string })._sectionId,
65
+ fieldKey: r.item.key,
66
+ });
67
+ }
68
+
69
+ // Deduplicate by sectionId+fieldKey, prefer lower score
70
+ const seen = new Set<string>();
71
+ return results
72
+ .filter((r) => {
73
+ const k = `${r.sectionId}::${r.fieldKey ?? ''}`;
74
+ if (seen.has(k)) return false;
75
+ seen.add(k);
76
+ return true;
77
+ })
78
+ .sort((a, b) => a.score - b.score)
79
+ .slice(0, 8);
80
+ }
81
+
82
+ export function SettingsSearch({ sections, onJump }: Props) {
83
+ const [q, setQ] = useState('');
84
+ const [matches, setMatches] = useState<Match[]>([]);
85
+ const [activeIdx, setActiveIdx] = useState(0);
86
+ const [highlighted, setHighlighted] = useState<string | null>(null);
87
+ const inputRef = useRef<HTMLInputElement>(null);
88
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
89
+ const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
90
+
91
+ const search = useCallback(
92
+ (query: string) => {
93
+ if (debounceRef.current) clearTimeout(debounceRef.current);
94
+ if (!query.trim()) {
95
+ setMatches([]);
96
+ setActiveIdx(0);
97
+ return;
98
+ }
99
+ debounceRef.current = setTimeout(() => {
100
+ setMatches(fuseSearch(sections, query));
101
+ setActiveIdx(0);
102
+ }, 200);
103
+ },
104
+ [sections],
105
+ );
106
+
107
+ useEffect(() => {
108
+ search(q);
109
+ }, [q, search]);
110
+
111
+ // Cleanup timers
112
+ useEffect(() => {
113
+ return () => {
114
+ if (debounceRef.current) clearTimeout(debounceRef.current);
115
+ if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
116
+ };
117
+ }, []);
118
+
119
+ const highlightSection = (sectionId: string) => {
120
+ setHighlighted(sectionId);
121
+ if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
122
+ highlightTimerRef.current = setTimeout(() => setHighlighted(null), 1500);
123
+ const el = document.querySelector(`[data-section="${sectionId}"]`);
124
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
125
+ };
126
+
127
+ const handleKey = (e: React.KeyboardEvent) => {
128
+ if (e.key === 'Escape') {
129
+ setQ('');
130
+ inputRef.current?.blur();
131
+ } else if (e.key === 'ArrowDown') {
132
+ e.preventDefault();
133
+ setActiveIdx((i) => Math.min(i + 1, matches.length - 1));
134
+ } else if (e.key === 'ArrowUp') {
135
+ e.preventDefault();
136
+ setActiveIdx((i) => Math.max(i - 1, 0));
137
+ } else if (e.key === 'Enter' && matches[activeIdx]) {
138
+ const m = matches[activeIdx];
139
+ highlightSection(m.sectionId);
140
+ onJump(m.sectionId, m.fieldKey);
141
+ setQ('');
142
+ }
143
+ };
144
+
145
+ const handleSelect = (m: Match) => {
146
+ highlightSection(m.sectionId);
147
+ onJump(m.sectionId, m.fieldKey);
148
+ setQ('');
149
+ };
150
+
151
+ return (
152
+ <div className="settings-search">
153
+ <div className="settings-search-input-wrap">
154
+ <Search size={13} className="settings-search-icon" />
155
+ <input
156
+ ref={inputRef}
157
+ className="settings-search-input"
158
+ placeholder="Search settings…"
159
+ value={q}
160
+ onChange={(e) => setQ(e.target.value)}
161
+ onKeyDown={handleKey}
162
+ aria-label="Search settings"
163
+ />
164
+ {q && (
165
+ <button
166
+ type="button"
167
+ className="icon-btn settings-search-clear"
168
+ onClick={() => setQ('')}
169
+ aria-label="Clear search"
170
+ >
171
+ <X size={12} />
172
+ </button>
173
+ )}
174
+ </div>
175
+
176
+ {q && matches.length > 0 && (
177
+ <div className="settings-search-results">
178
+ {matches.map((m, idx) => {
179
+ const label = m.type === 'section'
180
+ ? (m.item as SettingsSection).label
181
+ : (m.item as SettingsField).label;
182
+ const key = m.type === 'section'
183
+ ? (m.item as SettingsSection).id
184
+ : (m.item as SettingsField).key;
185
+ return (
186
+ <button
187
+ key={`${m.sectionId}::${key}`}
188
+ type="button"
189
+ className={cn('settings-search-result', idx === activeIdx && 'settings-search-result-active')}
190
+ onClick={() => handleSelect(m)}
191
+ onMouseEnter={() => setActiveIdx(idx)}
192
+ >
193
+ <span className={cn('settings-search-result-type', m.type === 'section' && 'settings-search-result-type-section')}>
194
+ {m.type === 'section' ? 'section' : 'field'}
195
+ </span>
196
+ <span className="settings-search-result-label">
197
+ {label}
198
+ </span>
199
+ {m.type === 'field' && (
200
+ <span className="settings-search-result-key mono muted">{key}</span>
201
+ )}
202
+ </button>
203
+ );
204
+ })}
205
+ </div>
206
+ )}
207
+
208
+ {q && matches.length === 0 && (
209
+ <div className="settings-search-empty muted">No matching settings.</div>
210
+ )}
211
+ </div>
212
+ );
213
+ }
@@ -22,6 +22,7 @@ import {
22
22
  Activity,
23
23
  Radio,
24
24
  Coins,
25
+ Brain,
25
26
  type LucideIcon,
26
27
  } from 'lucide-react';
27
28
  import { cn } from '../lib/utils';
@@ -46,11 +47,11 @@ export const TABS: TabDef[] = [
46
47
  { id: 'activity', label: 'Activity', icon: Activity },
47
48
  { id: 'background', label: 'Active', icon: Radio },
48
49
  { id: 'skills', label: 'Skills', icon: Sparkles },
50
+ { id: 'memory', label: 'Memory', icon: Brain },
49
51
  { id: 'mods', label: 'Mods', icon: Puzzle },
50
52
  { id: 'schedules', label: 'Schedules', icon: Clock },
51
53
  { id: 'history', label: 'History', icon: HistoryIcon },
52
54
  { id: 'minimax', label: 'Usage', icon: Coins },
53
- { id: 'config', label: 'Config', icon: Settings2 },
54
55
  { id: 'settings', label: 'Settings', icon: Sliders },
55
56
  ];
56
57
 
@@ -0,0 +1,250 @@
1
+ // src/web/components/UsageChart.tsx
2
+ //
3
+ // Hand-rolled SVG chart. No external chart library — keeps bundle small.
4
+ //
5
+ // Props:
6
+ // series: { labels: string[]; requests: number[]; tokens: number[] }
7
+ // height?: number (default 280)
8
+ // onHover?: (index: number | null, x: number, y: number) => void
9
+
10
+ import { useCallback, useRef, useState } from 'react';
11
+ import { cn } from '../lib/utils';
12
+
13
+ type Props = {
14
+ series: { labels: string[]; requests: number[]; tokens: number[] };
15
+ height?: number;
16
+ onHover?: (index: number | null, x: number, y: number) => void;
17
+ className?: string;
18
+ };
19
+
20
+ const PADDING = { top: 16, right: 20, bottom: 40, left: 52 };
21
+
22
+ export function UsageChart({ series, height = 280, onHover, className }: Props) {
23
+ const containerRef = useRef<HTMLDivElement>(null);
24
+ const [tooltip, setTooltip] = useState<{ index: number; x: number; y: number } | null>(null);
25
+
26
+ const { labels, requests, tokens } = series;
27
+ const n = labels.length;
28
+ if (n === 0) {
29
+ return (
30
+ <div className={cn('usage-chart-empty', className)} style={{ height }}>
31
+ <span>No data for this range</span>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ const innerW = 600 - PADDING.left - PADDING.right;
37
+ const innerH = height - PADDING.top - PADDING.bottom;
38
+
39
+ const maxReq = Math.max(...requests, 1);
40
+ const maxTok = Math.max(...tokens, 1);
41
+ // Two separate Y scales (left = requests, right = tokens)
42
+ const barW = Math.max(4, Math.min(24, Math.floor(innerW / n) - 4));
43
+ const gap = (innerW - barW * n) / (n + 1);
44
+
45
+ function barX(i: number) {
46
+ return PADDING.left + gap + i * (barW + gap);
47
+ }
48
+
49
+ function barH(v: number) {
50
+ return (v / maxReq) * innerH * 0.75;
51
+ }
52
+
53
+ function lineY(v: number) {
54
+ return PADDING.top + innerH - (v / maxTok) * innerH * 0.75;
55
+ }
56
+
57
+ // Build polyline points for the token line.
58
+ const linePoints = tokens
59
+ .map((v, i) => `${barX(i) + barW / 2},${lineY(v)}`)
60
+ .join(' ');
61
+
62
+ // Y-axis ticks (left, requests).
63
+ const leftTicks = [0, 0.25, 0.5, 0.75, 1].map(t => ({
64
+ y: PADDING.top + innerH - t * innerH * 0.75,
65
+ label: t === 0 ? '0' : `${Math.round(maxReq * t).toLocaleString()}`,
66
+ }));
67
+
68
+ // Y-axis ticks (right, tokens).
69
+ const rightTicks = [0, 0.25, 0.5, 0.75, 1].map(t => ({
70
+ y: PADDING.top + innerH - t * innerH * 0.75,
71
+ label: t === 0 ? '0' : `${Math.round(maxTok * t / 1000)}k`,
72
+ }));
73
+
74
+ // X-axis labels (show at most 7 to avoid crowding).
75
+ const labelStep = Math.max(1, Math.floor(n / 7));
76
+ const xLabels = labels.map((l, i) => ({ i, l, x: barX(i) + barW / 2 })).filter((_, i) => i % labelStep === 0);
77
+
78
+ const handleMouseMove = useCallback(
79
+ (e: React.MouseEvent<SVGSVGElement>) => {
80
+ if (!containerRef.current) return;
81
+ const rect = containerRef.current.getBoundingClientRect();
82
+ const svgX = (e.clientX - rect.left) * (600 / rect.width);
83
+ const svgY = (e.clientY - rect.top) * (height / rect.height);
84
+ // Find closest bar.
85
+ const relX = svgX - PADDING.left;
86
+ const i = Math.round((relX - gap / 2) / (barW + gap));
87
+ const clamped = Math.max(0, Math.min(n - 1, i));
88
+ setTooltip({ index: clamped, x: e.clientX - rect.left, y: e.clientY - rect.top });
89
+ onHover?.(clamped, svgX, svgY);
90
+ },
91
+ [n, barW, gap, onHover, height],
92
+ );
93
+
94
+ const handleMouseLeave = useCallback(() => {
95
+ setTooltip(null);
96
+ onHover?.(null, 0, 0);
97
+ }, [onHover]);
98
+
99
+ const tip = tooltip;
100
+
101
+ return (
102
+ <div ref={containerRef} className={cn('usage-chart', className)} style={{ position: 'relative' }}>
103
+ <svg
104
+ viewBox={`0 0 600 ${height}`}
105
+ width="100%"
106
+ height={height}
107
+ style={{ display: 'block', overflow: 'visible' }}
108
+ onMouseMove={handleMouseMove}
109
+ onMouseLeave={handleMouseLeave}
110
+ >
111
+ {/* Grid lines */}
112
+ {leftTicks.map((tick, i) => (
113
+ <line
114
+ key={`grid-${i}`}
115
+ x1={PADDING.left}
116
+ y1={tick.y}
117
+ x2={600 - PADDING.right}
118
+ y2={tick.y}
119
+ stroke="var(--border)"
120
+ strokeWidth={1}
121
+ strokeDasharray="4,3"
122
+ opacity={0.5}
123
+ />
124
+ ))}
125
+
126
+ {/* Left Y axis — requests */}
127
+ {leftTicks.map((tick, i) => (
128
+ <text
129
+ key={`ly-${i}`}
130
+ x={PADDING.left - 6}
131
+ y={tick.y + 4}
132
+ textAnchor="end"
133
+ fontSize={10}
134
+ fill="var(--text-muted)"
135
+ fontFamily="var(--font-mono, ui-monospace, monospace)"
136
+ >
137
+ {tick.label}
138
+ </text>
139
+ ))}
140
+
141
+ {/* Right Y axis — tokens */}
142
+ {rightTicks.map((tick, i) => (
143
+ <text
144
+ key={`ry-${i}`}
145
+ x={600 - PADDING.right + 6}
146
+ y={tick.y + 4}
147
+ textAnchor="start"
148
+ fontSize={10}
149
+ fill="var(--text-muted)"
150
+ fontFamily="var(--font-mono, ui-monospace, monospace)"
151
+ >
152
+ {tick.label}
153
+ </text>
154
+ ))}
155
+
156
+ {/* Bars — requests */}
157
+ {requests.map((v, i) => {
158
+ const bh = barH(v);
159
+ const bx = barX(i);
160
+ const by = PADDING.top + innerH - bh;
161
+ return (
162
+ <rect
163
+ key={`bar-${i}`}
164
+ x={bx}
165
+ y={by}
166
+ width={barW}
167
+ height={bh}
168
+ rx={2}
169
+ fill={tip?.index === i ? 'var(--accent)' : 'var(--accent)'}
170
+ opacity={tip?.index === i ? 1 : 0.75}
171
+ style={{ transition: 'opacity 120ms ease' }}
172
+ />
173
+ );
174
+ })}
175
+
176
+ {/* Token line */}
177
+ <polyline
178
+ points={linePoints}
179
+ fill="none"
180
+ stroke="var(--warning, #d29922)"
181
+ strokeWidth={2}
182
+ strokeLinejoin="round"
183
+ strokeLinecap="round"
184
+ opacity={0.9}
185
+ />
186
+
187
+ {/* Token dots */}
188
+ {tokens.map((v, i) => (
189
+ <circle
190
+ key={`dot-${i}`}
191
+ cx={barX(i) + barW / 2}
192
+ cy={lineY(v)}
193
+ r={tip?.index === i ? 5 : 3}
194
+ fill="var(--warning, #d29922)"
195
+ opacity={tip?.index === i ? 1 : 0.7}
196
+ style={{ transition: 'r 120ms ease' }}
197
+ />
198
+ ))}
199
+
200
+ {/* X axis labels */}
201
+ {xLabels.map(({ i, l, x }) => (
202
+ <text
203
+ key={`xl-${i}`}
204
+ x={x}
205
+ y={PADDING.top + innerH + 18}
206
+ textAnchor="middle"
207
+ fontSize={10}
208
+ fill="var(--text-muted)"
209
+ fontFamily="var(--font-mono, ui-monospace, monospace)"
210
+ >
211
+ {l}
212
+ </text>
213
+ ))}
214
+
215
+ {/* X axis line */}
216
+ <line
217
+ x1={PADDING.left}
218
+ y1={PADDING.top + innerH}
219
+ x2={600 - PADDING.right}
220
+ y2={PADDING.top + innerH}
221
+ stroke="var(--border)"
222
+ strokeWidth={1}
223
+ />
224
+ </svg>
225
+
226
+ {/* Floating tooltip */}
227
+ {tip !== null && (
228
+ <div
229
+ className="usage-chart-tooltip"
230
+ style={{
231
+ left: Math.min(tip.x + 12, 600 - 140),
232
+ top: tip.y - 60,
233
+ }}
234
+ >
235
+ <div className="usage-tooltip-date">{labels[tip.index]}</div>
236
+ <div className="usage-tooltip-row">
237
+ <span className="usage-tooltip-dot" style={{ background: 'var(--accent)' }} />
238
+ <span>Requests:</span>
239
+ <strong>{requests[tip.index].toLocaleString()}</strong>
240
+ </div>
241
+ <div className="usage-tooltip-row">
242
+ <span className="usage-tooltip-dot" style={{ background: 'var(--warning, #d29922)' }} />
243
+ <span>Tokens:</span>
244
+ <strong>{tokens[tip.index].toLocaleString()}</strong>
245
+ </div>
246
+ </div>
247
+ )}
248
+ </div>
249
+ );
250
+ }
@@ -0,0 +1,90 @@
1
+ // src/web/components/UsageTable.tsx
2
+ //
3
+ // Sortable compact table for per-model usage breakdown.
4
+ //
5
+ // Props:
6
+ // rows: PerModelRow[]
7
+ // sortKey: string
8
+ // sortDir: 'asc' | 'desc'
9
+ // onSort: (key: string) => void
10
+
11
+ import { cn } from '../lib/utils';
12
+
13
+ export type PerModelRow = {
14
+ providerId: string;
15
+ modelId: string;
16
+ requests: number;
17
+ totalTokens: number;
18
+ promptTokens: number;
19
+ completionTokens: number;
20
+ errors: number;
21
+ avgLatencyMs: number;
22
+ };
23
+
24
+ type Props = {
25
+ rows: PerModelRow[];
26
+ sortKey: string;
27
+ sortDir: 'asc' | 'desc';
28
+ onSort: (key: string) => void;
29
+ };
30
+
31
+ function SortHeader({ label, col, sortKey, sortDir, onSort }: {
32
+ label: string; col: string; sortKey: string; sortDir: 'asc' | 'desc'; onSort: (k: string) => void;
33
+ }) {
34
+ const active = sortKey === col;
35
+ return (
36
+ <th
37
+ className={cn('usage-sort-header', active && 'is-active')}
38
+ onClick={() => onSort(col)}
39
+ aria-sort={active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
40
+ >
41
+ <span>{label}</span>
42
+ <span className="usage-sort-icon">
43
+ {active ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ' ↕'}
44
+ </span>
45
+ </th>
46
+ );
47
+ }
48
+
49
+ export function UsageTable({ rows, sortKey, sortDir, onSort }: Props) {
50
+ return (
51
+ <div className="usage-table-wrap">
52
+ <table className="usage-table">
53
+ <thead>
54
+ <tr>
55
+ <SortHeader label="Model" col="modelId" sortKey={sortKey} sortDir={sortDir} onSort={onSort} />
56
+ <SortHeader label="Requests" col="requests" sortKey={sortKey} sortDir={sortDir} onSort={onSort} />
57
+ <SortHeader label="Prompt Tok" col="promptTokens" sortKey={sortKey} sortDir={sortDir} onSort={onSort} />
58
+ <SortHeader label="Compl Tok" col="completionTokens" sortKey={sortKey} sortDir={sortDir} onSort={onSort} />
59
+ <SortHeader label="Total Tok" col="totalTokens" sortKey={sortKey} sortDir={sortDir} onSort={onSort} />
60
+ <SortHeader label="Avg Latency" col="avgLatencyMs" sortKey={sortKey} sortDir={sortDir} onSort={onSort} />
61
+ <SortHeader label="Errors" col="errors" sortKey={sortKey} sortDir={sortDir} onSort={onSort} />
62
+ </tr>
63
+ </thead>
64
+ <tbody>
65
+ {rows.length === 0 ? (
66
+ <tr>
67
+ <td colSpan={7} className="usage-table-empty">No data for this range</td>
68
+ </tr>
69
+ ) : (
70
+ rows.map((row) => (
71
+ <tr key={`${row.providerId}::${row.modelId}`}>
72
+ <td className="usage-table-model">
73
+ <code>{row.modelId}</code>
74
+ </td>
75
+ <td className="mono">{row.requests.toLocaleString()}</td>
76
+ <td className="mono">{row.promptTokens.toLocaleString()}</td>
77
+ <td className="mono">{row.completionTokens.toLocaleString()}</td>
78
+ <td className="mono">{row.totalTokens.toLocaleString()}</td>
79
+ <td className="mono">{row.avgLatencyMs}ms</td>
80
+ <td className={cn('mono', row.errors > 0 && 'is-err')}>
81
+ {row.errors > 0 ? row.errors : '—'}
82
+ </td>
83
+ </tr>
84
+ ))
85
+ )}
86
+ </tbody>
87
+ </table>
88
+ </div>
89
+ );
90
+ }
@@ -1,18 +1,9 @@
1
- // src/components/chat/ChatComposer.tsx — bottom composer pill (v3.22 design).
1
+ // src/components/chat/ChatComposer.tsx — bottom composer pill (v4.2.5).
2
2
  //
3
- // Replaces FloatingComposer. Visual layout follows the design HTML:
4
- //
5
- // [agent chip] [model badge] [──── textarea ────] [📎] [✨] [➤]
6
- //
7
- // All wrapped in a single pill (chat-composer-pill) with rounded ends
8
- // and a focus-within accent border. The legacy Composer component
9
- // already provides the textarea, agent chip menu, attachment tags,
10
- // and slash-command suggestion dropdown — the redesign simply nests
11
- // the Composer inside the new pill chrome and adds the hint row
12
- // (⏎ / ⇧⏎ / /) underneath.
13
- //
14
- // Props match the original FloatingComposer EXACTLY (so Chat.tsx +
15
- // MobileChat.tsx don't need any changes).
3
+ // v4.2.5 small refinement: the pill's border picks up the
4
+ // activeSource color (opencode → accent border; bizar → neutral),
5
+ // so the user has visual confirmation that their next message will
6
+ // land in the right backend.
16
7
 
17
8
  import { useState } from 'react';
18
9
  import { Composer } from './Composer';
@@ -32,31 +23,29 @@ interface Props {
32
23
  onPickSuggestion: (cmd: string) => void;
33
24
  agents: Array<{ name: string }>;
34
25
  onAttach: () => void;
35
- /** Backward-compat used to be page-level panel toggles. Ignored in
36
- * the redesign (rail + info are always visible). */
26
+ /** Which stream will receive the next message. */
27
+ activeSource?: 'bizar' | 'opencode' | null;
28
+ /** Backward-compat — used to be page-level panel toggles. Ignored. */
37
29
  sessionsOpen?: boolean;
38
30
  /** Backward-compat — see sessionsOpen. */
39
31
  infoOpen?: boolean;
40
32
  }
41
33
 
42
34
  export function ChatComposer(props: Props) {
43
- const { text, sending, onSend } = props;
35
+ const { text, sending, onSend, activeSource } = props;
44
36
  const [takingOff, setTakingOff] = useState(false);
45
37
 
46
- // Wrap onSend with a brief takeoff animation — a small CSS hook on
47
- // the wrapper pill. We don't animate a specific element here; the
48
- // pill itself gets the brief `takeoff` class so the send button
49
- // (the last child of Composer) gets the animation if the design
50
- // wants it.
51
38
  const handleSend = () => {
52
- if (!text.trim()) return;
39
+ if (!text.trim() || sending) return;
53
40
  setTakingOff(true);
54
41
  onSend();
55
42
  window.setTimeout(() => setTakingOff(false), 320);
56
43
  };
57
44
 
58
45
  return (
59
- <div className="chat-composer-wrap">
46
+ <div
47
+ className={`chat-composer-wrap chat-composer-source-${activeSource ?? 'none'}`}
48
+ >
60
49
  <div className={`chat-composer-pill${takingOff ? ' takeoff' : ''}`}>
61
50
  <Composer {...props} onSend={handleSend} />
62
51
  </div>
@@ -70,7 +59,14 @@ export function ChatComposer(props: Props) {
70
59
  <span>
71
60
  <kbd>/</kbd> commands
72
61
  </span>
62
+ {activeSource === 'opencode' && (
63
+ <span className="chat-composer-source-hint">→ opencode</span>
64
+ )}
65
+ {activeSource === 'bizar' && (
66
+ <span className="chat-composer-source-hint">→ bizar chat</span>
67
+ )}
68
+ {sending && <span className="chat-composer-source-hint">sending…</span>}
73
69
  </div>
74
70
  </div>
75
71
  );
76
- }
72
+ }