@polderlabs/bizar 4.5.2 → 4.7.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 (123) hide show
  1. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
  2. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
  4. package/bizar-dash/dist/assets/{mobile-lbH6szyX.js → mobile-BK8-ythT.js} +18 -18
  5. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
  6. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js → mobile-Chvf9u_B.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js.map → mobile-Chvf9u_B.js.map} +1 -1
  8. package/bizar-dash/dist/index.html +3 -3
  9. package/bizar-dash/dist/mobile.html +2 -2
  10. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  11. package/bizar-dash/skills/publishing/SKILL.md +146 -0
  12. package/bizar-dash/src/server/api.mjs +8 -0
  13. package/bizar-dash/src/server/backup-store.mjs +525 -0
  14. package/bizar-dash/src/server/digest-store.mjs +558 -0
  15. package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
  16. package/bizar-dash/src/server/logger.mjs +71 -0
  17. package/bizar-dash/src/server/memory-lightrag.mjs +45 -6
  18. package/bizar-dash/src/server/metrics.mjs +193 -0
  19. package/bizar-dash/src/server/routes/backup.mjs +112 -0
  20. package/bizar-dash/src/server/routes/chat.mjs +20 -3
  21. package/bizar-dash/src/server/routes/digests.mjs +82 -0
  22. package/bizar-dash/src/server/routes/lightrag.mjs +3 -2
  23. package/bizar-dash/src/server/routes/memory.mjs +5 -4
  24. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  25. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  26. package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
  27. package/bizar-dash/src/server/schedules-runner.mjs +126 -0
  28. package/bizar-dash/src/server/server.mjs +79 -0
  29. package/bizar-dash/src/web/App.tsx +8 -1
  30. package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
  31. package/bizar-dash/src/web/components/SearchModal.tsx +6 -3
  32. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  33. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  34. package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
  35. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  36. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  37. package/bizar-dash/src/web/locales/en.json +52 -0
  38. package/bizar-dash/src/web/main.tsx +5 -0
  39. package/bizar-dash/src/web/styles/main.css +70 -8
  40. package/bizar-dash/src/web/views/Activity.tsx +35 -18
  41. package/bizar-dash/src/web/views/Agents.tsx +57 -42
  42. package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
  43. package/bizar-dash/src/web/views/Chat.tsx +8 -0
  44. package/bizar-dash/src/web/views/History.tsx +94 -76
  45. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
  46. package/bizar-dash/src/web/views/Mods.tsx +30 -17
  47. package/bizar-dash/src/web/views/Overview.tsx +19 -9
  48. package/bizar-dash/src/web/views/Providers.tsx +16 -16
  49. package/bizar-dash/src/web/views/Schedules.tsx +33 -15
  50. package/bizar-dash/src/web/views/Settings.tsx +97 -1745
  51. package/bizar-dash/src/web/views/Skills.tsx +4 -1
  52. package/bizar-dash/src/web/views/Tasks.tsx +11 -2
  53. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
  54. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
  55. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
  56. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
  57. package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
  58. package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
  59. package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
  60. package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
  61. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
  62. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
  63. package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
  64. package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
  65. package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
  66. package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
  67. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
  68. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
  69. package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
  70. package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
  71. package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
  72. package/bizar-dash/tests/a11y.test.tsx +206 -0
  73. package/bizar-dash/tests/backup-restore.test.mjs +217 -0
  74. package/bizar-dash/tests/backup-restore.test.tsx +123 -0
  75. package/bizar-dash/tests/backup-store.test.mjs +300 -0
  76. package/bizar-dash/tests/cli-bugfixes.test.mjs +4 -4
  77. package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
  78. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  79. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  80. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  81. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  82. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  83. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  84. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  85. package/bizar-dash/tests/digest-generation.test.mjs +191 -0
  86. package/bizar-dash/tests/digest-store.test.mjs +264 -0
  87. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  88. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  89. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  90. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  91. package/bizar-dash/tests/logger.test.mjs +207 -0
  92. package/bizar-dash/tests/metrics.test.mjs +183 -0
  93. package/bizar-dash/tests/rate-limit.test.mjs +298 -0
  94. package/bizar-dash/tests/server-bugfixes.test.mjs +2 -2
  95. package/bizar-dash/tests/setup.ts +7 -0
  96. package/bizar-dash/vitest.config.ts +13 -0
  97. package/cli/artifact-cli.mjs +605 -0
  98. package/cli/artifact-render.mjs +621 -0
  99. package/cli/artifact-server.mjs +847 -0
  100. package/cli/artifact.mjs +38 -2096
  101. package/cli/bg.mjs +5 -13
  102. package/cli/bin.mjs +221 -1350
  103. package/cli/commands/artifact.mjs +20 -0
  104. package/cli/commands/dash.mjs +160 -0
  105. package/cli/commands/headroom.mjs +204 -0
  106. package/cli/commands/install.mjs +169 -0
  107. package/cli/commands/memory.mjs +25 -0
  108. package/cli/commands/minimax.mjs +285 -0
  109. package/cli/commands/mod.mjs +185 -0
  110. package/cli/commands/service.mjs +65 -0
  111. package/cli/commands/usage.mjs +109 -0
  112. package/cli/commands/util.mjs +459 -0
  113. package/cli/digest.mjs +149 -0
  114. package/cli/doctor.mjs +1 -13
  115. package/cli/provision.mjs +2 -13
  116. package/cli/service-controller.mjs +1 -11
  117. package/cli/service.mjs +1 -11
  118. package/cli/utils.mjs +41 -1
  119. package/package.json +6 -1
  120. package/bizar-dash/dist/assets/main-B4OfGAwz.js +0 -361
  121. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +0 -1
  122. package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
  123. 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
  }
