@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
@@ -1,6 +1,6 @@
1
1
  // src/App.tsx — root shell. Wires data + contexts + tab routing.
2
2
 
3
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Topbar, TABS } from './components/Topbar';
5
5
  import { Sidebar } from './components/Sidebar';
6
6
  import { ModalProvider, useModal } from './components/Modal';
@@ -394,10 +394,6 @@ function Shell() {
394
394
  const m = msg;
395
395
  const file = m.path?.split('/').pop() || m.path || '';
396
396
  toast.info(`File changed: ${file}`, 2500);
397
- api
398
- .get<Snapshot>('/snapshot')
399
- .then((s) => setSnapshot((cur) => ({ ...(cur ?? ({} as Snapshot)), ...s })))
400
- .catch(() => undefined);
401
397
  } else if (msg.type === 'tasks:change') {
402
398
  const m = msg;
403
399
  setSnapshot((cur) => {
@@ -798,7 +794,11 @@ function Shell() {
798
794
  <p>Loading Bizar…</p>
799
795
  </div>
800
796
  )}
801
- {renderedView}
797
+ {renderedView && (
798
+ <Suspense fallback={<div className="loading"><Spinner size="lg" /><p>Loading…</p></div>}>
799
+ <ViewErrorBoundary>{renderedView}</ViewErrorBoundary>
800
+ </Suspense>
801
+ )}
802
802
  </main>
803
803
  </div>
804
804
  <SearchModal
@@ -809,3 +809,37 @@ function Shell() {
809
809
  </div>
810
810
  );
811
811
  }
812
+
813
+ // v4.5.x — Catches render-time errors in the active view so a single bad
814
+ // component doesn't bring down the whole dashboard (topbar, sidebar, WS).
815
+ // Falls back to a friendly error card with a Retry button that re-mounts
816
+ // the wrapped subtree by remounting the boundary via React `key` bumps.
817
+ class ViewErrorBoundary extends Component<
818
+ { children: React.ReactNode },
819
+ { err: Error | null }
820
+ > {
821
+ state: { err: Error | null } = { err: null };
822
+ static getDerivedStateFromError(err: Error): { err: Error | null } {
823
+ return { err };
824
+ }
825
+ componentDidCatch(err: Error, info: { componentStack?: string }) {
826
+ // eslint-disable-next-line no-console
827
+ console.error('[ViewErrorBoundary] caught render error:', err, info?.componentStack);
828
+ }
829
+ reset = () => this.setState({ err: null });
830
+ override render() {
831
+ if (this.state.err) {
832
+ return (
833
+ <div className="view-error-fallback" role="alert">
834
+ <AlertTriangle size={20} />
835
+ <div className="view-error-body">
836
+ <strong>This view crashed.</strong>
837
+ <pre>{this.state.err.message}</pre>
838
+ <Button variant="secondary" size="sm" onClick={this.reset}>Retry</Button>
839
+ </div>
840
+ </div>
841
+ );
842
+ }
843
+ return this.props.children;
844
+ }
845
+ }
@@ -128,8 +128,8 @@ export function SearchModal({ open, onClose, onSelect }: Props) {
128
128
  return (
129
129
  <div className="search-modal-backdrop" onClick={onClose}>
130
130
  <div className="search-modal" role="dialog" aria-modal="true" aria-labelledby="search-modal-title" onClick={(e) => e.stopPropagation()}>
131
- <div className="search-modal-head">
132
- <Search size={14} />
131
+ <div className="search-modal-head" role="search">
132
+ <Search size={14} aria-hidden />
133
133
  <span id="search-modal-title" className="sr-only">Search</span>
134
134
  <input
135
135
  ref={inputRef}
@@ -138,6 +138,7 @@ export function SearchModal({ open, onClose, onSelect }: Props) {
138
138
  value={q}
139
139
  onChange={(e) => setQ(e.target.value)}
140
140
  onKeyDown={onKey}
141
+ aria-label="Search"
141
142
  />
142
143
  <button
143
144
  type="button"
@@ -134,7 +134,7 @@ function ToastItem({
134
134
  }) {
135
135
  const Icon = ICONS[toast.kind];
136
136
  return (
137
- <div className={`toast toast-${toast.kind}`} role="status">
137
+ <div className={`toast toast-${toast.kind}`} role="alert" aria-live="assertive" aria-atomic="true">
138
138
  <Icon size={16} className="toast-icon" />
139
139
  <span className="toast-message">{toast.message}</span>
140
140
  <button
@@ -139,7 +139,7 @@ export function Topbar({
139
139
  </div>
140
140
  </div>
141
141
  {showTabs && (
142
- <nav className="tabs-row" role="tablist">
142
+ <nav className="tabs-row" role="tablist" aria-label="Primary tabs">
143
143
  {TABS.map((tab) => {
144
144
  const Icon = tab.icon;
145
145
  const active = tab.id === activeTab;
@@ -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
+ }
@@ -135,8 +135,8 @@ class ApiClient {
135
135
  * @example
136
136
  * const r = await api.get<ProjectList>('/projects');
137
137
  */
138
- async get<T>(path: string): Promise<T> {
139
- return this.req<T>('GET', path);
138
+ async get<T>(path: string, signal?: AbortSignal): Promise<T> {
139
+ return this.req<T>('GET', path, undefined, signal);
140
140
  }
141
141
 
142
142
  /**
@@ -145,8 +145,8 @@ class ApiClient {
145
145
  * IMPORTANT: do NOT include the `/api` prefix in `path` — the
146
146
  * wrapper adds it. Use the unprefixed form: `api.post('/fs/mkdir', body)`.
147
147
  */
148
- async post<T>(path: string, body?: unknown): Promise<T> {
149
- return this.req<T>('POST', path, body);
148
+ async post<T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
149
+ return this.req<T>('POST', path, body, signal);
150
150
  }
151
151
 
152
152
  /**
@@ -155,8 +155,8 @@ class ApiClient {
155
155
  * IMPORTANT: do NOT include the `/api` prefix in `path` — the
156
156
  * wrapper adds it. Use the unprefixed form: `api.put('/settings', body)`.
157
157
  */
158
- async put<T>(path: string, body?: unknown): Promise<T> {
159
- return this.req<T>('PUT', path, body);
158
+ async put<T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
159
+ return this.req<T>('PUT', path, body, signal);
160
160
  }
161
161
 
162
162
  /**
@@ -165,8 +165,8 @@ class ApiClient {
165
165
  * IMPORTANT: do NOT include the `/api` prefix in `path` — the
166
166
  * wrapper adds it. Use the unprefixed form: `api.patch('/settings', body)`.
167
167
  */
168
- async patch<T>(path: string, body?: unknown): Promise<T> {
169
- return this.req<T>('PATCH', path, body);
168
+ async patch<T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
169
+ return this.req<T>('PATCH', path, body, signal);
170
170
  }
171
171
 
172
172
  /**
@@ -175,11 +175,11 @@ class ApiClient {
175
175
  * IMPORTANT: do NOT include the `/api` prefix in `path` — the
176
176
  * wrapper adds it. Use the unprefixed form: `api.del('/projects/123')`.
177
177
  */
178
- async del<T = unknown>(path: string): Promise<T> {
179
- return this.req<T>('DELETE', path);
178
+ async del<T = unknown>(path: string, signal?: AbortSignal): Promise<T> {
179
+ return this.req<T>('DELETE', path, undefined, signal);
180
180
  }
181
181
 
182
- private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
182
+ private async req<T>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
183
183
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
184
184
  const tok = this.getToken();
185
185
  if (tok) headers.Authorization = `Bearer ${tok}`;
@@ -187,6 +187,7 @@ class ApiClient {
187
187
  if (body !== undefined && body !== null) {
188
188
  opts.body = typeof body === 'string' ? body : JSON.stringify(body);
189
189
  }
190
+ if (signal) opts.signal = signal;
190
191
  const r = await fetch(this.base + path, opts);
191
192
  const ct = r.headers.get('content-type') || '';
192
193
  if (ct.includes('application/json')) {
@@ -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';
@@ -1376,6 +1376,8 @@
1376
1376
  border-radius: var(--radius-lg); padding: 0.875rem 1.125rem;
1377
1377
  transition: border-color var(--motion-base) var(--motion-ease);
1378
1378
  max-width: 880px; width: 100%; align-self: flex-start;
1379
+ content-visibility: auto;
1380
+ contain-intrinsic-size: 0 80px;
1379
1381
  }
1380
1382
  .chat-message-user { background: var(--bg-2); }
1381
1383
  .chat-message-pinned { border-left: 2px solid var(--accent); }
@@ -129,10 +129,10 @@ pre,
129
129
  --accent-glow: rgba(139, 92, 246, 0.18);
130
130
  --accent-soft: rgba(139, 92, 246, 0.08);
131
131
 
132
- --success: #34d399;
133
- --warning: #fbbf24;
134
- --error: #f87171;
135
- --info: #60a5fa;
132
+ --success: oklch(0.72 0.16 145);
133
+ --warning: oklch(0.78 0.14 70);
134
+ --error: oklch(0.66 0.20 25);
135
+ --info: oklch(0.72 0.13 235);
136
136
  --success-soft: rgba(52, 211, 153, 0.15);
137
137
  --error-soft: rgba(248, 113, 113, 0.12);
138
138
  --warning-soft: rgba(251, 191, 36, 0.15);
@@ -155,7 +155,7 @@ pre,
155
155
  'Inter var', 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto,
156
156
  sans-serif;
157
157
 
158
- /* Spacing scale (8pt) */
158
+ /* Spacing scale (8pt + extra for spacious layouts) */
159
159
  --space-1: 4px;
160
160
  --space-2: 8px;
161
161
  --space-3: 12px;
@@ -165,6 +165,9 @@ pre,
165
165
  --space-8: 32px;
166
166
  --space-10: 40px;
167
167
  --space-12: 48px;
168
+ --space-16: 64px;
169
+ --space-20: 80px;
170
+ --space-24: 96px;
168
171
 
169
172
  /* v4.6.0 — Semantic spacing aliases. These mirror the existing
170
173
  --space-* numeric scale so existing rules stay untouched, and
@@ -179,10 +182,10 @@ pre,
179
182
  --spacing-xl: var(--space-6); /* 24px — page-level padding */
180
183
 
181
184
  /* Radius */
182
- --radius-sm: 4px;
185
+ --radius-sm: 6px;
183
186
  --radius: 8px;
184
187
  --radius-md: 10px;
185
- --radius-lg: 12px;
188
+ --radius-lg: 14px;
186
189
  --radius-xl: 16px;
187
190
 
188
191
  /* Shadows */
@@ -197,28 +200,15 @@ pre,
197
200
 
198
201
  /* Motion */
199
202
  --motion-fast: 120ms;
200
- --motion-base: 180ms;
201
- --motion-slow: 280ms;
203
+ --motion-base: 200ms;
204
+ --motion-slow: 320ms;
202
205
  --ease: cubic-bezier(0.4, 0, 0.2, 1);
206
+ --motion-ease: cubic-bezier(0.2, 0, 0, 1);
203
207
 
204
208
  /* v3.21.x — Semantic and surface tokens for chat UI overhaul */
205
209
  --surface-0: var(--bg);
206
210
  --surface-1: color-mix(in oklab, var(--bg) 92%, var(--text) 8%);
207
211
  --surface-2: color-mix(in oklab, var(--bg) 84%, var(--text) 16%);
208
- --success: oklch(0.72 0.16 145);
209
- --warning: oklch(0.78 0.14 70);
210
- --error: oklch(0.66 0.20 25);
211
- --info: oklch(0.72 0.13 235);
212
- --radius-sm: 6px;
213
- --radius-md: 10px;
214
- --radius-lg: 14px;
215
- --motion-fast: 120ms;
216
- --motion-base: 200ms;
217
- --motion-slow: 320ms;
218
- --motion-ease: cubic-bezier(0.2, 0, 0, 1);
219
- --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
220
- --space-5: 20px; --space-6: 24px; --space-8: 32px; --space-10: 40px;
221
- --space-12: 48px; --space-16: 64px; --space-20: 80px; --space-24: 96px;
222
212
 
223
213
  /* v3.21.x — Gemini-inspired surface tokens for chat UI overhaul.
224
214
  --bg-1, --bg-2 are NEW names that don't conflict with --bg / --bg-elev. */
@@ -429,6 +419,41 @@ pre,
429
419
  font-size: 12px;
430
420
  }
431
421
 
422
+ /* v4.5.x — Per-view render-error fallback shown by App's ErrorBoundary.
423
+ Different from .boot-error (which fires before any view mounts). */
424
+ .view-error-fallback {
425
+ max-width: 560px;
426
+ margin: var(--space-12) auto;
427
+ padding: var(--space-6);
428
+ background: var(--bg-elev);
429
+ border: 1px solid var(--error);
430
+ border-radius: var(--radius-lg);
431
+ box-shadow: var(--shadow-2);
432
+ display: flex;
433
+ gap: var(--space-4);
434
+ align-items: flex-start;
435
+ }
436
+ .view-error-fallback > svg {
437
+ color: var(--error);
438
+ flex-shrink: 0;
439
+ margin-top: 2px;
440
+ }
441
+ .view-error-fallback .view-error-body {
442
+ display: flex;
443
+ flex-direction: column;
444
+ gap: var(--space-2);
445
+ min-width: 0;
446
+ }
447
+ .view-error-fallback pre {
448
+ background: var(--bg);
449
+ padding: var(--space-2) var(--space-3);
450
+ border-radius: var(--radius-sm);
451
+ font-size: 12px;
452
+ white-space: pre-wrap;
453
+ word-break: break-word;
454
+ margin: 0;
455
+ }
456
+
432
457
  /* v3.6.2 — Token-entry form on boot-error screen */
433
458
  .token-entry-form {
434
459
  margin: var(--space-4) 0;
@@ -1438,6 +1463,8 @@ pre,
1438
1463
  font-family: var(--font-mono);
1439
1464
  border-left: 2px solid var(--border);
1440
1465
  align-items: baseline;
1466
+ content-visibility: auto;
1467
+ contain-intrinsic-size: 0 80px;
1441
1468
  }
1442
1469
 
1443
1470
  .activity-item:hover {
@@ -3207,6 +3234,8 @@ pre,
3207
3234
  box-shadow var(--motion-base) var(--ease),
3208
3235
  transform var(--motion-fast) var(--ease),
3209
3236
  background var(--motion-base) var(--ease);
3237
+ content-visibility: auto;
3238
+ contain-intrinsic-size: 0 80px;
3210
3239
  }
3211
3240
  .task-card::before {
3212
3241
  content: '';
@@ -162,6 +162,8 @@
162
162
  cursor: grab;
163
163
  transition: transform var(--motion-fast) var(--ease),
164
164
  border-color var(--motion-base) var(--ease);
165
+ content-visibility: auto;
166
+ contain-intrinsic-size: 0 80px;
165
167
  }
166
168
 
167
169
  .task-card:hover {
@@ -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>