@polderlabs/bizar 4.5.1 → 4.7.0

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 (94) hide show
  1. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  2. package/bizar-dash/dist/assets/main-DAlLdW8I.css +1 -0
  3. package/bizar-dash/dist/assets/main-DGGq-iZI.js +361 -0
  4. package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-O6ANdD4W.js → mobile-CWqPoGaT.js} +18 -19
  6. package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +1 -0
  7. package/bizar-dash/dist/assets/{mobile-DQLFCjwJ.js → mobile-i4Uv9eW8.js} +1 -2
  8. package/bizar-dash/dist/assets/{mobile-DQLFCjwJ.js.map → mobile-i4Uv9eW8.js.map} +1 -1
  9. package/bizar-dash/dist/index.html +3 -3
  10. package/bizar-dash/dist/mobile.html +2 -2
  11. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  12. package/bizar-dash/src/server/headroom.mjs +37 -35
  13. package/bizar-dash/src/server/logger.mjs +71 -0
  14. package/bizar-dash/src/server/memory-lightrag.mjs +84 -37
  15. package/bizar-dash/src/server/metrics.mjs +193 -0
  16. package/bizar-dash/src/server/mods-loader.mjs +13 -5
  17. package/bizar-dash/src/server/providers-store.mjs +73 -1
  18. package/bizar-dash/src/server/routes/chat.mjs +41 -0
  19. package/bizar-dash/src/server/routes/lightrag.mjs +7 -2
  20. package/bizar-dash/src/server/routes/memory.mjs +13 -4
  21. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  22. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  23. package/bizar-dash/src/server/schedules-runner.mjs +4 -4
  24. package/bizar-dash/src/server/server.mjs +101 -37
  25. package/bizar-dash/src/server/watcher.mjs +2 -2
  26. package/bizar-dash/src/web/App.tsx +40 -6
  27. package/bizar-dash/src/web/components/SearchModal.tsx +3 -2
  28. package/bizar-dash/src/web/components/Toast.tsx +1 -1
  29. package/bizar-dash/src/web/components/Topbar.tsx +1 -1
  30. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  31. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  32. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  33. package/bizar-dash/src/web/lib/api.ts +12 -11
  34. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  35. package/bizar-dash/src/web/locales/en.json +52 -0
  36. package/bizar-dash/src/web/main.tsx +5 -0
  37. package/bizar-dash/src/web/styles/chat.css +2 -0
  38. package/bizar-dash/src/web/styles/main.css +52 -23
  39. package/bizar-dash/src/web/styles/tasks.css +2 -0
  40. package/bizar-dash/src/web/views/Activity.tsx +24 -17
  41. package/bizar-dash/src/web/views/History.tsx +90 -75
  42. package/bizar-dash/src/web/views/Memory.tsx +4 -3
  43. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +3 -2
  44. package/bizar-dash/src/web/views/Overview.tsx +15 -10
  45. package/bizar-dash/src/web/views/Settings.tsx +9 -2
  46. package/bizar-dash/src/web/views/Skills.tsx +3 -2
  47. package/bizar-dash/src/web/views/Tasks.tsx +4 -3
  48. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +8 -6
  49. package/bizar-dash/tests/cli-bugfixes.test.mjs +151 -0
  50. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  51. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  52. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  53. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  54. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  55. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  56. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  57. package/bizar-dash/tests/frontend-bugfixes.test.mjs +151 -0
  58. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  59. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  60. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  61. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  62. package/bizar-dash/tests/logger.test.mjs +207 -0
  63. package/bizar-dash/tests/metrics.test.mjs +183 -0
  64. package/bizar-dash/tests/server-bugfixes.test.mjs +318 -0
  65. package/bizar-dash/tests/setup.ts +7 -0
  66. package/bizar-dash/vitest.config.ts +13 -0
  67. package/cli/artifact-cli.mjs +605 -0
  68. package/cli/artifact-render.mjs +621 -0
  69. package/cli/artifact-server.mjs +847 -0
  70. package/cli/artifact.mjs +38 -2089
  71. package/cli/bg.mjs +5 -13
  72. package/cli/bin.mjs +170 -1348
  73. package/cli/commands/artifact.mjs +20 -0
  74. package/cli/commands/dash.mjs +160 -0
  75. package/cli/commands/headroom.mjs +204 -0
  76. package/cli/commands/install.mjs +169 -0
  77. package/cli/commands/memory.mjs +25 -0
  78. package/cli/commands/minimax.mjs +265 -0
  79. package/cli/commands/mod.mjs +185 -0
  80. package/cli/commands/service.mjs +65 -0
  81. package/cli/commands/usage.mjs +109 -0
  82. package/cli/commands/util.mjs +306 -0
  83. package/cli/doctor.mjs +6 -16
  84. package/cli/memory.mjs +65 -21
  85. package/cli/provision.mjs +10 -15
  86. package/cli/service-controller.mjs +1 -11
  87. package/cli/service.mjs +1 -11
  88. package/cli/utils.mjs +41 -1
  89. package/install.sh +1 -2
  90. package/package.json +8 -3
  91. package/bizar-dash/dist/assets/main-eWZ4NlCL.css +0 -1
  92. package/bizar-dash/dist/assets/main-usWhlPWa.js +0 -362
  93. package/bizar-dash/dist/assets/main-usWhlPWa.js.map +0 -1
  94. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +0 -1