@@ -125,6 +125,8 @@ export function Composer({
125
125
  multiple
126
126
  style={{ display: 'none' }}
127
127
  onChange={onFiles}
128
+ aria-label="Attach files"
129
+ tabIndex={-1}
128
130
  />
129
131
  <button
130
132
  type="button"
@@ -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';
@@ -101,6 +101,41 @@ pre,
101
101
  border: 0;
102
102
  }
103
103
 
104
+ /* v4.8.0 — WCAG 2.2 AA: skip-to-main-content link. Visually hidden
105
+ until focused (keyboard tab), then pops in at the top-left so
106
+ users can jump past the topbar + sidebar into the active view.
107
+ Anchors must be focusable by default; we just hide them visually. */
108
+ .skip-to-main {
109
+ position: absolute;
110
+ top: -100px;
111
+ left: var(--space-2);
112
+ z-index: 9999;
113
+ padding: 10px 14px;
114
+ background: var(--accent);
115
+ color: #ffffff;
116
+ border-radius: var(--radius-sm);
117
+ font-size: 13px;
118
+ font-weight: 600;
119
+ text-decoration: none;
120
+ box-shadow: var(--shadow-2);
121
+ transition: top 120ms var(--ease);
122
+ }
123
+ .skip-to-main:focus,
124
+ .skip-to-main:focus-visible {
125
+ top: var(--space-2);
126
+ outline: 2px solid var(--accent-2);
127
+ outline-offset: 2px;
128
+ }
129
+
130
+ /* v4.8.0 — Make <main> programmatically focusable for the skip link
131
+ without a visible focus ring (it has no interactive content). */
132
+ #main-content:focus {
133
+ outline: none;
134
+ }
135
+ #main-content:focus-visible {
136
+ outline: none;
137
+ }
138
+
104
139
  /* ─── Theme tokens ─────────────────────────────────────────────────── */
105
140
 
