@polderlabs/bizar-dash 3.0.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.
- package/dist/assets/index-B5X9g8B4.css +1 -0
- package/dist/assets/index-LqQuSp9d.js +388 -0
- package/dist/assets/index-LqQuSp9d.js.map +1 -0
- package/dist/index.html +18 -0
- package/package.json +67 -0
- package/src/cli.mjs +228 -0
- package/src/server/agents-store.mjs +190 -0
- package/src/server/api.mjs +913 -0
- package/src/server/browser.mjs +40 -0
- package/src/server/diagnostics-store.mjs +138 -0
- package/src/server/mods-loader.mjs +361 -0
- package/src/server/projects-store.mjs +198 -0
- package/src/server/providers-store.mjs +183 -0
- package/src/server/schedules-runner.mjs +150 -0
- package/src/server/schedules-store.mjs +233 -0
- package/src/server/search-store.mjs +120 -0
- package/src/server/server.mjs +388 -0
- package/src/server/state.mjs +357 -0
- package/src/server/tailscale-store.mjs +113 -0
- package/src/server/tasks-store.mjs +275 -0
- package/src/server/tui.mjs +844 -0
- package/src/server/watcher.mjs +81 -0
- package/src/web/App.tsx +316 -0
- package/src/web/components/Button.tsx +55 -0
- package/src/web/components/Card.tsx +40 -0
- package/src/web/components/EmptyState.tsx +30 -0
- package/src/web/components/Modal.tsx +137 -0
- package/src/web/components/SearchModal.tsx +185 -0
- package/src/web/components/Spinner.tsx +19 -0
- package/src/web/components/StatusBadge.tsx +25 -0
- package/src/web/components/Tag.tsx +28 -0
- package/src/web/components/Toast.tsx +142 -0
- package/src/web/components/Topbar.tsx +203 -0
- package/src/web/index.html +17 -0
- package/src/web/lib/api.ts +71 -0
- package/src/web/lib/markdown.tsx +59 -0
- package/src/web/lib/types.ts +388 -0
- package/src/web/lib/utils.ts +79 -0
- package/src/web/lib/ws.ts +132 -0
- package/src/web/main.tsx +12 -0
- package/src/web/styles/main.css +3148 -0
- package/src/web/views/Agents.tsx +406 -0
- package/src/web/views/Chat.tsx +527 -0
- package/src/web/views/Config.tsx +683 -0
- package/src/web/views/Mods.tsx +350 -0
- package/src/web/views/Overview.tsx +350 -0
- package/src/web/views/Plans.tsx +667 -0
- package/src/web/views/Schedules.tsx +299 -0
- package/src/web/views/Settings.tsx +571 -0
- package/src/web/views/Tasks.tsx +761 -0
- package/templates/mod/FORMAT.md +76 -0
- package/templates/mod/hello-mod/README.md +19 -0
- package/templates/mod/hello-mod/agents/greeter.md +8 -0
- package/templates/mod/hello-mod/commands/hello.md +6 -0
- package/templates/mod/hello-mod/mod.json +20 -0
- package/templates/mod/hello-mod/routes/ping.mjs +9 -0
- package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +24 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// src/components/SearchModal.tsx — fuzzy search modal opened by / or Cmd/Ctrl+K.
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Search, X } from 'lucide-react';
|
|
4
|
+
import { useToast } from './Toast';
|
|
5
|
+
import { api } from '../lib/api';
|
|
6
|
+
import type { SearchResult } from '../lib/types';
|
|
7
|
+
import { cn } from '../lib/utils';
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
open: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
onSelect: (r: SearchResult) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const SCOPES = [
|
|
16
|
+
{ id: 'all', label: 'All' },
|
|
17
|
+
{ id: 'projects', label: 'Projects' },
|
|
18
|
+
{ id: 'agents', label: 'Agents' },
|
|
19
|
+
{ id: 'tasks', label: 'Tasks' },
|
|
20
|
+
{ id: 'mods', label: 'Mods' },
|
|
21
|
+
{ id: 'schedules', label: 'Schedules' },
|
|
22
|
+
{ id: 'commands', label: 'Commands' },
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export function SearchModal({ open, onClose, onSelect }: Props) {
|
|
26
|
+
const toast = useToast();
|
|
27
|
+
const [q, setQ] = useState('');
|
|
28
|
+
const [scope, setScope] = useState<typeof SCOPES[number]['id']>('all');
|
|
29
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
30
|
+
const [loading, setLoading] = useState(false);
|
|
31
|
+
const [activeIdx, setActiveIdx] = useState(0);
|
|
32
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (open) {
|
|
36
|
+
setQ('');
|
|
37
|
+
setResults([]);
|
|
38
|
+
setActiveIdx(0);
|
|
39
|
+
setTimeout(() => inputRef.current?.focus(), 30);
|
|
40
|
+
}
|
|
41
|
+
}, [open]);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!open) return;
|
|
45
|
+
if (!q.trim()) {
|
|
46
|
+
setResults([]);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
setLoading(true);
|
|
51
|
+
const t = setTimeout(() => {
|
|
52
|
+
api
|
|
53
|
+
.get<{ results: SearchResult[] }>(`/search?q=${encodeURIComponent(q)}&scope=${scope}`)
|
|
54
|
+
.then((r) => {
|
|
55
|
+
if (cancelled) return;
|
|
56
|
+
setResults(r.results || []);
|
|
57
|
+
setActiveIdx(0);
|
|
58
|
+
})
|
|
59
|
+
.catch((err) => {
|
|
60
|
+
if (cancelled) return;
|
|
61
|
+
toast.error(`Search failed: ${(err as Error).message}`);
|
|
62
|
+
})
|
|
63
|
+
.finally(() => !cancelled && setLoading(false));
|
|
64
|
+
}, 150);
|
|
65
|
+
return () => {
|
|
66
|
+
cancelled = true;
|
|
67
|
+
clearTimeout(t);
|
|
68
|
+
};
|
|
69
|
+
}, [q, scope, open, toast]);
|
|
70
|
+
|
|
71
|
+
if (!open) return null;
|
|
72
|
+
|
|
73
|
+
const grouped: Record<string, SearchResult[]> = {};
|
|
74
|
+
for (const r of results) {
|
|
75
|
+
grouped[r.type] = grouped[r.type] || [];
|
|
76
|
+
grouped[r.type].push(r);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const flat: SearchResult[] = [];
|
|
80
|
+
for (const scope of SCOPES.map((s) => s.id)) {
|
|
81
|
+
if (grouped[scope]) flat.push(...grouped[scope]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const onKey = (e: React.KeyboardEvent) => {
|
|
85
|
+
if (e.key === 'Escape') {
|
|
86
|
+
onClose();
|
|
87
|
+
} else if (e.key === 'ArrowDown') {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
setActiveIdx((i) => Math.min(i + 1, flat.length - 1));
|
|
90
|
+
} else if (e.key === 'ArrowUp') {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
setActiveIdx((i) => Math.max(i - 1, 0));
|
|
93
|
+
} else if (e.key === 'Enter' && flat[activeIdx]) {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
onSelect(flat[activeIdx]);
|
|
96
|
+
onClose();
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="search-modal-backdrop" onClick={onClose}>
|
|
102
|
+
<div className="search-modal" onClick={(e) => e.stopPropagation()}>
|
|
103
|
+
<div className="search-modal-head">
|
|
104
|
+
<Search size={14} />
|
|
105
|
+
<input
|
|
106
|
+
ref={inputRef}
|
|
107
|
+
className="search-modal-input"
|
|
108
|
+
placeholder="Search tasks, plans, agents, projects, mods…"
|
|
109
|
+
value={q}
|
|
110
|
+
onChange={(e) => setQ(e.target.value)}
|
|
111
|
+
onKeyDown={onKey}
|
|
112
|
+
/>
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
className="icon-btn"
|
|
116
|
+
aria-label="Close"
|
|
117
|
+
onClick={onClose}
|
|
118
|
+
>
|
|
119
|
+
<X size={14} />
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="search-modal-scopes">
|
|
123
|
+
{SCOPES.map((s) => (
|
|
124
|
+
<button
|
|
125
|
+
key={s.id}
|
|
126
|
+
type="button"
|
|
127
|
+
className={cn('search-scope', scope === s.id && 'search-scope-active')}
|
|
128
|
+
onClick={() => setScope(s.id)}
|
|
129
|
+
>
|
|
130
|
+
{s.label}
|
|
131
|
+
</button>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
<div className="search-modal-body">
|
|
135
|
+
{loading && <div className="muted">Searching…</div>}
|
|
136
|
+
{!loading && q && flat.length === 0 && <div className="muted">No results.</div>}
|
|
137
|
+
{!loading && !q && <div className="muted">Type to search…</div>}
|
|
138
|
+
{SCOPES.map((s) => {
|
|
139
|
+
const list = grouped[s.id];
|
|
140
|
+
if (!list || list.length === 0) return null;
|
|
141
|
+
return (
|
|
142
|
+
<div key={s.id} className="search-group">
|
|
143
|
+
<div className="search-group-head">{s.label}</div>
|
|
144
|
+
{list.map((r) => {
|
|
145
|
+
const flatIdx = flat.indexOf(r);
|
|
146
|
+
return (
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
key={`${r.type}-${flatIdx}`}
|
|
150
|
+
className={cn('search-result', flatIdx === activeIdx && 'search-result-active')}
|
|
151
|
+
onMouseEnter={() => setActiveIdx(flatIdx)}
|
|
152
|
+
onClick={() => {
|
|
153
|
+
onSelect(r);
|
|
154
|
+
onClose();
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
<span className="search-result-type">{r.type}</span>
|
|
158
|
+
<span className="search-result-label">
|
|
159
|
+
{summarize(r)}
|
|
160
|
+
</span>
|
|
161
|
+
</button>
|
|
162
|
+
);
|
|
163
|
+
})}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
167
|
+
</div>
|
|
168
|
+
<div className="search-modal-foot">
|
|
169
|
+
<span className="muted">↑↓ navigate · ↵ open · esc close</span>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function summarize(r: SearchResult): string {
|
|
177
|
+
const i = r.item || {};
|
|
178
|
+
if (r.type === 'project') return `${i.name} — ${i.path}`;
|
|
179
|
+
if (r.type === 'agent') return `${i.name} — ${i.description || i.model || ''}`;
|
|
180
|
+
if (r.type === 'task') return `${i.title} — ${(i.status || '')}`;
|
|
181
|
+
if (r.type === 'mod') return `${i.name} v${i.version} — ${i.description || ''}`;
|
|
182
|
+
if (r.type === 'schedule') return `${i.name} (${i.type}: ${i.schedule})`;
|
|
183
|
+
if (r.type === 'command') return `${i.name} — ${i.description || ''}`;
|
|
184
|
+
return JSON.stringify(i).slice(0, 80);
|
|
185
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/components/Spinner.tsx — loading indicator.
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/utils';
|
|
4
|
+
|
|
5
|
+
export type SpinnerProps = {
|
|
6
|
+
size?: 'sm' | 'md' | 'lg';
|
|
7
|
+
className?: string;
|
|
8
|
+
label?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function Spinner({ size = 'md', className, label }: SpinnerProps) {
|
|
12
|
+
return (
|
|
13
|
+
<span
|
|
14
|
+
className={cn('spinner', `spinner-${size}`, className)}
|
|
15
|
+
role="status"
|
|
16
|
+
aria-label={label || 'Loading'}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// src/components/StatusBadge.tsx — small colored pill.
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
|
|
6
|
+
export type StatusBadgeProps = {
|
|
7
|
+
kind?: 'success' | 'warning' | 'error' | 'info' | 'accent' | 'neutral';
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
dot?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function StatusBadge({
|
|
14
|
+
kind = 'neutral',
|
|
15
|
+
children,
|
|
16
|
+
className,
|
|
17
|
+
dot = false,
|
|
18
|
+
}: StatusBadgeProps) {
|
|
19
|
+
return (
|
|
20
|
+
<span className={cn('badge', `badge-${kind}`, className)}>
|
|
21
|
+
{dot && <span className="badge-dot" />}
|
|
22
|
+
{children}
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/components/Tag.tsx — pill for tags.
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
|
|
6
|
+
export type TagProps = {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
onRemove?: () => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function Tag({ children, className, onRemove }: TagProps) {
|
|
13
|
+
return (
|
|
14
|
+
<span className={cn('tag', className)}>
|
|
15
|
+
{children}
|
|
16
|
+
{onRemove && (
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
className="tag-remove"
|
|
20
|
+
aria-label={`Remove ${typeof children === 'string' ? children : 'tag'}`}
|
|
21
|
+
onClick={onRemove}
|
|
22
|
+
>
|
|
23
|
+
×
|
|
24
|
+
</button>
|
|
25
|
+
)}
|
|
26
|
+
</span>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// src/components/Toast.tsx — toast context, provider hook, container.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { CheckCircle2, AlertTriangle, AlertCircle, Info, X } from 'lucide-react';
|
|
12
|
+
|
|
13
|
+
export type ToastKind = 'info' | 'success' | 'error' | 'warning';
|
|
14
|
+
|
|
15
|
+
export type Toast = {
|
|
16
|
+
id: number;
|
|
17
|
+
kind: ToastKind;
|
|
18
|
+
message: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ToastApi = {
|
|
22
|
+
show: (message: string, kind?: ToastKind, durationMs?: number) => void;
|
|
23
|
+
info: (message: string, durationMs?: number) => void;
|
|
24
|
+
success: (message: string, durationMs?: number) => void;
|
|
25
|
+
error: (message: string, durationMs?: number) => void;
|
|
26
|
+
warning: (message: string, durationMs?: number) => void;
|
|
27
|
+
dismiss: (id: number) => void;
|
|
28
|
+
toasts: Toast[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const ToastContext = createContext<ToastApi | null>(null);
|
|
32
|
+
|
|
33
|
+
export function useToast(): ToastApi {
|
|
34
|
+
const ctx = useContext(ToastContext);
|
|
35
|
+
if (!ctx) {
|
|
36
|
+
// Fallback no-op so tests/Storybook don't crash
|
|
37
|
+
return {
|
|
38
|
+
show: () => undefined,
|
|
39
|
+
info: () => undefined,
|
|
40
|
+
success: () => undefined,
|
|
41
|
+
error: () => undefined,
|
|
42
|
+
warning: () => undefined,
|
|
43
|
+
dismiss: () => undefined,
|
|
44
|
+
toasts: [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return ctx;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ICONS: Record<ToastKind, typeof Info> = {
|
|
51
|
+
info: Info,
|
|
52
|
+
success: CheckCircle2,
|
|
53
|
+
error: AlertCircle,
|
|
54
|
+
warning: AlertTriangle,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
58
|
+
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
59
|
+
const idRef = useRef(1);
|
|
60
|
+
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
|
61
|
+
|
|
62
|
+
const dismiss = useCallback((id: number) => {
|
|
63
|
+
setToasts((cur) => cur.filter((t) => t.id !== id));
|
|
64
|
+
const timer = timersRef.current.get(id);
|
|
65
|
+
if (timer) {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
timersRef.current.delete(id);
|
|
68
|
+
}
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const show = useCallback<ToastApi['show']>(
|
|
72
|
+
(message, kind = 'info', durationMs = 4000) => {
|
|
73
|
+
const id = idRef.current++;
|
|
74
|
+
setToasts((cur) => [...cur, { id, kind, message }]);
|
|
75
|
+
if (durationMs > 0) {
|
|
76
|
+
const timer = setTimeout(() => dismiss(id), durationMs);
|
|
77
|
+
timersRef.current.set(id, timer);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
[dismiss],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const api = useMemo<ToastApi>(
|
|
84
|
+
() => ({
|
|
85
|
+
show,
|
|
86
|
+
info: (m, d) => show(m, 'info', d),
|
|
87
|
+
success: (m, d) => show(m, 'success', d),
|
|
88
|
+
error: (m, d) => show(m, 'error', d),
|
|
89
|
+
warning: (m, d) => show(m, 'warning', d),
|
|
90
|
+
dismiss,
|
|
91
|
+
toasts,
|
|
92
|
+
}),
|
|
93
|
+
[show, dismiss, toasts],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<ToastContext.Provider value={api}>
|
|
98
|
+
{children}
|
|
99
|
+
<ToastContainer toasts={toasts} onDismiss={dismiss} />
|
|
100
|
+
</ToastContext.Provider>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function ToastContainer({
|
|
105
|
+
toasts,
|
|
106
|
+
onDismiss,
|
|
107
|
+
}: {
|
|
108
|
+
toasts: Toast[];
|
|
109
|
+
onDismiss: (id: number) => void;
|
|
110
|
+
}) {
|
|
111
|
+
return (
|
|
112
|
+
<div className="toast-stack" aria-live="polite">
|
|
113
|
+
{toasts.map((t) => (
|
|
114
|
+
<ToastItem key={t.id} toast={t} onDismiss={onDismiss} />
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ToastItem({
|
|
121
|
+
toast,
|
|
122
|
+
onDismiss,
|
|
123
|
+
}: {
|
|
124
|
+
toast: Toast;
|
|
125
|
+
onDismiss: (id: number) => void;
|
|
126
|
+
}) {
|
|
127
|
+
const Icon = ICONS[toast.kind];
|
|
128
|
+
return (
|
|
129
|
+
<div className={`toast toast-${toast.kind}`} role="status">
|
|
130
|
+
<Icon size={16} className="toast-icon" />
|
|
131
|
+
<span className="toast-message">{toast.message}</span>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
className="toast-close"
|
|
135
|
+
aria-label="Dismiss"
|
|
136
|
+
onClick={() => onDismiss(toast.id)}
|
|
137
|
+
>
|
|
138
|
+
<X size={14} />
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// src/components/Topbar.tsx — header with brand, project selector, search, tabs, ws status.
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
LayoutDashboard,
|
|
6
|
+
MessageSquare,
|
|
7
|
+
Bot,
|
|
8
|
+
Map,
|
|
9
|
+
Folder,
|
|
10
|
+
CheckSquare,
|
|
11
|
+
Settings2,
|
|
12
|
+
Sliders,
|
|
13
|
+
Puzzle,
|
|
14
|
+
Clock,
|
|
15
|
+
Search as SearchIcon,
|
|
16
|
+
ChevronDown,
|
|
17
|
+
Plus,
|
|
18
|
+
RefreshCw,
|
|
19
|
+
Power,
|
|
20
|
+
type LucideIcon,
|
|
21
|
+
} from 'lucide-react';
|
|
22
|
+
import { cn } from '../lib/utils';
|
|
23
|
+
import type { ProjectRecord, WsStatus } from '../lib/types';
|
|
24
|
+
import { api } from '../lib/api';
|
|
25
|
+
|
|
26
|
+
export type TabDef = {
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
icon: LucideIcon;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const TABS: TabDef[] = [
|
|
33
|
+
{ id: 'overview', label: 'Overview', icon: LayoutDashboard },
|
|
34
|
+
{ id: 'chat', label: 'Chat', icon: MessageSquare },
|
|
35
|
+
{ id: 'agents', label: 'Agents', icon: Bot },
|
|
36
|
+
{ id: 'plans', label: 'Plans', icon: Map },
|
|
37
|
+
{ id: 'tasks', label: 'Tasks', icon: CheckSquare },
|
|
38
|
+
{ id: 'mods', label: 'Mods', icon: Puzzle },
|
|
39
|
+
{ id: 'schedules', label: 'Schedules', icon: Clock },
|
|
40
|
+
{ id: 'config', label: 'Config', icon: Settings2 },
|
|
41
|
+
{ id: 'settings', label: 'Settings', icon: Sliders },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export type TopbarProps = {
|
|
45
|
+
activeTab: string;
|
|
46
|
+
onTabChange: (id: string) => void;
|
|
47
|
+
wsStatus: WsStatus;
|
|
48
|
+
version: string;
|
|
49
|
+
activeProject: ProjectRecord | null;
|
|
50
|
+
projects: ProjectRecord[];
|
|
51
|
+
onProjectChange: (id: string) => void;
|
|
52
|
+
onProjectsRefresh: () => void;
|
|
53
|
+
onOpenSearch: () => void;
|
|
54
|
+
rightSlot?: ReactNode;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function Topbar({
|
|
58
|
+
activeTab,
|
|
59
|
+
onTabChange,
|
|
60
|
+
wsStatus,
|
|
61
|
+
version,
|
|
62
|
+
activeProject,
|
|
63
|
+
projects,
|
|
64
|
+
onProjectChange,
|
|
65
|
+
onProjectsRefresh,
|
|
66
|
+
onOpenSearch,
|
|
67
|
+
rightSlot,
|
|
68
|
+
}: TopbarProps) {
|
|
69
|
+
return (
|
|
70
|
+
<header className="topbar">
|
|
71
|
+
<div className="brand">
|
|
72
|
+
<span className="brand-logo" aria-hidden>🪩</span>
|
|
73
|
+
<span className="brand-title">Bizar</span>
|
|
74
|
+
<span className="brand-version">{version}</span>
|
|
75
|
+
</div>
|
|
76
|
+
<ProjectSelector
|
|
77
|
+
activeProject={activeProject}
|
|
78
|
+
projects={projects}
|
|
79
|
+
onChange={onProjectChange}
|
|
80
|
+
onRefresh={onProjectsRefresh}
|
|
81
|
+
/>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
className="topbar-search"
|
|
85
|
+
onClick={onOpenSearch}
|
|
86
|
+
title="Search (Ctrl/Cmd+K)"
|
|
87
|
+
>
|
|
88
|
+
<SearchIcon size={14} />
|
|
89
|
+
<span className="muted">Search…</span>
|
|
90
|
+
<kbd>⌘K</kbd>
|
|
91
|
+
</button>
|
|
92
|
+
<nav className="tabs" role="tablist">
|
|
93
|
+
{TABS.map((tab) => {
|
|
94
|
+
const Icon = tab.icon;
|
|
95
|
+
const active = tab.id === activeTab;
|
|
96
|
+
return (
|
|
97
|
+
<button
|
|
98
|
+
key={tab.id}
|
|
99
|
+
type="button"
|
|
100
|
+
role="tab"
|
|
101
|
+
aria-selected={active}
|
|
102
|
+
className={cn('tab', active && 'tab-active')}
|
|
103
|
+
onClick={() => onTabChange(tab.id)}
|
|
104
|
+
title={tab.label}
|
|
105
|
+
>
|
|
106
|
+
<Icon size={14} className="tab-icon" />
|
|
107
|
+
<span className="tab-label">{tab.label}</span>
|
|
108
|
+
</button>
|
|
109
|
+
);
|
|
110
|
+
})}
|
|
111
|
+
</nav>
|
|
112
|
+
<div className="topbar-right">
|
|
113
|
+
{rightSlot}
|
|
114
|
+
<div className={cn('ws-status', `ws-${wsStatus}`)} title={`WebSocket: ${wsStatus}`}>
|
|
115
|
+
<span className="ws-dot" />
|
|
116
|
+
<span className="ws-label">{wsStatus}</span>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</header>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function ProjectSelector({
|
|
124
|
+
activeProject,
|
|
125
|
+
projects,
|
|
126
|
+
onChange,
|
|
127
|
+
onRefresh,
|
|
128
|
+
}: {
|
|
129
|
+
activeProject: ProjectRecord | null;
|
|
130
|
+
projects: ProjectRecord[];
|
|
131
|
+
onChange: (id: string) => void;
|
|
132
|
+
onRefresh: () => void;
|
|
133
|
+
}) {
|
|
134
|
+
const [open, setOpen] = useState(false);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!open) return;
|
|
138
|
+
const onClick = () => setOpen(false);
|
|
139
|
+
document.addEventListener('click', onClick);
|
|
140
|
+
return () => document.removeEventListener('click', onClick);
|
|
141
|
+
}, [open]);
|
|
142
|
+
|
|
143
|
+
const onAdd = async () => {
|
|
144
|
+
const path = (prompt('Project path:') || '').trim();
|
|
145
|
+
if (!path) return;
|
|
146
|
+
try {
|
|
147
|
+
await api.post('/projects', { path });
|
|
148
|
+
onRefresh();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
alert(`Add failed: ${(err as Error).message}`);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div className="project-selector" onClick={(e) => e.stopPropagation()}>
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
className="project-selector-btn"
|
|
159
|
+
onClick={() => setOpen((v) => !v)}
|
|
160
|
+
title="Switch active project"
|
|
161
|
+
>
|
|
162
|
+
<Folder size={14} />
|
|
163
|
+
<span className="project-selector-name">
|
|
164
|
+
{activeProject?.name || '(no project)'}
|
|
165
|
+
</span>
|
|
166
|
+
<ChevronDown size={12} />
|
|
167
|
+
</button>
|
|
168
|
+
{open && (
|
|
169
|
+
<div className="project-selector-menu">
|
|
170
|
+
<div className="project-selector-menu-head">
|
|
171
|
+
<span className="muted">{projects.length} project{projects.length === 1 ? '' : 's'}</span>
|
|
172
|
+
<div className="project-selector-menu-actions">
|
|
173
|
+
<button type="button" className="icon-btn" onClick={onRefresh} title="Refresh">
|
|
174
|
+
<RefreshCw size={12} />
|
|
175
|
+
</button>
|
|
176
|
+
<button type="button" className="icon-btn" onClick={onAdd} title="Add project">
|
|
177
|
+
<Plus size={12} />
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
<ul className="project-selector-list">
|
|
182
|
+
{projects.length === 0 && (
|
|
183
|
+
<li className="muted project-selector-empty">No projects. Add one to start.</li>
|
|
184
|
+
)}
|
|
185
|
+
{projects.map((p) => (
|
|
186
|
+
<li
|
|
187
|
+
key={p.id}
|
|
188
|
+
className={cn('project-selector-item', activeProject?.id === p.id && 'active')}
|
|
189
|
+
onClick={() => {
|
|
190
|
+
onChange(p.id);
|
|
191
|
+
setOpen(false);
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
<span className="project-selector-item-name">{p.name}</span>
|
|
195
|
+
<span className="project-selector-item-status">{p.status}</span>
|
|
196
|
+
</li>
|
|
197
|
+
))}
|
|
198
|
+
</ul>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Bizar Dashboard</title>
|
|
7
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🪩</text></svg>" />
|
|
8
|
+
<link rel="preconnect" href="https://rsms.me/" />
|
|
9
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
10
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
11
|
+
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
<script type="module" src="/main.tsx"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/lib/api.ts — REST client. Single instance, type-safe wrappers.
|
|
2
|
+
|
|
3
|
+
export class ApiError extends Error {
|
|
4
|
+
status: number;
|
|
5
|
+
data: unknown;
|
|
6
|
+
constructor(message: string, status: number, data?: unknown) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'ApiError';
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.data = data;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class ApiClient {
|
|
15
|
+
base = '/api';
|
|
16
|
+
|
|
17
|
+
async get<T>(path: string): Promise<T> {
|
|
18
|
+
return this.req<T>('GET', path);
|
|
19
|
+
}
|
|
20
|
+
async post<T>(path: string, body?: unknown): Promise<T> {
|
|
21
|
+
return this.req<T>('POST', path, body);
|
|
22
|
+
}
|
|
23
|
+
async put<T>(path: string, body?: unknown): Promise<T> {
|
|
24
|
+
return this.req<T>('PUT', path, body);
|
|
25
|
+
}
|
|
26
|
+
async patch<T>(path: string, body?: unknown): Promise<T> {
|
|
27
|
+
return this.req<T>('PATCH', path, body);
|
|
28
|
+
}
|
|
29
|
+
async del<T = unknown>(path: string): Promise<T> {
|
|
30
|
+
return this.req<T>('DELETE', path);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
34
|
+
const opts: RequestInit = {
|
|
35
|
+
method,
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
};
|
|
38
|
+
if (body !== undefined && body !== null) {
|
|
39
|
+
opts.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
40
|
+
}
|
|
41
|
+
const r = await fetch(this.base + path, opts);
|
|
42
|
+
const ct = r.headers.get('content-type') || '';
|
|
43
|
+
if (ct.includes('application/json')) {
|
|
44
|
+
const data = await r.json();
|
|
45
|
+
if (!r.ok) {
|
|
46
|
+
const msg =
|
|
47
|
+
(data && typeof data === 'object' && 'message' in data
|
|
48
|
+
? (data as { message?: string }).message
|
|
49
|
+
: undefined) || `${method} ${path}: ${r.status}`;
|
|
50
|
+
throw new ApiError(msg, r.status, data);
|
|
51
|
+
}
|
|
52
|
+
return data as T;
|
|
53
|
+
}
|
|
54
|
+
const text = await r.text();
|
|
55
|
+
if (!r.ok) {
|
|
56
|
+
throw new ApiError(
|
|
57
|
+
`${method} ${path}: ${r.status} — ${text.slice(0, 200)}`,
|
|
58
|
+
r.status,
|
|
59
|
+
text,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(text) as T;
|
|
64
|
+
} catch {
|
|
65
|
+
return text as unknown as T;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const api = new ApiClient();
|
|
71
|
+
export { ApiClient };
|