@polderlabs/bizar 5.3.1 → 5.4.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 (65) hide show
  1. package/bizar-dash/dist/assets/EnvVarsSection-DhLHZ5ua.js +4 -0
  2. package/bizar-dash/dist/assets/EnvVarsSection-DhLHZ5ua.js.map +1 -0
  3. package/bizar-dash/dist/assets/MobileChat-9UpvDP6-.js +1 -0
  4. package/bizar-dash/dist/assets/{MobileChat-OidrxXR9.js.map → MobileChat-9UpvDP6-.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-BFmN9ZWR.js +1 -0
  6. package/bizar-dash/dist/assets/MobileSettings-BFmN9ZWR.js.map +1 -0
  7. package/bizar-dash/dist/assets/Toast-BlcKl_dN.js +1 -0
  8. package/bizar-dash/dist/assets/Toast-BlcKl_dN.js.map +1 -0
  9. package/bizar-dash/dist/assets/{icons-Bd8piSb2.js → icons-CMPxNV8t.js} +73 -73
  10. package/bizar-dash/dist/assets/icons-CMPxNV8t.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-Dhrvp9Gt.js +16 -0
  12. package/bizar-dash/dist/assets/main-Dhrvp9Gt.js.map +1 -0
  13. package/bizar-dash/dist/assets/{main-3G6mOhiK.css → main-O2tZVdHm.css} +1 -1
  14. package/bizar-dash/dist/assets/mobile-DDY1lquG.js +1 -0
  15. package/bizar-dash/dist/assets/mobile-DDY1lquG.js.map +1 -0
  16. package/bizar-dash/dist/assets/mobile-layout-BjkoHh1W.js +2 -0
  17. package/bizar-dash/dist/assets/mobile-layout-BjkoHh1W.js.map +1 -0
  18. package/bizar-dash/dist/assets/{mobile-CsZQAswA.css → mobile-layout-CJnZNLy3.css} +1 -1
  19. package/bizar-dash/dist/assets/useSlashCommands-eFCiWHVt.js +2 -0
  20. package/bizar-dash/dist/assets/useSlashCommands-eFCiWHVt.js.map +1 -0
  21. package/bizar-dash/dist/index.html +8 -6
  22. package/bizar-dash/dist/mobile.html +4 -4
  23. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/bizar-dash/src/web/MobileApp.tsx +18 -20
  25. package/bizar-dash/src/web/components/MobileBottomNav.tsx +38 -0
  26. package/bizar-dash/src/web/components/MobileDrawer.tsx +68 -0
  27. package/bizar-dash/src/web/components/MobileHeader.tsx +50 -0
  28. package/bizar-dash/src/web/components/MobileLayout.tsx +63 -0
  29. package/bizar-dash/src/web/components/TaskCard.tsx +69 -0
  30. package/bizar-dash/src/web/components/TaskCreateSheet.tsx +76 -0
  31. package/bizar-dash/src/web/main.tsx +1 -0
  32. package/bizar-dash/src/web/mobile/MobileDoctor.tsx +80 -0
  33. package/bizar-dash/src/web/mobile/MobileEval.tsx +167 -0
  34. package/bizar-dash/src/web/mobile/MobileMarketplace.tsx +106 -0
  35. package/bizar-dash/src/web/mobile/MobileMemory.tsx +108 -0
  36. package/bizar-dash/src/web/mobile/MobileOverview.tsx +213 -0
  37. package/bizar-dash/src/web/mobile/MobilePlugins.tsx +71 -0
  38. package/bizar-dash/src/web/mobile/MobileSettings.tsx +235 -180
  39. package/bizar-dash/src/web/mobile/MobileTasks.tsx +94 -0
  40. package/bizar-dash/src/web/mobile.tsx +1 -0
  41. package/bizar-dash/src/web/styles/main.css +388 -0
  42. package/bizar-dash/src/web/styles/memory.css +45 -0
  43. package/bizar-dash/src/web/styles/mobile-layout.css +233 -0
  44. package/bizar-dash/src/web/styles/mobile.css +180 -0
  45. package/bizar-dash/src/web/styles/settings.css +156 -0
  46. package/bizar-dash/src/web/styles/tasks.css +305 -0
  47. package/bizar-dash/tests/mobile-chat.test.tsx +82 -0
  48. package/bizar-dash/tests/mobile-layout.test.tsx +332 -0
  49. package/bizar-dash/tests/mobile-misc.test.tsx +321 -0
  50. package/bizar-dash/tests/mobile-overview.test.tsx +214 -0
  51. package/bizar-dash/tests/mobile-settings.test.tsx +181 -0
  52. package/bizar-dash/tests/mobile-tasks.test.tsx +162 -0
  53. package/package.json +1 -1
  54. package/bizar-dash/dist/assets/MobileChat-OidrxXR9.js +0 -1
  55. package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js +0 -1
  56. package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js.map +0 -1
  57. package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +0 -1
  58. package/bizar-dash/dist/assets/main-6YUZY968.js +0 -19
  59. package/bizar-dash/dist/assets/main-6YUZY968.js.map +0 -1
  60. package/bizar-dash/dist/assets/mobile-CSsp30lr.js +0 -2
  61. package/bizar-dash/dist/assets/mobile-CSsp30lr.js.map +0 -1
  62. package/bizar-dash/dist/assets/mobile-DtwK7DOV.js +0 -1
  63. package/bizar-dash/dist/assets/mobile-DtwK7DOV.js.map +0 -1
  64. package/bizar-dash/dist/assets/useSlashCommands-DEkxEvOO.js +0 -2
  65. package/bizar-dash/dist/assets/useSlashCommands-DEkxEvOO.js.map +0 -1
@@ -13,7 +13,7 @@ import type { Notification, Settings, Snapshot, WsMessage } from './lib/types';
13
13
  import { applyTheme, applyThemeTokens } from './lib/types';
14
14
  import { Ws } from './lib/ws';
15
15
  import { MobileBottomNav, type MobileTab } from './mobile/MobileBottomNav';
16
- import { MobileTopbar } from './mobile/MobileTopbar';
16
+ import { MobileLayout, type DrawerTab } from './components/MobileLayout';
17
17
  import { MobileActivity } from './mobile/views/MobileActivity';
18
18
  import { MobileAgents } from './mobile/views/MobileAgents';
19
19
  import { MobileConfig } from './mobile/views/MobileConfig';
@@ -75,6 +75,7 @@ export function MobileApp() {
75
75
  const [activeTab, setActiveTab] = useState<MainTabId>('activity');
76
76
  const [stack, setStack] = useState<MobileView[]>([]);
77
77
  const [searchOpen, setSearchOpen] = useState(false);
78
+ const [drawerOpen, setDrawerOpen] = useState(false);
78
79
  const [pendingChatTaskId, setPendingChatTaskId] = useState<string | null>(null);
79
80
  const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
80
81
  const [settings, setSettings] = useState<Settings | null>(null);
@@ -390,27 +391,24 @@ export function MobileApp() {
390
391
  }
391
392
 
392
393
  return (
393
- <div className="mobile-app">
394
- <MobileTopbar
395
- activeTab={currentView.id}
396
- snapshot={snapshot}
394
+ <>
395
+ <MobileLayout
396
+ activeTab={activeTab}
397
+ onTabChange={handleTabChange}
398
+ tabs={MAIN_TABS}
399
+ fullTabs={MAIN_TABS as DrawerTab[]}
400
+ onExitSettings={() => goToTab('activity')}
397
401
  onSearch={() => setSearchOpen(true)}
398
- onNavigate={handleNavigate}
399
- />
400
-
401
- <main className="mobile-content">{renderView()}</main>
402
-
403
- {stack.length === 0 && (
404
- <MobileBottomNav tabs={MAIN_TABS} activeTab={activeTab} onChange={handleTabChange} />
405
- )}
406
-
407
- {stack.length > 0 && (
408
- <button type="button" className="mobile-back-btn" onClick={popView} aria-label="Go back">
409
- ← Back
410
- </button>
411
- )}
402
+ >
403
+ {stack.length > 0 && (
404
+ <button type="button" className="mobile-back-btn" onClick={popView} aria-label="Go back">
405
+ Back
406
+ </button>
407
+ )}
408
+ {renderView()}
409
+ </MobileLayout>
412
410
 
413
411
  <MobileSearchModal open={searchOpen} onClose={() => setSearchOpen(false)} onNavigate={handleNavigate} />
414
- </div>
412
+ </>
415
413
  );
416
414
  }
@@ -0,0 +1,38 @@
1
+ // src/web/components/MobileBottomNav.tsx — iOS/Android-style bottom tab bar.
2
+ import type { LucideIcon } from 'lucide-react';
3
+ import { cn } from '../lib/utils';
4
+
5
+ export type MobileTab = {
6
+ id: string;
7
+ label: string;
8
+ icon: LucideIcon;
9
+ };
10
+
11
+ type Props = {
12
+ tabs: MobileTab[];
13
+ activeTab: string;
14
+ onTabChange: (id: string) => void;
15
+ };
16
+
17
+ export function MobileBottomNav({ tabs, activeTab, onTabChange }: Props) {
18
+ return (
19
+ <nav className="mobile-bottom-nav" role="tablist" aria-label="Primary navigation">
20
+ {tabs.map((t) => {
21
+ const Icon = t.icon;
22
+ return (
23
+ <button
24
+ key={t.id}
25
+ role="tab"
26
+ aria-selected={activeTab === t.id}
27
+ className={cn('mobile-nav-item', activeTab === t.id && 'is-active')}
28
+ onClick={() => onTabChange(t.id)}
29
+ type="button"
30
+ >
31
+ <Icon size={20} />
32
+ <span className="mobile-nav-label">{t.label}</span>
33
+ </button>
34
+ );
35
+ })}
36
+ </nav>
37
+ );
38
+ }
@@ -0,0 +1,68 @@
1
+ // src/web/components/MobileDrawer.tsx — slide-in drawer for secondary navigation.
2
+ import { X } from 'lucide-react';
3
+ import type { LucideIcon } from 'lucide-react';
4
+ import { cn } from '../lib/utils';
5
+
6
+ export type DrawerTab = {
7
+ id: string;
8
+ label: string;
9
+ icon: LucideIcon;
10
+ };
11
+
12
+ type Props = {
13
+ open: boolean;
14
+ onClose: () => void;
15
+ tabs: DrawerTab[];
16
+ activeTab: string;
17
+ onTabChange: (id: string) => void;
18
+ };
19
+
20
+ export function MobileDrawer({ open, onClose, tabs, activeTab, onTabChange }: Props) {
21
+ return (
22
+ <>
23
+ {open && (
24
+ <div
25
+ className="mobile-drawer-overlay"
26
+ onClick={onClose}
27
+ aria-hidden="true"
28
+ />
29
+ )}
30
+ <aside
31
+ className={cn('mobile-drawer', open && 'is-open')}
32
+ role="dialog"
33
+ aria-modal="true"
34
+ aria-label="All views"
35
+ >
36
+ <div className="mobile-drawer-head">
37
+ <h2>All Views</h2>
38
+ <button
39
+ onClick={onClose}
40
+ aria-label="Close menu"
41
+ type="button"
42
+ className="mobile-drawer-close"
43
+ >
44
+ <X size={20} />
45
+ </button>
46
+ </div>
47
+ <div className="mobile-drawer-grid">
48
+ {tabs.map((t) => {
49
+ const Icon = t.icon;
50
+ return (
51
+ <button
52
+ key={t.id}
53
+ role="tab"
54
+ aria-selected={activeTab === t.id}
55
+ className={cn('mobile-drawer-item', activeTab === t.id && 'is-active')}
56
+ onClick={() => onTabChange(t.id)}
57
+ type="button"
58
+ >
59
+ <Icon size={22} />
60
+ <span>{t.label}</span>
61
+ </button>
62
+ );
63
+ })}
64
+ </div>
65
+ </aside>
66
+ </>
67
+ );
68
+ }
@@ -0,0 +1,50 @@
1
+ // src/web/components/MobileHeader.tsx — sticky top bar with menu, title, and actions.
2
+ import { Menu, Search, X } from 'lucide-react';
3
+ import { cn } from '../lib/utils';
4
+
5
+ type Props = {
6
+ onMenuClick: () => void;
7
+ scrolled: boolean;
8
+ activeTab: string;
9
+ onSettings?: (() => void) | null;
10
+ onSearch?: () => void;
11
+ };
12
+
13
+ export function MobileHeader({ onMenuClick, scrolled, activeTab, onSettings, onSearch }: Props) {
14
+ return (
15
+ <header className={cn('mobile-header', scrolled && 'is-scrolled')}>
16
+ <button
17
+ onClick={onMenuClick}
18
+ className="mobile-menu-btn"
19
+ aria-label="Open menu"
20
+ type="button"
21
+ >
22
+ <Menu size={20} />
23
+ </button>
24
+
25
+ <h1 className="mobile-header-title">
26
+ {activeTab === 'settings' ? 'Settings' : 'Bizar'}
27
+ </h1>
28
+
29
+ {onSettings ? (
30
+ <button
31
+ onClick={onSettings}
32
+ className="mobile-settings-exit"
33
+ aria-label="Exit settings"
34
+ type="button"
35
+ >
36
+ <X size={20} />
37
+ </button>
38
+ ) : (
39
+ <button
40
+ onClick={onSearch}
41
+ className="mobile-search-btn"
42
+ aria-label="Search"
43
+ type="button"
44
+ >
45
+ <Search size={20} />
46
+ </button>
47
+ )}
48
+ </header>
49
+ );
50
+ }
@@ -0,0 +1,63 @@
1
+ // src/web/components/MobileLayout.tsx — main mobile shell: header + drawer + content + bottom nav.
2
+ import { useState } from 'react';
3
+ import { MobileHeader } from './MobileHeader';
4
+ import { MobileDrawer, type DrawerTab } from './MobileDrawer';
5
+ import { MobileBottomNav, type MobileTab } from './MobileBottomNav';
6
+
7
+ // Re-export DrawerTab so consumers only need to import from MobileLayout.
8
+ export { type DrawerTab } from './MobileDrawer';
9
+
10
+ type Props = {
11
+ children: React.ReactNode;
12
+ activeTab: string;
13
+ onTabChange: (id: string) => void;
14
+ tabs: MobileTab[];
15
+ fullTabs: DrawerTab[];
16
+ onExitSettings?: () => void;
17
+ onSearch?: () => void;
18
+ };
19
+
20
+ export function MobileLayout({
21
+ children,
22
+ activeTab,
23
+ onTabChange,
24
+ tabs,
25
+ fullTabs,
26
+ onExitSettings,
27
+ onSearch,
28
+ }: Props) {
29
+ const [drawerOpen, setDrawerOpen] = useState(false);
30
+ const [scrolled, setScrolled] = useState(false);
31
+
32
+ return (
33
+ <div className="mobile-layout">
34
+ <MobileHeader
35
+ onMenuClick={() => setDrawerOpen(true)}
36
+ scrolled={scrolled}
37
+ activeTab={activeTab}
38
+ onSettings={activeTab === 'settings' ? onExitSettings : null}
39
+ onSearch={onSearch}
40
+ />
41
+
42
+ <MobileDrawer
43
+ open={drawerOpen}
44
+ onClose={() => setDrawerOpen(false)}
45
+ tabs={fullTabs}
46
+ activeTab={activeTab}
47
+ onTabChange={(id) => {
48
+ onTabChange(id);
49
+ setDrawerOpen(false);
50
+ }}
51
+ />
52
+
53
+ <main
54
+ className="mobile-content"
55
+ onScroll={(e) => setScrolled(e.currentTarget.scrollTop > 10)}
56
+ >
57
+ {children}
58
+ </main>
59
+
60
+ <MobileBottomNav tabs={tabs} activeTab={activeTab} onTabChange={onTabChange} />
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,69 @@
1
+ // src/components/TaskCard.tsx — mobile-friendly task card with expandable details.
2
+ import { useState } from 'react';
3
+ import { ChevronRight } from 'lucide-react';
4
+ import { Button } from './Button';
5
+ import { api } from '../lib/api';
6
+ import { cn } from '../lib/utils';
7
+ import type { Task } from '../lib/types';
8
+
9
+ interface Props {
10
+ task: Task;
11
+ workspaceId?: string;
12
+ }
13
+
14
+ export function TaskCard({ task, workspaceId }: Props) {
15
+ const [expanded, setExpanded] = useState(false);
16
+
17
+ const handleTransition = async (to: string) => {
18
+ try {
19
+ await api.post(`/tasks/${encodeURIComponent(task.id)}/transition`, { to });
20
+ } catch {
21
+ // best-effort — parent will refresh
22
+ }
23
+ };
24
+
25
+ const handleDelete = async () => {
26
+ if (!confirm('Delete this task?')) return;
27
+ try {
28
+ await api.del(`/tasks/${encodeURIComponent(task.id)}`);
29
+ } catch {
30
+ // best-effort — parent will refresh
31
+ }
32
+ };
33
+
34
+ return (
35
+ <div className={cn('task-card-mobile', `is-${task.status === 'queued' ? 'todo' : task.status}`)}>
36
+ <div className="task-card-mobile-head" onClick={() => setExpanded(!expanded)} role="button" tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && setExpanded(!expanded)}>
37
+ <div className={cn('task-card-mobile-status', `is-${task.status === 'queued' ? 'todo' : task.status}`)} />
38
+ <div className="task-card-mobile-body">
39
+ <div className="task-card-mobile-title">{task.title}</div>
40
+ {task.priority && task.priority !== 'normal' && (
41
+ <span className={cn('task-priority-badge', `is-${task.priority}`)}>{task.priority}</span>
42
+ )}
43
+ </div>
44
+ <ChevronRight size={16} className={cn('task-chevron', expanded && 'is-rotated')} />
45
+ </div>
46
+ {expanded && (
47
+ <div className="task-card-mobile-details">
48
+ {task.description && <p className="task-card-mobile-desc">{task.description}</p>}
49
+ <div className="task-card-mobile-meta">
50
+ {task.assignee && <span>@{task.assignee}</span>}
51
+ {task.dueDate && <span>{new Date(task.dueDate).toLocaleDateString()}</span>}
52
+ {task.tags?.map((t) => (
53
+ <span key={t} className="tag">{t}</span>
54
+ ))}
55
+ </div>
56
+ <div className="task-card-mobile-actions">
57
+ {task.status !== 'doing' && (
58
+ <Button size="sm" onClick={() => handleTransition('doing')}>Start</Button>
59
+ )}
60
+ {task.status !== 'done' && (
61
+ <Button size="sm" onClick={() => handleTransition('done')}>Done</Button>
62
+ )}
63
+ <Button size="sm" variant="danger" onClick={handleDelete}>Delete</Button>
64
+ </div>
65
+ </div>
66
+ )}
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,76 @@
1
+ // src/components/TaskCreateSheet.tsx — bottom sheet for creating tasks.
2
+ import { useState } from 'react';
3
+ import { Button } from './Button';
4
+ import { api } from '../lib/api';
5
+ import { cn } from '../lib/utils';
6
+
7
+ interface Props {
8
+ workspaceId?: string;
9
+ onClose: () => void;
10
+ onCreated: () => void;
11
+ }
12
+
13
+ export function TaskCreateSheet({ workspaceId, onClose, onCreated }: Props) {
14
+ const [title, setTitle] = useState('');
15
+ const [description, setDescription] = useState('');
16
+ const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
17
+ const [submitting, setSubmitting] = useState(false);
18
+
19
+ const submit = async () => {
20
+ if (!title.trim()) return;
21
+ setSubmitting(true);
22
+ try {
23
+ const payload: Record<string, unknown> = { title, description, priority };
24
+ if (workspaceId) payload.workspaceId = workspaceId;
25
+ await api.post('/tasks', payload);
26
+ onCreated();
27
+ onClose();
28
+ } catch {
29
+ // best-effort — parent handles refresh via onCreated
30
+ } finally {
31
+ setSubmitting(false);
32
+ }
33
+ };
34
+
35
+ return (
36
+ <>
37
+ <div className="sheet-backdrop" onClick={onClose} />
38
+ <div className="sheet-bottom" role="dialog" aria-modal="true" aria-labelledby="sheet-title">
39
+ <div className="sheet-handle" />
40
+ <h2 id="sheet-title" className="sheet-title">New Task</h2>
41
+ <input
42
+ autoFocus
43
+ value={title}
44
+ onChange={(e) => setTitle(e.target.value)}
45
+ placeholder="Task title..."
46
+ className="sheet-input"
47
+ />
48
+ <textarea
49
+ value={description}
50
+ onChange={(e) => setDescription(e.target.value)}
51
+ placeholder="Description (optional)..."
52
+ className="sheet-textarea"
53
+ rows={3}
54
+ />
55
+ <div className="sheet-priority">
56
+ {(['low', 'medium', 'high'] as const).map((p) => (
57
+ <button
58
+ key={p}
59
+ type="button"
60
+ className={cn('sheet-priority-btn', priority === p && 'is-active', `is-${p}`)}
61
+ onClick={() => setPriority(p)}
62
+ >
63
+ {p}
64
+ </button>
65
+ ))}
66
+ </div>
67
+ <div className="sheet-actions">
68
+ <Button onClick={onClose} disabled={submitting}>Cancel</Button>
69
+ <Button variant="primary" onClick={submit} disabled={submitting || !title.trim()}>
70
+ {submitting ? 'Creating...' : 'Create'}
71
+ </Button>
72
+ </div>
73
+ </div>
74
+ </>
75
+ );
76
+ }
@@ -12,6 +12,7 @@ setLocale('en', en);
12
12
  // from index.html (e.g. when the auto-redirect to /m is bypassed).
13
13
  import './styles/main.css';
14
14
  import './styles/mobile.css';
15
+ import './styles/mobile-layout.css';
15
16
  import './styles/glyphs.css';
16
17
  import './styles/minimax-usage.css';
17
18
  import './styles/skills.css';
@@ -0,0 +1,80 @@
1
+ // src/web/mobile/MobileDoctor.tsx — v5.4 mobile doctor health view with 30s auto-refresh.
2
+ import { useEffect, useState } from 'react';
3
+ import { CheckCircle2, AlertTriangle, XCircle } from 'lucide-react';
4
+ import { api } from '../lib/api';
5
+ import type { DoctorStatus, DoctorCheck } from '../lib/types';
6
+
7
+ type HealthResponse = {
8
+ status: DoctorStatus;
9
+ issues: DoctorCheck[];
10
+ };
11
+
12
+ const REFRESH_INTERVAL_MS = 30_000;
13
+
14
+ export function MobileDoctor() {
15
+ const [data, setData] = useState<HealthResponse | null>(null);
16
+ const [loading, setLoading] = useState(true);
17
+
18
+ useEffect(() => {
19
+ const fetch_ = () => {
20
+ api.get<HealthResponse>('/doctor/health')
21
+ .then((r) => {
22
+ setData(r);
23
+ setLoading(false);
24
+ })
25
+ .catch(() => setLoading(false));
26
+ };
27
+ fetch_();
28
+ const id = setInterval(fetch_, REFRESH_INTERVAL_MS);
29
+ return () => clearInterval(id);
30
+ }, []);
31
+
32
+ if (loading) {
33
+ return (
34
+ <div className="mobile-loading">
35
+ <p>Running health checks…</p>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ const status: DoctorStatus = data?.status ?? 'fail';
41
+ const issues: DoctorCheck[] = data?.issues ?? [];
42
+
43
+ return (
44
+ <div className="mobile-doctor">
45
+ <div className={`mobile-doctor-status is-${status}`}>
46
+ {status === 'ok' && <CheckCircle2 size={48} />}
47
+ {status === 'warn' && <AlertTriangle size={48} />}
48
+ {status === 'fail' && <XCircle size={48} />}
49
+ <div className="mobile-doctor-status-text">{status.toUpperCase()}</div>
50
+ <div className="mobile-doctor-status-sub">Auto-refreshes every 30s</div>
51
+ </div>
52
+
53
+ {issues.length > 0 && (
54
+ <div className="mobile-doctor-issues">
55
+ <h3>Issues ({issues.length})</h3>
56
+ {issues.map((i, idx) => (
57
+ <div key={idx} className={`mobile-doctor-issue is-${i.status}`}>
58
+ {i.status === 'warn' ? (
59
+ <AlertTriangle size={16} />
60
+ ) : (
61
+ <XCircle size={16} />
62
+ )}
63
+ <div>
64
+ <div className="mobile-doctor-issue-name">{i.name}</div>
65
+ <div className="mobile-doctor-issue-msg">{i.message}</div>
66
+ </div>
67
+ </div>
68
+ ))}
69
+ </div>
70
+ )}
71
+
72
+ {status === 'ok' && issues.length === 0 && (
73
+ <div className="mobile-doctor-ok">
74
+ <CheckCircle2 size={24} />
75
+ <p>All systems healthy.</p>
76
+ </div>
77
+ )}
78
+ </div>
79
+ );
80
+ }
@@ -0,0 +1,167 @@
1
+ // src/web/mobile/MobileEval.tsx — v5.4 mobile eval view with runs and schedules tabs.
2
+ import { useEffect, useState } from 'react';
3
+ import { ClipboardCheck, Clock } from 'lucide-react';
4
+ import { cn } from '../lib/utils';
5
+ import { api } from '../lib/api';
6
+ import type { EvalRunSummary } from '../components/EvalRunCard';
7
+
8
+ type EvalSchedule = {
9
+ id: string;
10
+ name: string;
11
+ suitePath: string;
12
+ cron: string;
13
+ agent: string;
14
+ };
15
+
16
+ function pickStatus(run: EvalRunSummary): 'pass' | 'warn' | 'fail' {
17
+ if (run.total <= 0) return 'fail';
18
+ if (run.failed === 0) return 'pass';
19
+ if (run.passed / run.total > 0.8) return 'warn';
20
+ return 'fail';
21
+ }
22
+
23
+ export function MobileEval() {
24
+ const [runs, setRuns] = useState<EvalRunSummary[]>([]);
25
+ const [schedules, setSchedules] = useState<EvalSchedule[]>([]);
26
+ const [tab, setTab] = useState<'runs' | 'schedules'>('runs');
27
+ const [loadingRuns, setLoadingRuns] = useState(true);
28
+ const [loadingSchedules, setLoadingSchedules] = useState(false);
29
+ const [selectedRun, setSelectedRun] = useState<EvalRunSummary | null>(null);
30
+
31
+ useEffect(() => {
32
+ api.get<{ runs: EvalRunSummary[] }>('/eval/runs')
33
+ .then((r) => setRuns(r.runs ?? []))
34
+ .catch(() => {/* best-effort */})
35
+ .finally(() => setLoadingRuns(false));
36
+ }, []);
37
+
38
+ const loadSchedules = () => {
39
+ setLoadingSchedules(true);
40
+ api.get<{ schedules: EvalSchedule[] }>('/eval/schedules')
41
+ .then((r) => setSchedules(r.schedules ?? []))
42
+ .catch(() => {/* best-effort */})
43
+ .finally(() => setLoadingSchedules(false));
44
+ };
45
+
46
+ useEffect(() => {
47
+ if (tab === 'schedules' && schedules.length === 0) {
48
+ loadSchedules();
49
+ }
50
+ }, [tab]);
51
+
52
+ return (
53
+ <div className="mobile-eval">
54
+ <div className="mobile-eval-tabs">
55
+ <button
56
+ type="button"
57
+ className={cn('mobile-eval-tab', tab === 'runs' && 'is-active')}
58
+ onClick={() => setTab('runs')}
59
+ >
60
+ <ClipboardCheck size={12} aria-hidden />
61
+ Runs
62
+ </button>
63
+ <button
64
+ type="button"
65
+ className={cn('mobile-eval-tab', tab === 'schedules' && 'is-active')}
66
+ onClick={() => setTab('schedules')}
67
+ >
68
+ <Clock size={12} aria-hidden />
69
+ Schedules
70
+ </button>
71
+ </div>
72
+
73
+ {tab === 'runs' && (
74
+ <div className="mobile-eval-runs">
75
+ {loadingRuns ? (
76
+ <div className="mobile-loading"><p>Loading runs…</p></div>
77
+ ) : runs.length === 0 ? (
78
+ <div className="mobile-empty">
79
+ <ClipboardCheck size={32} />
80
+ <p>No eval runs yet.</p>
81
+ </div>
82
+ ) : (
83
+ runs.map((r) => {
84
+ const status = pickStatus(r);
85
+ return (
86
+ <div
87
+ key={r.id}
88
+ className="mobile-eval-run-card"
89
+ onClick={() => setSelectedRun(r)}
90
+ role="button"
91
+ tabIndex={0}
92
+ onKeyDown={(e) => e.key === 'Enter' && setSelectedRun(r)}
93
+ >
94
+ <div className={cn('mobile-eval-status', `is-${status}`)}>
95
+ {r.passed}/{r.total}
96
+ </div>
97
+ <div className="mobile-eval-run-info">
98
+ <div className="mobile-eval-run-id">{r.id}</div>
99
+ <div className="mobile-eval-run-time">
100
+ {new Date(r.startedAt).toLocaleString()}
101
+ </div>
102
+ </div>
103
+ </div>
104
+ );
105
+ })
106
+ )}
107
+ </div>
108
+ )}
109
+
110
+ {tab === 'schedules' && (
111
+ <div className="mobile-eval-schedules">
112
+ {loadingSchedules ? (
113
+ <div className="mobile-loading"><p>Loading schedules…</p></div>
114
+ ) : schedules.length === 0 ? (
115
+ <div className="mobile-empty">
116
+ <Clock size={32} />
117
+ <p>No eval schedules.</p>
118
+ </div>
119
+ ) : (
120
+ schedules.map((s) => (
121
+ <div key={s.id} className="mobile-eval-schedule-card">
122
+ <div className="mobile-eval-schedule-name">{s.name}</div>
123
+ <div className="mobile-eval-schedule-meta">
124
+ {s.cron} · {s.suitePath} · agent:{s.agent || 'thor'}
125
+ </div>
126
+ </div>
127
+ ))
128
+ )}
129
+ </div>
130
+ )}
131
+
132
+ {selectedRun && (
133
+ <div className="mobile-eval-run-sheet">
134
+ <div className="mobile-bottom-sheet-header">
135
+ <h3>Run {selectedRun.id}</h3>
136
+ <button
137
+ type="button"
138
+ className="mobile-bottom-sheet-close"
139
+ onClick={() => setSelectedRun(null)}
140
+ aria-label="Close"
141
+ >
142
+ ×
143
+ </button>
144
+ </div>
145
+ <div className="mobile-eval-run-sheet-body">
146
+ <div className="mobile-eval-run-sheet-row">
147
+ <span>Suite</span>
148
+ <span>{selectedRun.suitePath}</span>
149
+ </div>
150
+ <div className="mobile-eval-run-sheet-row">
151
+ <span>Passed</span>
152
+ <span>{selectedRun.passed}</span>
153
+ </div>
154
+ <div className="mobile-eval-run-sheet-row">
155
+ <span>Failed</span>
156
+ <span>{selectedRun.failed}</span>
157
+ </div>
158
+ <div className="mobile-eval-run-sheet-row">
159
+ <span>Started</span>
160
+ <span>{new Date(selectedRun.startedAt).toLocaleString()}</span>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ )}
165
+ </div>
166
+ );
167
+ }