@polderlabs/bizar 5.3.0 → 5.4.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 (69) hide show
  1. package/bizar-dash/dist/assets/EnvVarsSection-DqvB8ZcJ.js +4 -0
  2. package/bizar-dash/dist/assets/EnvVarsSection-DqvB8ZcJ.js.map +1 -0
  3. package/bizar-dash/dist/assets/MobileChat-DRXP2ERQ.js +1 -0
  4. package/bizar-dash/dist/assets/{MobileChat-OidrxXR9.js.map → MobileChat-DRXP2ERQ.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js +1 -0
  6. package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js.map +1 -0
  7. package/bizar-dash/dist/assets/Toast-CcZVVN4i.js +1 -0
  8. package/bizar-dash/dist/assets/Toast-CcZVVN4i.js.map +1 -0
  9. package/bizar-dash/dist/assets/{icons-Bd8piSb2.js → icons-OMEGx5KA.js} +66 -61
  10. package/bizar-dash/dist/assets/icons-OMEGx5KA.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-CMAGZouj.js +16 -0
  12. package/bizar-dash/dist/assets/main-CMAGZouj.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-XN2P7YfM.js +1 -0
  15. package/bizar-dash/dist/assets/mobile-XN2P7YfM.js.map +1 -0
  16. package/bizar-dash/dist/assets/{mobile-CsZQAswA.css → mobile-layout-D6V-7WnA.css} +1 -1
  17. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js +2 -0
  18. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js.map +1 -0
  19. package/bizar-dash/dist/assets/useSlashCommands-iyoW95UB.js +2 -0
  20. package/bizar-dash/dist/assets/useSlashCommands-iyoW95UB.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/server/auth.mjs +14 -1
  25. package/bizar-dash/src/server/routes/users.mjs +18 -0
  26. package/bizar-dash/src/web/MobileApp.tsx +26 -19
  27. package/bizar-dash/src/web/components/MobileBottomNav.tsx +38 -0
  28. package/bizar-dash/src/web/components/MobileDrawer.tsx +68 -0
  29. package/bizar-dash/src/web/components/MobileHeader.tsx +50 -0
  30. package/bizar-dash/src/web/components/MobileLayout.tsx +63 -0
  31. package/bizar-dash/src/web/components/TaskCard.tsx +69 -0
  32. package/bizar-dash/src/web/components/TaskCreateSheet.tsx +76 -0
  33. package/bizar-dash/src/web/main.tsx +1 -0
  34. package/bizar-dash/src/web/mobile/MobileDoctor.tsx +80 -0
  35. package/bizar-dash/src/web/mobile/MobileEval.tsx +167 -0
  36. package/bizar-dash/src/web/mobile/MobileMarketplace.tsx +106 -0
  37. package/bizar-dash/src/web/mobile/MobileMemory.tsx +108 -0
  38. package/bizar-dash/src/web/mobile/MobileOverview.tsx +213 -0
  39. package/bizar-dash/src/web/mobile/MobilePlugins.tsx +71 -0
  40. package/bizar-dash/src/web/mobile/MobileSettings.tsx +235 -180
  41. package/bizar-dash/src/web/mobile/MobileTasks.tsx +94 -0
  42. package/bizar-dash/src/web/mobile.tsx +1 -0
  43. package/bizar-dash/src/web/styles/main.css +388 -0
  44. package/bizar-dash/src/web/styles/memory.css +45 -0
  45. package/bizar-dash/src/web/styles/mobile-layout.css +231 -0
  46. package/bizar-dash/src/web/styles/mobile.css +180 -0
  47. package/bizar-dash/src/web/styles/settings.css +110 -0
  48. package/bizar-dash/src/web/styles/tasks.css +305 -0
  49. package/bizar-dash/tests/mobile-chat.test.tsx +82 -0
  50. package/bizar-dash/tests/mobile-layout.test.tsx +296 -0
  51. package/bizar-dash/tests/mobile-misc.test.tsx +321 -0
  52. package/bizar-dash/tests/mobile-overview.test.tsx +214 -0
  53. package/bizar-dash/tests/mobile-settings.test.tsx +181 -0
  54. package/bizar-dash/tests/mobile-tasks.test.tsx +162 -0
  55. package/cli/commands/dash.mjs +5 -0
  56. package/cli/commands/tailscale.mjs +8 -1
  57. package/package.json +1 -1
  58. package/bizar-dash/dist/assets/MobileChat-OidrxXR9.js +0 -1
  59. package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js +0 -1
  60. package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js.map +0 -1
  61. package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +0 -1
  62. package/bizar-dash/dist/assets/main-6YUZY968.js +0 -19
  63. package/bizar-dash/dist/assets/main-6YUZY968.js.map +0 -1
  64. package/bizar-dash/dist/assets/mobile-CSsp30lr.js +0 -2
  65. package/bizar-dash/dist/assets/mobile-CSsp30lr.js.map +0 -1
  66. package/bizar-dash/dist/assets/mobile-DtwK7DOV.js +0 -1
  67. package/bizar-dash/dist/assets/mobile-DtwK7DOV.js.map +0 -1
  68. package/bizar-dash/dist/assets/useSlashCommands-DEkxEvOO.js +0 -2
  69. package/bizar-dash/dist/assets/useSlashCommands-DEkxEvOO.js.map +0 -1
@@ -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
+ }
@@ -0,0 +1,106 @@
1
+ // src/web/mobile/MobileMarketplace.tsx — v5.4 mobile plugin marketplace with search.
2
+ import { useEffect, useState } from 'react';
3
+ import { Store, Search } from 'lucide-react';
4
+ import { api } from '../lib/api';
5
+ import { MarketplacePluginCard, type MarketplacePlugin } from '../components/MarketplacePluginCard';
6
+
7
+ type RegistryResponse = {
8
+ plugins: MarketplacePlugin[];
9
+ };
10
+
11
+ export function MobileMarketplace() {
12
+ const [registry, setRegistry] = useState<RegistryResponse | null>(null);
13
+ const [search, setSearch] = useState('');
14
+ const [installing, setInstalling] = useState<MarketplacePlugin | null>(null);
15
+ const [selected, setSelected] = useState<MarketplacePlugin | null>(null);
16
+ const [loading, setLoading] = useState(true);
17
+
18
+ useEffect(() => {
19
+ api.get<RegistryResponse>('/plugins/registry')
20
+ .then((r) => setRegistry(r))
21
+ .catch(() => {/* best-effort */})
22
+ .finally(() => setLoading(false));
23
+ }, []);
24
+
25
+ const install = async (plugin: MarketplacePlugin) => {
26
+ setInstalling(plugin);
27
+ try {
28
+ await api.post('/plugins/install', { pluginId: plugin.id });
29
+ setInstalling(null);
30
+ setSelected(null);
31
+ } catch {
32
+ setInstalling(null);
33
+ }
34
+ };
35
+
36
+ const filtered = (registry?.plugins ?? []).filter(
37
+ (p) => !search || p.name.toLowerCase().includes(search.toLowerCase()),
38
+ );
39
+
40
+ if (loading) {
41
+ return (
42
+ <div className="mobile-loading">
43
+ <p>Loading marketplace…</p>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ return (
49
+ <div className="mobile-marketplace">
50
+ <div className="mobile-marketplace-search">
51
+ <Search size={16} aria-hidden />
52
+ <input
53
+ type="search"
54
+ value={search}
55
+ onChange={(e) => setSearch(e.target.value)}
56
+ placeholder="Search plugins…"
57
+ aria-label="Search plugins"
58
+ />
59
+ </div>
60
+
61
+ <div className="mobile-marketplace-list">
62
+ {filtered.length === 0 ? (
63
+ <div className="mobile-empty">
64
+ <Store size={32} />
65
+ <p>No plugins{search ? ` match "${search}"` : ' in marketplace'}.</p>
66
+ </div>
67
+ ) : (
68
+ filtered.map((p) => (
69
+ <MarketplacePluginCard
70
+ key={p.id}
71
+ plugin={p}
72
+ onInstall={() => setSelected(p)}
73
+ onView={() => setSelected(p)}
74
+ />
75
+ ))
76
+ )}
77
+ </div>
78
+
79
+ {selected && (
80
+ <div className="mobile-plugin-install-dialog">
81
+ <div className="mobile-plugin-install-dialog-head">
82
+ <h3>{selected.name}</h3>
83
+ <p>{selected.description}</p>
84
+ </div>
85
+ <div className="mobile-plugin-install-dialog-actions">
86
+ <button
87
+ type="button"
88
+ className="mobile-btn mobile-btn-secondary"
89
+ onClick={() => setSelected(null)}
90
+ >
91
+ Cancel
92
+ </button>
93
+ <button
94
+ type="button"
95
+ className="mobile-btn mobile-btn-primary"
96
+ disabled={installing?.id === selected.id}
97
+ onClick={() => install(selected)}
98
+ >
99
+ {installing?.id === selected.id ? 'Installing…' : 'Install'}
100
+ </button>
101
+ </div>
102
+ </div>
103
+ )}
104
+ </div>
105
+ );
106
+ }