@polderlabs/bizar 4.5.2 → 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 (71) hide show
  1. package/bizar-dash/dist/assets/main-DGGq-iZI.js +361 -0
  2. package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +1 -0
  3. package/bizar-dash/dist/assets/{mobile-lbH6szyX.js → mobile-CWqPoGaT.js} +18 -18
  4. package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js → mobile-i4Uv9eW8.js} +1 -1
  6. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js.map → mobile-i4Uv9eW8.js.map} +1 -1
  7. package/bizar-dash/dist/index.html +2 -2
  8. package/bizar-dash/dist/mobile.html +2 -2
  9. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  10. package/bizar-dash/src/server/logger.mjs +71 -0
  11. package/bizar-dash/src/server/memory-lightrag.mjs +45 -6
  12. package/bizar-dash/src/server/metrics.mjs +193 -0
  13. package/bizar-dash/src/server/routes/chat.mjs +6 -3
  14. package/bizar-dash/src/server/routes/lightrag.mjs +3 -2
  15. package/bizar-dash/src/server/routes/memory.mjs +5 -4
  16. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  17. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  18. package/bizar-dash/src/server/server.mjs +62 -0
  19. package/bizar-dash/src/web/components/SearchModal.tsx +3 -2
  20. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  21. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  22. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  23. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  24. package/bizar-dash/src/web/locales/en.json +52 -0
  25. package/bizar-dash/src/web/main.tsx +5 -0
  26. package/bizar-dash/src/web/views/Activity.tsx +24 -17
  27. package/bizar-dash/src/web/views/History.tsx +90 -75
  28. package/bizar-dash/src/web/views/Overview.tsx +11 -8
  29. package/bizar-dash/src/web/views/Settings.tsx +7 -1
  30. package/bizar-dash/tests/cli-bugfixes.test.mjs +4 -4
  31. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  32. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  33. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  34. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  35. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  36. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  37. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  38. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  39. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  40. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  41. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  42. package/bizar-dash/tests/logger.test.mjs +207 -0
  43. package/bizar-dash/tests/metrics.test.mjs +183 -0
  44. package/bizar-dash/tests/server-bugfixes.test.mjs +2 -2
  45. package/bizar-dash/tests/setup.ts +7 -0
  46. package/bizar-dash/vitest.config.ts +13 -0
  47. package/cli/artifact-cli.mjs +605 -0
  48. package/cli/artifact-render.mjs +621 -0
  49. package/cli/artifact-server.mjs +847 -0
  50. package/cli/artifact.mjs +38 -2096
  51. package/cli/bg.mjs +5 -13
  52. package/cli/bin.mjs +149 -1361
  53. package/cli/commands/artifact.mjs +20 -0
  54. package/cli/commands/dash.mjs +160 -0
  55. package/cli/commands/headroom.mjs +204 -0
  56. package/cli/commands/install.mjs +169 -0
  57. package/cli/commands/memory.mjs +25 -0
  58. package/cli/commands/minimax.mjs +265 -0
  59. package/cli/commands/mod.mjs +185 -0
  60. package/cli/commands/service.mjs +65 -0
  61. package/cli/commands/usage.mjs +109 -0
  62. package/cli/commands/util.mjs +306 -0
  63. package/cli/doctor.mjs +1 -13
  64. package/cli/provision.mjs +2 -13
  65. package/cli/service-controller.mjs +1 -11
  66. package/cli/service.mjs +1 -11
  67. package/cli/utils.mjs +41 -1
  68. package/package.json +6 -1
  69. package/bizar-dash/dist/assets/main-B4OfGAwz.js +0 -361
  70. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +0 -1
  71. package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +0 -1