@@ -2,7 +2,7 @@
2
2
  // Shows per-project timelines (task events, plan events, project lifecycle)
3
3
  // aggregated from the activity log.
4
4
 
5
- import { useEffect, useState } from 'react';
5
+ import React, { useEffect, useState } from 'react';
6
6
  import {
7
7
  History as HistoryIcon,
8
8
  RefreshCw,
@@ -17,6 +17,7 @@ import {
17
17
  import { Card, CardTitle, CardMeta } from '../components/Card';
18
18
  import { Button } from '../components/Button';
19
19
  import { Spinner } from '../components/Spinner';
20
+ import { VirtualList } from '../components/VirtualList';
20
21
  import { useToast } from '../components/Toast';
21
22
  import { api } from '../lib/api';
22
23
  import { cn, formatRelative } from '../lib/utils';
@@ -65,6 +66,85 @@ const TIME_RANGES = [
65
66
  { id: 'all', label: 'All time', ms: 0 },
66
67
  ];
67
68
 
69
+ function renderProjectCard(
70
+ p: ProjectHistory,
71
+ events: HistoryEvent[],
72
+ expanded: Set<string>,
73
+ toggleProject: (id: string) => void,
74
+ ): React.ReactNode {
75
+ const isOpen = expanded.has(p.id);
76
+ return (
77
+ <Card key={p.id} className="history-project" style={{ marginBottom: 4 }}>
78
+ <div className="history-project-head">
79
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0, flex: 1 }}>
80
+ <div className="history-project-name">
81
+ <Folder size={16} /> {p.name}
82
+ </div>
83
+ <div className="history-project-meta">{p.path}</div>
84
+ </div>
85
+ <div className="history-project-stats">
86
+ <span className="history-project-stat">
87
+ <span className="history-project-stat-num">{p.tasks.done}</span>
88
+ <span className="muted">/ {p.tasks.total} done</span>
89
+ </span>
90
+ {p.tasks.doing > 0 && (
91
+ <span className="history-project-stat">
92
+ <span className="history-project-stat-num">{p.tasks.doing}</span>
93
+ <span className="muted">doing</span>
94
+ </span>
95
+ )}
96
+ {p.tasks.blocked > 0 && (
97
+ <span className="history-project-stat">
98
+ <span className="history-project-stat-num">{p.tasks.blocked}</span>
99
+ <span className="muted">blocked</span>
100
+ </span>
101
+ )}
102
+ <span className="history-project-stat">
103
+ <FileText size={11} /> <span className="history-project-stat-num">{p.plans}</span>
104
+ <span className="muted"> plan{p.plans === 1 ? '' : 's'}</span>
105
+ </span>
106
+ {p.lastAccessed && (
107
+ <span className="history-project-stat">
108
+ <span className="muted">last opened {formatRelative(p.lastAccessed)}</span>
109
+ </span>
110
+ )}
111
+ </div>
112
+ <button
113
+ type="button"
114
+ className="icon-btn"
115
+ onClick={() => toggleProject(p.id)}
116
+ aria-label={isOpen ? 'Collapse' : 'Expand'}
117
+ >
118
+ {isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
119
+ </button>
120
+ </div>
121
+
122
+ {isOpen && (
123
+ <div className="history-timeline-mini">
124
+ {events.length === 0 ? (
125
+ <div className="muted" style={{ padding: '12px 8px', fontSize: 12 }}>
126
+ No events in this time range.
127
+ </div>
128
+ ) : (
129
+ events.slice(-100).reverse().map((ev, i) => (
130
+ <div key={i} className="history-timeline-row">
131
+ <span className="history-timeline-ts">
132
+ {new Date(ev.ts).toLocaleTimeString()}
133
+ </span>
134
+ <span className="history-timeline-kind">{ev.kind || 'event'}</span>
135
+ <span className="history-timeline-msg" title={String(ev.text || ev.title || '')}>
136
+ {ev.author ? `@${ev.author} ` : ''}
137
+ {String(ev.text ?? ev.title ?? ev.name ?? '')}
138
+ </span>
139
+ </div>
140
+ ))
141
+ )}
142
+ </div>
143
+ )}
144
+ </Card>
145
+ );
146
+ }
147
+
68
148
  export function History({ snapshot }: Props) {
69
149
  const toast = useToast();
70
150
  const [data, setData] = useState<HistoryResponse | null>(null);
@@ -203,80 +283,15 @@ export function History({ snapshot }: Props) {
203
283
  </Card>
204
284
  )}
205
285
 
206
- {data.projects.map((p) => {
207
- const events = eventsByProject.get(p.id) || [];
208
- const isOpen = expanded.has(p.id);
209
- return (
210
- <Card key={p.id} className="history-project">
211
- <div className="history-project-head">
212
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0, flex: 1 }}>
213
- <div className="history-project-name">
214
- <Folder size={16} /> {p.name}
215
- </div>
216
- <div className="history-project-meta">{p.path}</div>
217
- </div>
218
- <div className="history-project-stats">
219
- <span className="history-project-stat">
220
- <span className="history-project-stat-num">{p.tasks.done}</span>
221
- <span className="muted">/ {p.tasks.total} done</span>
222
- </span>
223
- {p.tasks.doing > 0 && (
224
- <span className="history-project-stat">
225
- <span className="history-project-stat-num">{p.tasks.doing}</span>
226
- <span className="muted">doing</span>
227
- </span>
228
- )}
229
- {p.tasks.blocked > 0 && (
230
- <span className="history-project-stat">
231
- <span className="history-project-stat-num">{p.tasks.blocked}</span>
232
- <span className="muted">blocked</span>
233
- </span>
234
- )}
235
- <span className="history-project-stat">
236
- <FileText size={11} /> <span className="history-project-stat-num">{p.plans}</span>
237
- <span className="muted"> plan{p.plans === 1 ? '' : 's'}</span>
238
- </span>
239
- {p.lastAccessed && (
240
- <span className="history-project-stat">
241
- <span className="muted">last opened {formatRelative(p.lastAccessed)}</span>
242
- </span>
243
- )}
244
- </div>
245
- <button
246
- type="button"
247
- className="icon-btn"
248
- onClick={() => toggleProject(p.id)}
249
- aria-label={isOpen ? 'Collapse' : 'Expand'}
250
- >
251
- {isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
252
- </button>
253
- </div>
254
-
255
- {isOpen && (
256
- <div className="history-timeline-mini">
257
- {events.length === 0 ? (
258
- <div className="muted" style={{ padding: '12px 8px', fontSize: 12 }}>
259
- No events in this time range.
260
- </div>
261
- ) : (
262
- events.slice(-100).reverse().map((ev, i) => (
263
- <div key={i} className="history-timeline-row">
264
- <span className="history-timeline-ts">
265
- {new Date(ev.ts).toLocaleTimeString()}
266
- </span>
267
- <span className="history-timeline-kind">{ev.kind || 'event'}</span>
268
- <span className="history-timeline-msg" title={String(ev.text || ev.title || '')}>
269
- {ev.author ? `@${ev.author} ` : ''}
270
- {String(ev.text ?? ev.title ?? ev.name ?? '')}
271
- </span>
272
- </div>
273
- ))
274
- )}
275
- </div>
276
- )}
277
- </Card>
278
- );
279
- })}
286
+ {data.projects.length > 0 && (
287
+ <VirtualList
288
+ items={data.projects}
289
+ itemHeight={70}
290
+ height={Math.min(data.projects.length * 70, 500)}
291
+ className="history-virtual-list"
292
+ renderItem={(p) => renderProjectCard(p, eventsByProject.get(p.id) || [], expanded, toggleProject)}
293
+ />
294
+ )}
280
295
 