106
141
  :root {
@@ -258,10 +293,10 @@ pre,
258
293
  --accent-glow: rgba(124, 58, 237, 0.18);
259
294
  --accent-soft: rgba(124, 58, 237, 0.06);
260
295
 
261
- --success: #059669;
262
- --warning: #d97706;
263
- --error: #dc2626;
264
- --info: #2563eb;
296
+ --success: #047857;
297
+ --warning: #b45309;
298
+ --error: #b91c1c;
299
+ --info: #1d4ed8;
265
300
  --success-soft: rgba(5, 150, 105, 0.12);
266
301
  --error-soft: rgba(220, 38, 38, 0.08);
267
302
  --warning-soft: rgba(217, 119, 6, 0.12);
@@ -281,10 +316,12 @@ pre,
281
316
  /* v3.21.x — Surface and semantic tokens (light theme overrides). */
282
317
  --surface-1: color-mix(in oklab, var(--bg) 92%, var(--text) 8%);
283
318
  --surface-2: color-mix(in oklab, var(--bg) 84%, var(--text) 16%);
284
- --success: oklch(0.62 0.16 145);
285
- --warning: oklch(0.72 0.14 70);
286
- --error: oklch(0.60 0.20 25);
287
- --info: oklch(0.62 0.13 235);
319
+ /* v4.8.0 — Darken light-theme status colors so they pass WCAG AA (4.5:1)
320
+ against the light surface (#f7f8fa). */
321
+ --success: oklch(0.48 0.14 145);
322
+ --warning: oklch(0.55 0.15 70);
323
+ --error: oklch(0.48 0.20 25);
324
+ --info: oklch(0.48 0.16 235);
288
325
  --bg-1: #f4f4f5;
289
326
  --bg-2: #e4e4e7;
290
327
 
@@ -945,6 +982,17 @@ pre,
945
982
  box-shadow: 0 0 0 3px var(--accent-bg);
946
983
  }
947
984
 
985
+ /* v4.8.0 — Placeholder color must pass AA (4.5:1) against input
986
+ backgrounds on both themes. Default browser placeholder is too
987
+ light on dark and too dark on light, so we pin to --text-dim
988
+ which is computed to ~9:1 (dark) / ~6:1 (light). */
989
+ .input::placeholder,
990
+ .textarea::placeholder,
991
+ .select::placeholder {
992
+ color: var(--text-dim);
993
+ opacity: 1;
994
+ }
995
+
948
996
  .textarea.invalid {
949
997
  border-color: var(--error);
950
998
  }
@@ -5004,6 +5052,12 @@ kbd {
5004
5052
  cursor: pointer;
5005
5053
  user-select: none;
5006
5054
  transition: background var(--motion-fast) var(--ease);
5055
+ background: transparent;
5056
+ border: 0;
5057
+ color: inherit;
5058
+ text-align: left;
5059
+ width: 100%;
5060
+ font: inherit;
5007
5061
  }
5008
5062
 