@@ -0,0 +1,53 @@
1
+ // src/web/components/VirtualList.tsx — hand-rolled virtual scrolling (no external deps).
2
+ import React, { useState, useRef, useEffect, useMemo } from 'react';
3
+
4
+ export function VirtualList<T>({
5
+ items,
6
+ renderItem,
7
+ itemHeight = 80,
8
+ height = 400,
9
+ overscan = 5,
10
+ className,
11
+ }: {
12
+ items: T[];
13
+ renderItem: (item: T, index: number) => React.ReactNode;
14
+ itemHeight?: number;
15
+ height?: number;
16
+ overscan?: number;
17
+ className?: string;
18
+ }) {
19
+ const [scrollTop, setScrollTop] = useState(0);
20
+ const containerRef = useRef<HTMLDivElement>(null);
21
+
22
+ const visibleRange = useMemo(() => {
23
+ const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
24
+ const end = Math.min(
25
+ items.length,
26
+ Math.ceil((scrollTop + height) / itemHeight) + overscan,
27
+ );
28
+ return { start, end };
29
+ }, [scrollTop, itemHeight, height, overscan, items.length]);
30
+
31
+ const totalHeight = items.length * itemHeight;
32
+ const offsetY = visibleRange.start * itemHeight;
33
+ const visibleItems = items.slice(visibleRange.start, visibleRange.end);
34
+
35
+ return (
36
+ <div
37
+ ref={containerRef}
38
+ onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
39
+ style={{ height, overflowY: 'auto' }}
40
+ className={className}
41
+ >
42
+ <div style={{ height: totalHeight, position: 'relative' }}>
43
+ <div style={{ transform: `translateY(${offsetY}px)` }}>
44
+ {visibleItems.map((item, i) => (
45
+ <div key={visibleRange.start + i} style={{ height: itemHeight }}>
46
+ {renderItem(item, visibleRange.start + i)}
47
+ </div>
48
+ ))}
49
+ </div>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
@@ -8,6 +8,7 @@ import { useEffect, useRef } from 'react';
8
8
  import { LoadingSkeleton } from './LoadingSkeleton';
9
9
  import { WelcomeScreen } from './WelcomeScreen';
10
10
  import { MessageBubble } from './MessageBubble';
11
+ import { VirtualList } from '../VirtualList';
11
12
 
12
13
  interface Message {
13
14
  role?: string;
@@ -105,17 +106,22 @@ export function ChatThread({
105
106
 
106
107
  return (
107
108
  <div className="chat-thread legacy" ref={innerRef}>
108
- {messages.map((m, i) => (
109
- <MessageBubble
110
- key={`${i}-${m.ts ?? ''}`}
111
- message={m}
112
- pinned={pinned.has(i)}
113
- onCopy={() => onCopy(m)}
114
- onDelete={() => onDelete(i)}
115
- onTogglePin={() => onTogglePin(i)}
116
- onRegenerate={() => onRegenerate(String(m.ts ?? i))}
117
- />
118
- ))}
109
+ <VirtualList
110
+ items={messages}
111
+ itemHeight={120}
112
+ height={800}
113
+ className="chat-thread-virtual"
114
+ renderItem={(m, i) => (
115
+ <MessageBubble
116
+ message={m}
117
+ pinned={pinned.has(i)}
118
+ onCopy={() => onCopy(m)}
119
+ onDelete={() => onDelete(i)}
120
+ onTogglePin={() => onTogglePin(i)}
121
+ onRegenerate={() => onRegenerate(String(m.ts ?? i))}
122
+ />
123
+ )}
124
+ />
119
125
  </div>
120
126
  );
121
127
  }
@@ -0,0 +1,13 @@
1
+ // src/web/hooks/useI18n.ts — React hook for translations.
2
+ import { useState } from 'react';
3
+ import { t, setLocale, getLocale } from '../lib/i18n';
4
+
5
+ export function useI18n() {
6
+ const [, force] = useState(0);
7
+ return {
8
+ t,
9
+ setLocale,
10
+ locale: getLocale(),
11
+ rerender: () => force((n) => n + 1),
12
+ };
13
+ }
@@ -0,0 +1,25 @@
1
+ // src/web/lib/i18n.ts — tiny key-based translation system.
2
+ export type Locale = 'en' | string;
3
+ export type Translations = Record<string, string>;
4
+
5
+ let currentLocale: Locale = 'en';
6
+ const translations: Record<Locale, Translations> = { en: {} };
7
+
8
+ export function setLocale(locale: Locale, dict?: Translations) {
9
+ currentLocale = locale;
10
+ if (dict) translations[locale] = dict;
11
+ }
12
+
13
+ export function t(key: string, vars?: Record<string, string | number>): string {
14
+ let s = translations[currentLocale]?.[key] || translations['en']?.[key] || key;
15
+ if (vars) {
16
+ for (const [k, v] of Object.entries(vars)) {
17
+ s = s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
18
+ }
19
+ }
20
+ return s;
21
+ }
22
+
23
+ export function getLocale(): Locale {
24
+ return currentLocale;
25
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "settings.title": "Settings",
3
+ "settings.general": "General",
4
+ "settings.envVars": "Environment Variables",
5
+ "settings.providers": "Providers",
6
+ "settings.theme": "Theme",
7
+ "settings.themeMode": "Mode",
8
+ "settings.themeAccent": "Accent",
9
+ "settings.layout": "Layout",
10
+ "settings.notifications": "Notifications",
11
+ "settings.activity": "Activity",
12
+ "settings.save": "Save",
13
+ "settings.reset": "Reset",
14
+ "settings.reload": "Reload",
15
+ "settings.dashboard": "Dashboard",
16
+ "settings.background": "Background Agents",
17
+ "settings.systemLlm": "System LLM API",
18
+ "settings.headroom": "Headroom",
19
+ "settings.updates": "Updates",
20
+ "settings.about": "About",
21
+ "settings.auth": "Authentication",
22
+ "settings.agents": "Agent Behavior",
23
+ "settings.service": "Service",
24
+ "settings.tailscale": "Tailscale Serve",
25
+ "chat.title": "Chat",
26
+ "chat.newSession": "New session",
27
+ "chat.sendMessage": "Send message",
28
+ "chat.placeholder": "Type your message…",
29
+ "chat.export": "Export transcript",
30
+ "chat.rename": "Rename session",
31
+ "chat.delete": "Delete session",
32
+ "memory.title": "Memory",
33
+ "memory.overview": "Overview",
34
+ "memory.lightrag": "LightRAG",
35
+ "memory.obsidian": "Obsidian Vault",
36
+ "memory.gitSync": "Git Sync",
37
+ "memory.semantic": "Semantic Search",
38
+ "memory.config": "Config",
39
+ "memory.refresh": "Refresh memory",
40
+ "tasks.title": "Tasks",
41
+ "tasks.create": "Create task",
42
+ "tasks.complete": "Mark complete",
43
+ "tasks.delete": "Delete task",
44
+ "activity.title": "Activity",
45
+ "activity.live": "Live",
46
+ "activity.paused": "Paused",
47
+ "activity.refresh": "Refresh",
48
+ "history.title": "History",
49
+ "history.export": "Export",
50
+ "overview.title": "Overview",
51
+ "overview.submit": "Submit to Odin"
52
+ }
@@ -3,6 +3,11 @@ import { StrictMode, useEffect, useState } from 'react';
3
3
  import { createRoot } from 'react-dom/client';
4
4
  import { App } from './App';
5
5
  import { MobileApp } from './MobileApp';
6
+ import en from './locales/en.json';
7
+ import { setLocale } from './lib/i18n';
8
+
9
+ // Initialize i18n
10
+ setLocale('en', en);
6
11
  // Import mobile CSS so the MobileApp renders with styles when served
7
12
  // from index.html (e.g. when the auto-redirect to /m is bypassed).
8
13
  import './styles/main.css';
@@ -35,6 +35,7 @@ import { Button } from '../components/Button';
35
35
  import { Card, CardTitle } from '../components/Card';
36
36
  import { EmptyState } from '../components/EmptyState';
37
37
  import { Spinner } from '../components/Spinner';
38
+ import { VirtualList } from '../components/VirtualList';
38
39
  import { useToast } from '../components/Toast';
39
40
  import { useModal } from '../components/Modal';
40
41
  import { api } from '../lib/api';
@@ -776,23 +777,29 @@ export function Activity({ snapshot, refreshSnapshot }: Props) {
776
777
  <p>No events yet.</p>
777
778
  </div>
778
779
  ) : (
779
- [...events].reverse().map((ev, i) => {
780
- const Icon =
781
- ev.author ? Bot : ev.kind === 'task' ? CheckSquare : ev.kind === 'bg' ? Cpu : ActivityIcon;
782
- return (
783
- <div key={`${ev.ts}-${i}`} className="tl-stream-event">
784
- <span className="tl-stream-event-time">
785
- {new Date(ev.ts).toLocaleTimeString('en-GB', { hour12: false })}
786
- </span>
787
- <span className="tl-stream-event-icon" style={{ color: statusColor(ev.kind) }}>
788
- <Icon size={12} />
789
- </span>
790
- <span className="tl-stream-event-text">
791
- {ev.author || ev.text || ev.kind}
792
- </span>
793
- </div>
794
- );
795
- })
780
+ <VirtualList
781
+ items={[...events].reverse()}
782
+ itemHeight={40}
783
+ height={400}
784
+ className="tl-stream-virtual"
785
+ renderItem={(ev) => {
786
+ const Icon =
787
+ ev.author ? Bot : ev.kind === 'task' ? CheckSquare : ev.kind === 'bg' ? Cpu : ActivityIcon;
788
+ return (
789
+ <div className="tl-stream-event">
790
+ <span className="tl-stream-event-time">
791
+ {new Date(ev.ts).toLocaleTimeString('en-GB', { hour12: false })}
792
+ </span>
793
+ <span className="tl-stream-event-icon" style={{ color: statusColor(ev.kind) }}>
794
+ <Icon size={12} />
795
+ </span>
796
+ <span className="tl-stream-event-text">
797
+ {ev.author || ev.text || ev.kind}
798
+ </span>
799
+ </div>
800
+ );
801
+ }}
802
+ />
796
803
  )}
797
804
  </div>
798
805
  </aside>
@@ -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') && (
@@ -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';
@@ -367,21 +368,23 @@ function OverviewInner({
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
  )}
@@ -888,6 +888,7 @@ function SettingsViewInner({ 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 @@ function SettingsViewInner({ 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 @@ function SettingsViewInner({ 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 @@ function SettingsViewInner({ 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 @@ function SettingsViewInner({ 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 @@ function SettingsViewInner({ 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 }))}
@@ -20,7 +20,7 @@ const REPO = join(process.cwd());
20
20
  describe('B1 — parseWithModsFlag', () => {
21
21
  it('exits with code 2 when --with-mods is the last arg', async () => {
22
22
  // Import fresh each time to avoid module-level side effects
23
- const { parseWithModsFlag } = await import('../../cli/bin.mjs');
23
+ const { parseWithModsFlag } = await import('../../cli/commands/install.mjs');
24
24
  let exitCode = null;
25
25
  const origExit = process.exit;
26
26
  // Mock process.exit to capture the code without terminating the test
@@ -36,7 +36,7 @@ describe('B1 — parseWithModsFlag', () => {
36
36
  });
37
37
 
38
38
  it('exits with code 2 when next arg is another flag', async () => {
39
- const { parseWithModsFlag } = await import('../../cli/bin.mjs');
39
+ const { parseWithModsFlag } = await import('../../cli/commands/install.mjs');
40
40
  let exitCode = null;
41
41
  const origExit = process.exit;
42
42
  try {
@@ -51,7 +51,7 @@ describe('B1 — parseWithModsFlag', () => {
51
51
  });
52
52
 
53
53
  it('returns null when --with-mods is not present', async () => {
54
- const { parseWithModsFlag } = await import('../../cli/bin.mjs');
54
+ const { parseWithModsFlag } = await import('../../cli/commands/install.mjs');
55
55
  let exitCode = null;
56
56
  const origExit = process.exit;
57
57
  try {
@@ -65,7 +65,7 @@ describe('B1 — parseWithModsFlag', () => {
65
65
  });
66
66
 
67
67
  it('returns array of mod ids when value is provided', async () => {
68
- const { parseWithModsFlag } = await import('../../cli/bin.mjs');
68
+ const { parseWithModsFlag } = await import('../../cli/commands/install.mjs');
69
69
  let exitCode = null;
70
70
  const origExit = process.exit;
71
71
  try {