281
296
  {/* Global / unassigned events */}
282
297
  {eventsByProject.has('global') && (
@@ -7,7 +7,7 @@
7
7
  // semantic search) plus a config panel and a high-level overview. Each is a
8
8
  // standalone component under views/memory/.
9
9
 
10
- import { useCallback, useState } from 'react';
10
+ import React, { useCallback, useState } from 'react';
11
11
  import {
12
12
  Brain,
13
13
  FileText,
@@ -50,7 +50,7 @@ const SOURCES: Array<{
50
50
  { id: 'config', label: 'Config', icon: Sliders },
51
51
  ];
52
52
 
53
- export function Memory(_props: Props) {
53
+ function MemoryInner(_props: Props) {
54
54
  const toast = useToast();
55
55
  const [active, setActive] = useState<SubPanel>('overview');
56
56
  const [refreshKey, setRefreshKey] = useState(0);
@@ -137,4 +137,5 @@ export function Memory(_props: Props) {
137
137
  </div>
138
138
  </div>
139
139
  );
140
- }
140
+ }
141
+ export const Memory = React.memo(MemoryInner);
@@ -8,7 +8,7 @@
8
8
  // (written by minimax.mjs after every chatCompletion / fetchRemains call).
9
9
  // The Usage Analytics tab is only shown when the key is configured.
10
10
 
11
- import { useCallback, useEffect, useMemo, useState } from 'react';
11
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
12
12
  import {
13
13
  Coins,
14
14
  RefreshCw,
@@ -196,7 +196,7 @@ type TimeRange = '24h' | '7d' | '30d' | 'custom';
196
196
 
197
197
  // ─── Main component ────────────────────────────────────────────────────
198
198
 
199
- export function MiniMaxUsage({ activeTab, setActiveTab }: Props) {
199
+ function MiniMaxUsageInner({ activeTab, setActiveTab }: Props) {
200
200
  // Share state between tabs.
201
201
  const [view, setView] = useState<'quota' | 'analytics'>('quota');
202
202
  const toast = useToast();
@@ -921,3 +921,4 @@ function OnboardingWizard({ step, setStep, keyDraft, setKeyDraft, showKey, setSh
921
921
  </div>
922
922
  );
923
923
  }
924
+ export const MiniMaxUsage = React.memo(MiniMaxUsageInner);
@@ -1,5 +1,5 @@
1
1
  // src/views/Overview.tsx — v3.7.0 activity cards + SSE stream.
2
- import { useEffect, useRef, useState } from 'react';
2
+ import React, { useEffect, useRef, useState } from 'react';
3
3
  import {
4
4
  Bot,
5
5
  CheckSquare,
@@ -33,6 +33,7 @@ import { Button } from '../components/Button';
33
33
  import { MemoryStatusCard } from './memory/MemoryStatusCard';
34
34
  import { EmptyState } from '../components/EmptyState';
35
35
  import { Spinner } from '../components/Spinner';
36
+ import { VirtualList } from '../components/VirtualList';
36
37
  import { useToast } from '../components/Toast';
37
38
  import { useModal } from '../components/Modal';
38
39
  import { FileBrowser } from '../components/FileBrowser';
@@ -49,7 +50,7 @@ type Props = {
49
50
  refreshSnapshot: () => Promise<void>;
50
51
  };
51
52
 
52
- export function Overview({
53
+ function OverviewInner({
53
54
  snapshot,
54
55
  settings,
55
56
  setActiveTab,
@@ -367,21 +368,23 @@ export function Overview({
367
368
  </div>
368
369
  ) : (
369
370
  <div className={cn('activity-feed-list-wrap', !activityExpanded && 'activity-feed-list-wrap-collapsed')}>
370
- <div className="activity-feed-list">
371
- {activityItems.slice(0, 30).map((it, idx) => {
372
- const k = itemKey(it, idx);
373
- if (hiddenKeys.has(k)) return null;
371
+ <VirtualList
372
+ items={activityItems.slice(0, 30).filter((it, idx) => !hiddenKeys.has(itemKey(it, idx)))}
373
+ itemHeight={60}
374
+ height={Math.min(activityItems.filter((it, idx) => !hiddenKeys.has(itemKey(it, idx))).length * 60, 480)}
375
+ className="activity-feed-list"
376
+ renderItem={(it, vlIdx) => {
377
+ const originalIdx = activityItems.slice(0, 30).findIndex((a) => a === it);
374
378
  return (
375
379
  <ActivityFeedItem
376
- key={`${it.ts}-${idx}`}
377
380
  item={it}
378
- activityKey={k}
381
+ activityKey={itemKey(it, originalIdx)}
379
382
  onNavigate={setActiveTab}
380
383
  onHide={onHide}
381
384
  />
382
385
  );
383
- })}
384
- </div>
386
+ }}
387
+ />
385
388
  {!activityExpanded && activityItems.length > 8 && <div className="activity-feed-fade" aria-hidden="true" />}
386
389
  </div>
387
390
  )}
@@ -813,3 +816,5 @@ function ActivityFeedItem({
813
816
  </div>
814
817
  );
815
818
  }
819
+ const OverviewMemo = React.memo(OverviewInner);
820
+ export { OverviewMemo as Overview };
@@ -485,7 +485,7 @@ function UpdatesCard() {
485
485
  );
486
486
  }
487
487
 
488
- export function SettingsView({ settings: initial, refreshSnapshot }: Props) {
488
+ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
489
489
  const toast = useToast();
490
490
  const [settings, setSettings] = useState<Settings>(initial);
491
491
  const [dirty, setDirty] = useState(false);
@@ -888,6 +888,7 @@ export function SettingsView({ settings: initial, refreshSnapshot }: Props) {
888
888
  className="input"
889
889
  value={settings.theme.accent}
890
890
  onChange={(e) => patchTheme({ accent: e.target.value })}
891
+ aria-label="Accent color hex"
891
892
  />
892
893
  </div>
893
894
  </div>
@@ -906,6 +907,7 @@ export function SettingsView({ settings: initial, refreshSnapshot }: Props) {
906
907
  className="input"
907
908
  value={settings.theme.success}
908
909
  onChange={(e) => patchTheme({ success: e.target.value })}
910
+ aria-label="Success color hex"
909
911
  />
910
912
  </div>
911
913
  </div>
@@ -924,6 +926,7 @@ export function SettingsView({ settings: initial, refreshSnapshot }: Props) {
924
926
  className="input"
925
927
  value={settings.theme.warning}
926
928
  onChange={(e) => patchTheme({ warning: e.target.value })}
929
+ aria-label="Warning color hex"
927
930
  />
928
931
  </div>
929
932
  </div>
@@ -942,6 +945,7 @@ export function SettingsView({ settings: initial, refreshSnapshot }: Props) {
942
945
  className="input"
943
946
  value={settings.theme.error}
944
947
  onChange={(e) => patchTheme({ error: e.target.value })}
948
+ aria-label="Error color hex"
945
949
  />
946
950
  </div>
947
951
  </div>
@@ -960,6 +964,7 @@ export function SettingsView({ settings: initial, refreshSnapshot }: Props) {
960
964
  className="input"
961
965
  value={settings.theme.info}
962
966
  onChange={(e) => patchTheme({ info: e.target.value })}
967
+ aria-label="Info color hex"
963
968
  />
964
969
  </div>
965
970
  </div>
@@ -1144,8 +1149,9 @@ export function SettingsView({ settings: initial, refreshSnapshot }: Props) {
1144
1149
  />
1145
1150
  </div>
1146
1151
  <div className="task-form-field">
1147
- <label className="field-label">Use HTTPS</label>
1152
+ <label className="field-label" htmlFor="tailscale-https">Use HTTPS</label>
1148
1153
  <input
1154
+ id="tailscale-https"
1149
1155
  type="checkbox"
1150
1156
  checked={tailscaleDraft.https}
1151
1157
  onChange={(e) => setTailscaleDraft((cur) => ({ ...cur, https: e.target.checked }))}
@@ -1821,3 +1827,4 @@ function ActivityLogCard() {
1821
1827
  </Card>
1822
1828
  );
1823
1829
  }
1830
+ export const SettingsView = React.memo(SettingsViewInner);
@@ -1,5 +1,5 @@
1
1
  // src/views/Skills.tsx — v4.0.0 skills browser: scans all local SKILL.md sources.
2
- import { useEffect, useMemo, useState } from 'react';
2
+ import React, { useEffect, useMemo, useState } from 'react';
3
3
  import {
4
4
  Sparkles,
5
5
  Search,
@@ -58,7 +58,7 @@ const SOURCE_ICON: Record<string, typeof Package> = {
58
58
  project: Folder,
59
59
  };
60
60
 
61
- export function Skills({ snapshot, refreshSnapshot }: Props) {
61
+ function SkillsInner({ snapshot, refreshSnapshot }: Props) {
62
62
  const toast = useToast();
63
63
  const modal = useModal();
64
64
  const [skills, setSkills] = useState<Skill[]>([]);
@@ -377,3 +377,4 @@ function highlightMatch(text: string, q: string): React.ReactNode {
377
377
  </>
378
378
  );
379
379
  }
380
+ export const Skills = React.memo(SkillsInner);
@@ -20,7 +20,7 @@
20
20
  // We keep the API contract (statuses: queued/doing/done/blocked/backlog)
21
21
  // so the dashboard doesn't break the existing server, tasks store, and
22
22
  // tests. The UI maps API statuses to friendlier labels.
23
- import { useEffect, useMemo, useState } from 'react';
23
+ import React, { useEffect, useMemo, useState } from 'react';
24
24
  import {
25
25
  CheckSquare,
26
26
  Plus,
@@ -76,7 +76,7 @@ const COLUMNS: { id: Task['status']; label: string; kind: StatusKind }[] = [
76
76
  const PRIORITIES = ['low', 'normal', 'high'] as const;
77
77
  type Priority = (typeof PRIORITIES)[number];
78
78
 
79
- export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
79
+ function TasksInner({ snapshot, refreshSnapshot, setActiveTab }: Props) {
80
80
  const toast = useToast();
81
81
  const modal = useModal();
82
82
  const [tasks, setTasks] = useState<Task[]>(snapshot.tasks || []);
@@ -771,4 +771,5 @@ function openEditTaskModal(
771
771
  </div>
772
772
  ),
773
773
  });
774
- }
774
+ }
775
+ export const Tasks = React.memo(TasksInner);
@@ -98,14 +98,14 @@ export function MemoryOverview({ refreshKey, onRefresh, setActiveSubPanel }: Pro
98
98
  const [data, setData] = useState<MemoryOverviewData | null>(null);
99
99
  const [loading, setLoading] = useState(true);
100
100
 
101
- const reload = async () => {
101
+ const reload = async (signal?: AbortSignal) => {
102
102
  setLoading(true);
103
103
  try {
104
104
  const [health, status, lightrag, storage] = await Promise.all([
105
- api.get<HealthResponse>('/memory/health').catch(() => null),
106
- api.get<MemoryStatus>('/memory/status').catch(() => null),
107
- api.get<LightragStats>('/memory/lightrag/stats').catch(() => null),
108
- api.get<StorageStats>('/memory/storage').catch(() => null),
105
+ api.get<HealthResponse>('/memory/health', signal).catch(() => null),
106
+ api.get<MemoryStatus>('/memory/status', signal).catch(() => null),
107
+ api.get<LightragStats>('/memory/lightrag/stats', signal).catch(() => null),
108
+ api.get<StorageStats>('/memory/storage', signal).catch(() => null),
109
109
  ]);
110
110
  setData({
111
111
  health: health || {
@@ -145,7 +145,9 @@ export function MemoryOverview({ refreshKey, onRefresh, setActiveSubPanel }: Pro
145
145
  };
146
146
 
147
147
  useEffect(() => {
148
- reload();
148
+ const ctrl = new AbortController();
149
+ void reload(ctrl.signal);
150
+ return () => ctrl.abort();
149
151
  // eslint-disable-next-line react-hooks/exhaustive-deps
150
152
  }, [refreshKey]);
151
153
 
@@ -0,0 +1,151 @@
1
+ /**
2
+ * tests/cli-bugfixes.test.mjs — regression tests for CLI + installer bug fixes.
3
+ *
4
+ * Covers:
5
+ * B1 — parseWithModsFlag exits with code 2 when no value follows --with-mods
6
+ * B7 — check-deps.mjs uses path.join() (Windows fix) + covers all deps
7
+ * B9 — --json flag works for `bizar doctor --json`
8
+ * B10 — --debug flag sets DEBUG env var
9
+ *
10
+ * Run with: node --test bizar-dash/tests/cli-bugfixes.test.mjs
11
+ */
12
+ import { describe, it, beforeEach } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { join } from 'node:path';
15
+
16
+ const REPO = join(process.cwd());
17
+
18
+ // ─── B1: parseWithModsFlag exits with code 2 on missing value ──────────────────
19
+
20
+ describe('B1 — parseWithModsFlag', () => {
21
+ it('exits with code 2 when --with-mods is the last arg', async () => {
22
+ // Import fresh each time to avoid module-level side effects
23
+ const { parseWithModsFlag } = await import('../../cli/commands/install.mjs');
24
+ let exitCode = null;
25
+ const origExit = process.exit;
26
+ // Mock process.exit to capture the code without terminating the test
27
+ try {
28
+ process.exit = (code) => { exitCode = code; throw new Error('exit:' + code); };
29
+ parseWithModsFlag(['install', '--with-mods']);
30
+ } catch (err) {
31
+ if (err.message.startsWith('exit:')) exitCode = parseInt(err.message.split(':')[1]);
32
+ } finally {
33
+ process.exit = origExit;
34
+ }
35
+ assert.strictEqual(exitCode, 2, 'should have called process.exit(2)');
36
+ });
37
+
38
+ it('exits with code 2 when next arg is another flag', async () => {
39
+ const { parseWithModsFlag } = await import('../../cli/commands/install.mjs');
40
+ let exitCode = null;
41
+ const origExit = process.exit;
42
+ try {
43
+ process.exit = (code) => { exitCode = code; throw new Error('exit:' + code); };
44
+ parseWithModsFlag(['install', '--with-mods', '--force']);
45
+ } catch (err) {
46
+ if (err.message.startsWith('exit:')) exitCode = parseInt(err.message.split(':')[1]);
47
+ } finally {
48
+ process.exit = origExit;
49
+ }
50
+ assert.strictEqual(exitCode, 2);
51
+ });
52
+
53
+ it('returns null when --with-mods is not present', async () => {
54
+ const { parseWithModsFlag } = await import('../../cli/commands/install.mjs');
55
+ let exitCode = null;
56
+ const origExit = process.exit;
57
+ try {
58
+ process.exit = (code) => { exitCode = code; throw new Error('exit:' + code); };
59
+ const result = parseWithModsFlag(['install', '--force']);
60
+ assert.strictEqual(result, null);
61
+ assert.strictEqual(exitCode, null);
62
+ } finally {
63
+ process.exit = origExit;
64
+ }
65
+ });
66
+
67
+ it('returns array of mod ids when value is provided', async () => {
68
+ const { parseWithModsFlag } = await import('../../cli/commands/install.mjs');
69
+ let exitCode = null;
70
+ const origExit = process.exit;
71
+ try {
72
+ process.exit = (code) => { exitCode = code; throw new Error('exit:' + code); };
73
+ const result = parseWithModsFlag(['install', '--with-mods', 'a,b,c']);
74
+ assert.deepStrictEqual(result, ['a', 'b', 'c']);
75
+ assert.strictEqual(exitCode, null);
76
+ } finally {
77
+ process.exit = origExit;
78
+ }
79
+ });
80
+ });
81
+
82
+ // ─── B7: check-deps.mjs cross-platform path joining ───────────────────────────
83
+
84
+ describe('B7 — check-deps.mjs which() uses path.join()', () => {
85
+ it('covers all required dependencies', async () => {
86
+ const { checkDeps } = await import('../../scripts/check-deps.mjs');
87
+ const result = await checkDeps({ strict: false });
88
+ const names = [...result.present, ...result.missing].map(d => d.name);
89
+ const required = ['node', 'bun', 'opencode', 'tmux', 'git', 'python3', 'pip', 'jq', 'gh', 'headroom', 'semble', 'skills'];
90
+ for (const dep of required) {
91
+ assert.ok(names.includes(dep), `expected ${dep} to be checked`);
92
+ }
93
+ });
94
+
95
+ it('which() uses join() from node:path for path construction', async () => {
96
+ const { readFileSync } = await import('node:fs');
97
+ const src = readFileSync(join(process.cwd(), 'scripts', 'check-deps.mjs'), 'utf8');
98
+ assert.ok(src.includes("import { join } from 'node:path'"), 'should import join from node:path');
99
+ assert.ok(src.includes('join(dir, cmd + ext)'), 'should use join() for path construction');
100
+ });
101
+ });
102
+
103
+ // ─── B9: --json flag for doctor ───────────────────────────────────────────────
104
+
105
+ describe('B9 — --json flag', () => {
106
+ it('doctor --json outputs valid JSON', async () => {
107
+ const { spawnSync } = await import('node:child_process');
108
+ const bin = join(REPO, 'cli', 'bin.mjs');
109
+ const result = spawnSync('node', [bin, 'doctor', '--json'], {
110
+ encoding: 'utf8',
111
+ timeout: 30000,
112
+ });
113
+ const out = result.stdout.trim();
114
+ // Should be parseable as JSON
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(out);
118
+ } catch {
119
+ assert.fail(`expected JSON output, got: ${out.slice(0, 200)}`);
120
+ return;
121
+ }
122
+ assert.ok('passed' in parsed, 'JSON should have passed field');
123
+ assert.ok('failed' in parsed, 'JSON should have failed field');
124
+ assert.ok('results' in parsed, 'JSON should have results field');
125
+ assert.ok(Array.isArray(parsed.results), 'results should be an array');
126
+ });
127
+ });
128
+
129
+ // ─── B10: --debug flag sets DEBUG env var ──────────────────────────────────────
130
+
131
+ describe('B10 — --debug flag', () => {
132
+ it('dbg() helper is defined in bin.mjs', async () => {
133
+ const { readFileSync } = await import('node:fs');
134
+ const src = readFileSync(join(REPO, 'cli', 'bin.mjs'), 'utf8');
135
+ assert.ok(src.includes('function dbg('), 'dbg() helper should be defined');
136
+ assert.ok(src.includes("process.env.DEBUG = 'bizar:*'"), '--debug should set DEBUG=bizar:*');
137
+ assert.ok(src.includes("process.env.BIZAR_DEBUG = '1'"), '--debug should set BIZAR_DEBUG=1');
138
+ });
139
+
140
+ it('--debug flag is accepted without error', async () => {
141
+ const { spawnSync } = await import('node:child_process');
142
+ const bin = join(REPO, 'cli', 'bin.mjs');
143
+ // Just verify --debug doesn't cause a parse error or unknown flag
144
+ const result = spawnSync('node', [bin, '--help', '--debug'], {
145
+ encoding: 'utf8',
146
+ timeout: 10000,
147
+ });
148
+ // --debug should not cause an error (help should print)
149
+ assert.ok(result.status === 0 || result.stdout.includes('bizar'), '--debug should not error');
150
+ });
151
+ });