5009
5063
  .mods-registry-head:hover {
@@ -5214,6 +5268,14 @@ kbd {
5214
5268
  cursor: pointer;
5215
5269
  user-select: none;
5216
5270
  padding: 4px 0;
5271
+ background: transparent;
5272
+ border: 0;
5273
+ color: inherit;
5274
+ text-align: left;
5275
+ font: inherit;
5276
+ }
5277
+ button.mod-instructions-head {
5278
+ width: 100%;
5217
5279
  }
5218
5280
  .mod-instructions-head:hover { color: var(--accent); }
5219
5281
  .mod-instructions-spacer { flex: 1; }
@@ -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>
@@ -1102,7 +1109,9 @@ export function Activity({ snapshot, refreshSnapshot }: Props) {
1102
1109
  </Button>
1103
1110
  </div>
1104
1111
  <div className="tl-form-row">
1112
+ <label htmlFor="bg-message-input" className="sr-only">Send a message to this bg session</label>
1105
1113
  <input
1114
+ id="bg-message-input"
1106
1115
  className="input"
1107
1116
  placeholder="Send a message to this bg session…"
1108
1117
  value={bgMessage}
@@ -1110,6 +1119,7 @@ export function Activity({ snapshot, refreshSnapshot }: Props) {
1110
1119
  onKeyDown={(e) => {
1111
1120
  if (e.key === 'Enter') onSendBgMessage();
1112
1121
  }}
1122
+ aria-label="Send a message to this bg session"
1113
1123
  />
1114
1124
  <Button variant="primary" size="sm" disabled={!bgMessage.trim()} onClick={onSendBgMessage}>
1115
1125
  <Send size={12} /> Send
@@ -1120,18 +1130,22 @@ export function Activity({ snapshot, refreshSnapshot }: Props) {
1120
1130
 
1121
1131
  {selectedItem.kind !== 'bg' && (
1122
1132
  <div className="tl-detail-create">
1123
- <div className="field-label">Create follow-up task</div>
1133
+ <label className="field-label" htmlFor="followup-task-title">Create follow-up task</label>
1124
1134
  <input
1135
+ id="followup-task-title"
1125
1136
  className="input"
1126
1137
  placeholder="Task title"
1127
1138
  value={taskTitle}
1128
1139
  onChange={(e) => setTaskTitle(e.target.value)}
1129
1140
  />
1130
1141
  <div className="tl-form-row">
1142
+ <label htmlFor="followup-task-priority" className="sr-only">Task priority</label>
1131
1143
  <select
1144
+ id="followup-task-priority"
1132
1145
  className="select"
1133
1146
  value={taskPriority}
1134
1147
  onChange={(e) => setTaskPriority(e.target.value)}
1148
+ aria-label="Task priority"
1135
1149
  >
1136
1150
  <option value="low">Low</option>
1137
1151
  <option value="normal">Normal</option>
@@ -1173,7 +1187,9 @@ export function Activity({ snapshot, refreshSnapshot }: Props) {
1173
1187
  ))}
1174
1188
  </ul>
1175
1189
  <div className="comment-input-row">
1190
+ <label htmlFor="comment-input" className="sr-only">Add a comment</label>
1176
1191
  <input
1192
+ id="comment-input"
1177
1193
  className="input"
1178
1194
  placeholder="Add a comment…"
1179
1195
  value={commentText}
@@ -1181,6 +1197,7 @@ export function Activity({ snapshot, refreshSnapshot }: Props) {
1181
1197
  onKeyDown={(e) => {
1182
1198
  if (e.key === 'Enter' && !e.shiftKey) onAddComment();
1183
1199
  }}
1200
+ aria-label="Add a comment"
1184
1201
  />
1185
1202
  <Button
1186
1203
  variant="secondary"
@@ -144,16 +144,18 @@ export function Agents({ snapshot, refreshSnapshot }: Props) {
144
144
  width: 640,
145
145
  children: (
146
146
  <div className="agent-form">
147
- <label className="field-label">Name (a-z, 0-9, dashes)</label>
147
+ <label className="field-label" htmlFor="agent-new-name">Name (a-z, 0-9, dashes)</label>
148
148
  <input
149
+ id="agent-new-name"
149
150
  ref={(el) => (nameEl = el)}
150
151
  className="input"
151
152
  type="text"
152
153
  placeholder="my-agent"
153
154
  autoFocus
154
155
  />
155
- <label className="field-label">Description</label>
156
+ <label className="field-label" htmlFor="agent-new-desc">Description</label>
156
157
  <input
158
+ id="agent-new-desc"
157
159
  ref={(el) => { descEl = el; }}
158
160
  className="input"
159
161
  type="text"
@@ -161,8 +163,8 @@ export function Agents({ snapshot, refreshSnapshot }: Props) {
161
163
  />
162
164
  <div className="task-form-row">
163
165
  <div className="task-form-field">
164
- <label className="field-label">Model</label>
165
- <select ref={(el) => (modelEl = el)} className="select" defaultValue="">
166
+ <label className="field-label" htmlFor="agent-new-model">Model</label>
167
+ <select id="agent-new-model" ref={(el) => (modelEl = el)} className="select" defaultValue="">
166
168
  <option value="">(provider default)</option>
167
169
  {MODELS.map((m) => (
168
170
  <option key={m} value={m}>{m}</option>
@@ -170,22 +172,22 @@ export function Agents({ snapshot, refreshSnapshot }: Props) {
170
172
  </select>
171
173
  </div>
172
174
  <div className="task-form-field">
173
- <label className="field-label">Mode</label>
174
- <select ref={(el) => (modeEl = el)} className="select" defaultValue="subagent">
175
+ <label className="field-label" htmlFor="agent-new-mode">Mode</label>
176
+ <select id="agent-new-mode" ref={(el) => (modeEl = el)} className="select" defaultValue="subagent">
175
177
  <option value="primary">primary</option>
176
178
  <option value="subagent">subagent</option>
177
179
  <option value="all">all</option>
178
180
  </select>
179
181
  </div>
180
182
  <div className="task-form-field" style={{ flex: '0 0 80px' }}>
181
- <label className="field-label">Color</label>
182
- <input ref={(el) => (colorEl = el)} className="input" type="color" defaultValue="#8b5cf6" />
183
+ <label className="field-label" htmlFor="agent-new-color">Color</label>
184
+ <input id="agent-new-color" ref={(el) => (colorEl = el)} className="input" type="color" defaultValue="#8b5cf6" />
183
185
  </div>
184
186
  </div>
185
187
  <div className="task-form-row">
186
188
  <div className="task-form-field" style={{ flex: 1 }}>
187
- <label className="field-label">Category</label>
188
- <select ref={(el) => (categoryEl = el)} className="select" defaultValue="">
189
+ <label className="field-label" htmlFor="agent-new-category">Category</label>
190
+ <select id="agent-new-category" ref={(el) => (categoryEl = el)} className="select" defaultValue="">
189
191
  <option value="">(none)</option>
190
192
  {CATEGORIES.map((c) => (
191
193
  <option key={c.id} value={c.id}>{c.label}</option>
@@ -193,21 +195,24 @@ export function Agents({ snapshot, refreshSnapshot }: Props) {
193
195
  </select>
194
196
  </div>
195
197
  <div className="task-form-field" style={{ flex: 2 }}>
196
- <label className="field-label">Tags (comma-separated)</label>
197
- <input ref={(el) => (tagsEl = el)} className="input" type="text" placeholder="reasoning, code, planning" />
198
+ <label className="field-label" htmlFor="agent-new-tags">Tags (comma-separated)</label>
199
+ <input id="agent-new-tags" ref={(el) => (tagsEl = el)} className="input" type="text" placeholder="reasoning, code, planning" />
198
200
  </div>
199
201
  </div>
200
- <label className="field-label">Tools</label>
201
- <div ref={(el) => (toolsContainer = el)} className="agent-tools">
202
- {TOOL_OPTIONS.map((t) => (
203
- <label key={t} className="checkbox-row">
204
- <input type="checkbox" value={t} />
205
- <span>{t}</span>
206
- </label>
207
- ))}
208
- </div>
209
- <label className="field-label">System prompt</label>
202
+ <fieldset>
203
+ <legend className="field-label" style={{ padding: 0 }}>Tools</legend>
204
+ <div ref={(el) => (toolsContainer = el)} className="agent-tools">
205
+ {TOOL_OPTIONS.map((t) => (
206
+ <label key={t} className="checkbox-row">
207
+ <input type="checkbox" value={t} aria-label={t} />
208
+ <span>{t}</span>
209
+ </label>
210
+ ))}
211
+ </div>
212
+ </fieldset>
213
+ <label className="field-label" htmlFor="agent-new-prompt">System prompt</label>
210
214
  <textarea
215
+ id="agent-new-prompt"
211
216
  ref={(el) => (promptEl = el)}
212
217
  className="textarea"
213
218
  rows={6}
@@ -280,8 +285,9 @@ export function Agents({ snapshot, refreshSnapshot }: Props) {
280
285
  <div className="muted">
281
286
  File: <code>{full.path}</code>
282
287
  </div>
283
- <label className="field-label">Description</label>
288
+ <label className="field-label" htmlFor="agent-edit-desc">Description</label>
284
289
  <input
290
+ id="agent-edit-desc"
285
291
  ref={(el) => { descEl = el; }}
286
292
  className="input"
287
293
  type="text"
@@ -289,8 +295,8 @@ export function Agents({ snapshot, refreshSnapshot }: Props) {
289
295
  />
290
296
  <div className="task-form-row">
291
297
  <div className="task-form-field">
292
- <label className="field-label">Model</label>
293
- <select ref={(el) => (modelEl = el)} className="select" defaultValue={full.model}>
298
+ <label className="field-label" htmlFor="agent-edit-model">Model</label>
299
+ <select id="agent-edit-model" ref={(el) => (modelEl = el)} className="select" defaultValue={full.model}>
294
300
  <option value="">(provider default)</option>
295
301
  {MODELS.map((m) => (
296
302
  <option key={m} value={m}>{m}</option>
@@ -298,22 +304,22 @@ export function Agents({ snapshot, refreshSnapshot }: Props) {
298
304
  </select>
299
305
  </div>
300
306
  <div className="task-form-field">
301
- <label className="field-label">Mode</label>
302
- <select ref={(el) => (modeEl = el)} className="select" defaultValue={full.mode || 'subagent'}>
307
+ <label className="field-label" htmlFor="agent-edit-mode">Mode</label>
308
+ <select id="agent-edit-mode" ref={(el) => (modeEl = el)} className="select" defaultValue={full.mode || 'subagent'}>
303
309
  <option value="primary">primary</option>
304
310
  <option value="subagent">subagent</option>
305
311
  <option value="all">all</option>
306
312
  </select>
307
313
  </div>
308
314
  <div className="task-form-field" style={{ flex: '0 0 80px' }}>
309
- <label className="field-label">Color</label>
310
- <input ref={(el) => (colorEl = el)} className="input" type="color" defaultValue={full.color || '#8b5cf6'} />
315
+ <label className="field-label" htmlFor="agent-edit-color">Color</label>
316
+ <input id="agent-edit-color" ref={(el) => (colorEl = el)} className="input" type="color" defaultValue={full.color || '#8b5cf6'} />
311
317
  </div>
312
318
  </div>
313
319
  <div className="task-form-row">
314
320
  <div className="task-form-field" style={{ flex: 1 }}>
315
- <label className="field-label">Category</label>
316
- <select ref={(el) => (categoryEl = el)} className="select" defaultValue={full.category || ''}>
321
+ <label className="field-label" htmlFor="agent-edit-category">Category</label>
322
+ <select id="agent-edit-category" ref={(el) => (categoryEl = el)} className="select" defaultValue={full.category || ''}>
317
323
  <option value="">(none)</option>
318
324
  {CATEGORIES.map((c) => (
319
325
  <option key={c.id} value={c.id}>{c.label}</option>
@@ -321,8 +327,9 @@ export function Agents({ snapshot, refreshSnapshot }: Props) {
321
327
  </select>
322
328
  </div>
323
329
  <div className="task-form-field" style={{ flex: 2 }}>
324
- <label className="field-label">Tags (comma-separated)</label>
330
+ <label className="field-label" htmlFor="agent-edit-tags">Tags (comma-separated)</label>
325
331
  <input
332
+ id="agent-edit-tags"
326
333
  ref={(el) => (tagsEl = el)}
327
334
  className="input"
328
335
  type="text"
@@ -330,17 +337,20 @@ export function Agents({ snapshot, refreshSnapshot }: Props) {
330
337
  />
331
338
  </div>
332
339
  </div>
333
- <label className="field-label">Tools</label>
334
- <div ref={(el) => (toolsContainer = el)} className="agent-tools">
335
- {TOOL_OPTIONS.map((t) => (
336
- <label key={t} className="checkbox-row">
337
- <input type="checkbox" value={t} defaultChecked={full.tools?.includes(t)} />
338
- <span>{t}</span>
339
- </label>
340
- ))}
341
- </div>
342
- <label className="field-label">System prompt</label>
340
+ <fieldset>
341
+ <legend className="field-label" style={{ padding: 0 }}>Tools</legend>
342
+ <div ref={(el) => (toolsContainer = el)} className="agent-tools">
343
+ {TOOL_OPTIONS.map((t) => (
344
+ <label key={t} className="checkbox-row">
345
+ <input type="checkbox" value={t} aria-label={t} defaultChecked={full.tools?.includes(t)} />
346
+ <span>{t}</span>
347
+ </label>
348
+ ))}
349
+ </div>
350
+ </fieldset>
351
+ <label className="field-label" htmlFor="agent-edit-prompt">System prompt</label>
343
352
  <textarea
353
+ id="agent-edit-prompt"
344
354
  ref={(el) => (promptEl = el)}
345
355
  className="textarea"
346
356
  rows={8}
@@ -483,15 +493,20 @@ export function Agents({ snapshot, refreshSnapshot }: Props) {
483
493
  </div>
484
494
  <div className="view-actions">
485
495
  <div className="search-input">
496
+ <label htmlFor="agents-search" className="sr-only">Search agents</label>
486
497
  <input
498
+ id="agents-search"
487
499
  className="input"
488
500
  type="text"
489
501
  placeholder="Search…"
490
502
  value={search}
491
503
  onChange={(e) => setSearch(e.target.value)}
504
+ aria-label="Search agents"
492
505
  />
493
506
  </div>
507
+ <label htmlFor="agents-category-filter" className="sr-only">Filter by category</label>
494
508
  <select
509
+ id="agents-category-filter"
495
510
  className="select select-sm"
496
511
  value={categoryFilter}
497
512
  onChange={(e) => setCategoryFilter(e.target.value)}