@polderlabs/bizar 2.6.1 → 3.0.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.
- package/cli/bin.mjs +158 -130
- package/cli/plan.test.mjs +2331 -0
- package/cli/service.mjs +309 -0
- package/package.json +19 -27
- package/cli/dashboard/api.mjs +0 -473
- package/cli/dashboard/browser.mjs +0 -40
- package/cli/dashboard/server.mjs +0 -366
- package/cli/dashboard/state.mjs +0 -438
- package/cli/dashboard/tasks-store.mjs +0 -203
- package/cli/dashboard/watcher.mjs +0 -81
- package/cli/dashboard.mjs +0 -97
- package/dist/assets/index-BVvY22Gt.css +0 -1
- package/dist/assets/index-CO3c8O32.js +0 -285
- package/dist/assets/index-CO3c8O32.js.map +0 -1
- package/dist/index.html +0 -18
- package/src/App.tsx +0 -233
- package/src/components/Button.tsx +0 -55
- package/src/components/Card.tsx +0 -40
- package/src/components/EmptyState.tsx +0 -30
- package/src/components/Modal.tsx +0 -137
- package/src/components/Spinner.tsx +0 -19
- package/src/components/StatusBadge.tsx +0 -25
- package/src/components/Tag.tsx +0 -28
- package/src/components/Toast.tsx +0 -142
- package/src/components/Topbar.tsx +0 -88
- package/src/index.html +0 -17
- package/src/lib/api.ts +0 -71
- package/src/lib/markdown.tsx +0 -59
- package/src/lib/types.ts +0 -200
- package/src/lib/utils.ts +0 -79
- package/src/lib/ws.ts +0 -132
- package/src/main.tsx +0 -12
- package/src/styles/main.css +0 -2324
- package/src/views/Agents.tsx +0 -199
- package/src/views/Chat.tsx +0 -255
- package/src/views/Config.tsx +0 -250
- package/src/views/Overview.tsx +0 -267
- package/src/views/Plans.tsx +0 -667
- package/src/views/Projects.tsx +0 -155
- package/src/views/Settings.tsx +0 -253
- package/src/views/Tasks.tsx +0 -567
- package/tsconfig.json +0 -23
- package/vite.config.ts +0 -24
package/src/views/Agents.tsx
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
// src/views/Agents.tsx — agent grid + invoke modal.
|
|
2
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
-
import { Bot, RefreshCw, Play, X } from 'lucide-react';
|
|
4
|
-
import { Button } from '../components/Button';
|
|
5
|
-
import { Card, CardTitle } from '../components/Card';
|
|
6
|
-
import { EmptyState } from '../components/EmptyState';
|
|
7
|
-
import { Spinner } from '../components/Spinner';
|
|
8
|
-
import { StatusBadge } from '../components/StatusBadge';
|
|
9
|
-
import { useModal } from '../components/Modal';
|
|
10
|
-
import { useToast } from '../components/Toast';
|
|
11
|
-
import { api } from '../lib/api';
|
|
12
|
-
import { formatRelative, truncate } from '../lib/utils';
|
|
13
|
-
import type { Agent, Settings, Snapshot } from '../lib/types';
|
|
14
|
-
|
|
15
|
-
type Props = {
|
|
16
|
-
snapshot: Snapshot;
|
|
17
|
-
settings: Settings;
|
|
18
|
-
activeTab: string;
|
|
19
|
-
setActiveTab: (id: string) => void;
|
|
20
|
-
refreshSnapshot: () => Promise<void>;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export function Agents({ snapshot }: Props) {
|
|
24
|
-
const toast = useToast();
|
|
25
|
-
const modal = useModal();
|
|
26
|
-
const [agents, setAgents] = useState<Agent[]>(snapshot.agents || []);
|
|
27
|
-
const [loading, setLoading] = useState(!snapshot.agents);
|
|
28
|
-
|
|
29
|
-
useEffect(() => {
|
|
30
|
-
if (snapshot.agents?.length) {
|
|
31
|
-
setAgents(snapshot.agents);
|
|
32
|
-
setLoading(false);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
let cancelled = false;
|
|
36
|
-
api
|
|
37
|
-
.get<{ agents: Agent[] }>('/agents')
|
|
38
|
-
.then((d) => {
|
|
39
|
-
if (!cancelled) {
|
|
40
|
-
setAgents(d.agents || []);
|
|
41
|
-
setLoading(false);
|
|
42
|
-
}
|
|
43
|
-
})
|
|
44
|
-
.catch((err) => {
|
|
45
|
-
if (!cancelled) {
|
|
46
|
-
setLoading(false);
|
|
47
|
-
toast.error(`Could not load agents: ${(err as Error).message}`);
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
return () => {
|
|
51
|
-
cancelled = true;
|
|
52
|
-
};
|
|
53
|
-
}, [snapshot.agents, toast]);
|
|
54
|
-
|
|
55
|
-
const sorted = useMemo(
|
|
56
|
-
() => [...agents].sort((a, b) => a.name.localeCompare(b.name)),
|
|
57
|
-
[agents],
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
const refresh = async () => {
|
|
61
|
-
try {
|
|
62
|
-
const d = await api.get<{ agents: Agent[] }>('/agents');
|
|
63
|
-
setAgents(d.agents || []);
|
|
64
|
-
toast.info('Agents refreshed.', 1500);
|
|
65
|
-
} catch (err) {
|
|
66
|
-
toast.error(`Refresh failed: ${(err as Error).message}`);
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
return (
|
|
71
|
-
<div className="view view-agents">
|
|
72
|
-
<header className="view-header">
|
|
73
|
-
<div className="view-header-text">
|
|
74
|
-
<h2 className="view-title">
|
|
75
|
-
<Bot size={18} /> Agents ({sorted.length})
|
|
76
|
-
</h2>
|
|
77
|
-
<p className="view-subtitle">
|
|
78
|
-
The Norse pantheon — 13 specialized agents across 4 cost tiers.
|
|
79
|
-
</p>
|
|
80
|
-
</div>
|
|
81
|
-
<div className="view-actions">
|
|
82
|
-
<Button variant="secondary" size="sm" onClick={refresh}>
|
|
83
|
-
<RefreshCw size={14} /> Refresh
|
|
84
|
-
</Button>
|
|
85
|
-
</div>
|
|
86
|
-
</header>
|
|
87
|
-
|
|
88
|
-
{loading ? (
|
|
89
|
-
<div className="view-loading">
|
|
90
|
-
<Spinner size="lg" />
|
|
91
|
-
</div>
|
|
92
|
-
) : sorted.length === 0 ? (
|
|
93
|
-
<EmptyState
|
|
94
|
-
icon={<Bot size={32} />}
|
|
95
|
-
title="No agents found"
|
|
96
|
-
message="Run bizar in the terminal to install Bizar."
|
|
97
|
-
/>
|
|
98
|
-
) : (
|
|
99
|
-
<div className="agent-grid">
|
|
100
|
-
{sorted.map((a) => (
|
|
101
|
-
<AgentCard key={a.name} agent={a} onInvoke={() => openInvoke(modal, a, toast, refresh)} />
|
|
102
|
-
))}
|
|
103
|
-
</div>
|
|
104
|
-
)}
|
|
105
|
-
</div>
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function AgentCard({
|
|
110
|
-
agent,
|
|
111
|
-
onInvoke,
|
|
112
|
-
}: {
|
|
113
|
-
agent: Agent;
|
|
114
|
-
onInvoke: () => void;
|
|
115
|
-
}) {
|
|
116
|
-
return (
|
|
117
|
-
<Card variant="elevated" interactive className="agent-card">
|
|
118
|
-
<div className="agent-card-head">
|
|
119
|
-
<div className="agent-card-name">{agent.name}</div>
|
|
120
|
-
<StatusBadge kind={agent.mode === 'primary' ? 'accent' : 'neutral'}>
|
|
121
|
-
{agent.mode || 'agent'}
|
|
122
|
-
</StatusBadge>
|
|
123
|
-
</div>
|
|
124
|
-
<p className="agent-card-desc">{truncate(agent.description, 200)}</p>
|
|
125
|
-
<div className="agent-card-meta">
|
|
126
|
-
<span className="mono" title={agent.model || ''}>
|
|
127
|
-
{agent.model || '—'}
|
|
128
|
-
</span>
|
|
129
|
-
<span className="tabular-nums muted">{formatRelative(agent.mtime)}</span>
|
|
130
|
-
</div>
|
|
131
|
-
<div className="agent-card-actions">
|
|
132
|
-
<Button variant="primary" size="sm" onClick={onInvoke}>
|
|
133
|
-
<Play size={12} /> Invoke
|
|
134
|
-
</Button>
|
|
135
|
-
</div>
|
|
136
|
-
</Card>
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function openInvoke(
|
|
141
|
-
modal: ReturnType<typeof useModal>,
|
|
142
|
-
agent: Agent,
|
|
143
|
-
toast: ReturnType<typeof useToast>,
|
|
144
|
-
refresh: () => Promise<void>,
|
|
145
|
-
) {
|
|
146
|
-
let promptEl: HTMLTextAreaElement | null = null;
|
|
147
|
-
|
|
148
|
-
const submit = async () => {
|
|
149
|
-
const prompt = (promptEl?.value || '').trim();
|
|
150
|
-
if (!prompt) {
|
|
151
|
-
toast.warning('Prompt is required.');
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
try {
|
|
155
|
-
await api.post(`/agents/${encodeURIComponent(agent.name)}/invoke`, { prompt });
|
|
156
|
-
toast.success(`Invoked ${agent.name}.`);
|
|
157
|
-
modal.close();
|
|
158
|
-
await refresh();
|
|
159
|
-
} catch (err) {
|
|
160
|
-
toast.error(`Invoke failed: ${(err as Error).message}`);
|
|
161
|
-
}
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
modal.open({
|
|
165
|
-
title: `Invoke ${agent.name}`,
|
|
166
|
-
width: 560,
|
|
167
|
-
children: (
|
|
168
|
-
<div className="invoke-form">
|
|
169
|
-
<p className="muted invoke-form-meta mono">
|
|
170
|
-
{agent.model || '—'} · {agent.path}
|
|
171
|
-
</p>
|
|
172
|
-
<p className="invoke-form-desc">{agent.description}</p>
|
|
173
|
-
<label className="field-label" htmlFor="invoke-prompt">
|
|
174
|
-
Prompt
|
|
175
|
-
</label>
|
|
176
|
-
<textarea
|
|
177
|
-
ref={(el) => {
|
|
178
|
-
promptEl = el;
|
|
179
|
-
}}
|
|
180
|
-
id="invoke-prompt"
|
|
181
|
-
className="textarea"
|
|
182
|
-
rows={5}
|
|
183
|
-
placeholder="What should this agent do?"
|
|
184
|
-
autoFocus
|
|
185
|
-
/>
|
|
186
|
-
</div>
|
|
187
|
-
),
|
|
188
|
-
footer: (
|
|
189
|
-
<div className="modal-footer-actions">
|
|
190
|
-
<Button variant="ghost" onClick={() => modal.close()}>
|
|
191
|
-
<X size={14} /> Cancel
|
|
192
|
-
</Button>
|
|
193
|
-
<Button variant="primary" onClick={submit}>
|
|
194
|
-
<Play size={14} /> Invoke
|
|
195
|
-
</Button>
|
|
196
|
-
</div>
|
|
197
|
-
),
|
|
198
|
-
});
|
|
199
|
-
}
|
package/src/views/Chat.tsx
DELETED
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
// src/views/Chat.tsx — chat history + send box + slash suggestions.
|
|
2
|
-
import { useEffect, useRef, useState } from 'react';
|
|
3
|
-
import ReactMarkdown from 'react-markdown';
|
|
4
|
-
import remarkGfm from 'remark-gfm';
|
|
5
|
-
import { MessageSquare, Send, RefreshCw, Eraser, CornerDownLeft } from 'lucide-react';
|
|
6
|
-
import { Button } from '../components/Button';
|
|
7
|
-
import { EmptyState } from '../components/EmptyState';
|
|
8
|
-
import { Spinner } from '../components/Spinner';
|
|
9
|
-
import { useToast } from '../components/Toast';
|
|
10
|
-
import { api } from '../lib/api';
|
|
11
|
-
import { cn, formatTime } from '../lib/utils';
|
|
12
|
-
import type { ChatMessage, ChatResponse, Settings, Snapshot } from '../lib/types';
|
|
13
|
-
|
|
14
|
-
type Props = {
|
|
15
|
-
snapshot: Snapshot;
|
|
16
|
-
settings: Settings;
|
|
17
|
-
activeTab: string;
|
|
18
|
-
setActiveTab: (id: string) => void;
|
|
19
|
-
refreshSnapshot: () => Promise<void>;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
type SlashCommand = { cmd: string; desc: string };
|
|
23
|
-
|
|
24
|
-
const SLASH_COMMANDS: SlashCommand[] = [
|
|
25
|
-
{ cmd: '/plan new <slug>', desc: 'Create a new plan' },
|
|
26
|
-
{ cmd: '/plan list', desc: 'List plans' },
|
|
27
|
-
{ cmd: '/plan open <slug>', desc: 'Open a plan URL' },
|
|
28
|
-
{ cmd: '/plan status <slug> <status>', desc: 'Set plan status' },
|
|
29
|
-
{ cmd: '/visual-plan on|off', desc: 'Toggle visual plan mode' },
|
|
30
|
-
{ cmd: '/bizar', desc: 'Show Bizar menu / launch dashboard' },
|
|
31
|
-
{ cmd: '/audit', desc: 'Run security audit' },
|
|
32
|
-
{ cmd: '/explain <q>', desc: 'Read-only code Q&A' },
|
|
33
|
-
{ cmd: '/init', desc: 'Initialize .bizar/ in this project' },
|
|
34
|
-
{ cmd: '/learn', desc: 'Extract patterns from session' },
|
|
35
|
-
{ cmd: '/pr-review', desc: 'PR review with @mimir + @forseti' },
|
|
36
|
-
{ cmd: '/help', desc: 'Show all slash commands' },
|
|
37
|
-
];
|
|
38
|
-
|
|
39
|
-
export function Chat(_: Props) {
|
|
40
|
-
const toast = useToast();
|
|
41
|
-
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
42
|
-
const [sessions, setSessions] = useState<{ id: string; mtime: number }[]>(
|
|
43
|
-
[],
|
|
44
|
-
);
|
|
45
|
-
const [loading, setLoading] = useState(true);
|
|
46
|
-
const [sessionId, setSessionId] = useState<string>('');
|
|
47
|
-
const [text, setText] = useState('');
|
|
48
|
-
const [sending, setSending] = useState(false);
|
|
49
|
-
const [suggestions, setSuggestions] = useState<SlashCommand[]>([]);
|
|
50
|
-
const listRef = useRef<HTMLDivElement>(null);
|
|
51
|
-
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
52
|
-
|
|
53
|
-
const loadChat = async (sid?: string) => {
|
|
54
|
-
try {
|
|
55
|
-
const url = sid ? `/chat?session=${encodeURIComponent(sid)}` : '/chat?limit=200';
|
|
56
|
-
const data = await api.get<ChatResponse>(url);
|
|
57
|
-
setMessages(data.messages || []);
|
|
58
|
-
setSessions(data.sessions || []);
|
|
59
|
-
} catch (err) {
|
|
60
|
-
toast.error(`Chat load failed: ${(err as Error).message}`);
|
|
61
|
-
} finally {
|
|
62
|
-
setLoading(false);
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
loadChat();
|
|
68
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
|
-
}, []);
|
|
70
|
-
|
|
71
|
-
// Auto-scroll on new messages
|
|
72
|
-
useEffect(() => {
|
|
73
|
-
if (listRef.current) {
|
|
74
|
-
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
75
|
-
}
|
|
76
|
-
}, [messages]);
|
|
77
|
-
|
|
78
|
-
// Slash-command suggestions
|
|
79
|
-
useEffect(() => {
|
|
80
|
-
if (text.startsWith('/') && !text.includes(' ')) {
|
|
81
|
-
const q = text.toLowerCase();
|
|
82
|
-
setSuggestions(
|
|
83
|
-
SLASH_COMMANDS.filter((c) => c.cmd.toLowerCase().startsWith(q)).slice(
|
|
84
|
-
0,
|
|
85
|
-
6,
|
|
86
|
-
),
|
|
87
|
-
);
|
|
88
|
-
} else {
|
|
89
|
-
setSuggestions([]);
|
|
90
|
-
}
|
|
91
|
-
}, [text]);
|
|
92
|
-
|
|
93
|
-
const onSend = async () => {
|
|
94
|
-
const message = text.trim();
|
|
95
|
-
if (!message || sending) return;
|
|
96
|
-
setSending(true);
|
|
97
|
-
const optimistic: ChatMessage = {
|
|
98
|
-
role: 'user',
|
|
99
|
-
content: message,
|
|
100
|
-
ts: new Date().toISOString(),
|
|
101
|
-
};
|
|
102
|
-
setMessages((cur) => [...cur, optimistic]);
|
|
103
|
-
setText('');
|
|
104
|
-
setSuggestions([]);
|
|
105
|
-
try {
|
|
106
|
-
await api.post('/chat', { message });
|
|
107
|
-
toast.success('Message accepted. Open the TUI to dispatch.');
|
|
108
|
-
} catch (err) {
|
|
109
|
-
setMessages((cur) => cur.filter((m) => m !== optimistic));
|
|
110
|
-
toast.error(`Send failed: ${(err as Error).message}`);
|
|
111
|
-
} finally {
|
|
112
|
-
setSending(false);
|
|
113
|
-
inputRef.current?.focus();
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
118
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
119
|
-
e.preventDefault();
|
|
120
|
-
onSend();
|
|
121
|
-
} else if (e.key === 'Tab' && suggestions.length) {
|
|
122
|
-
e.preventDefault();
|
|
123
|
-
const first = suggestions[0];
|
|
124
|
-
const base = first.cmd.split(' ')[0];
|
|
125
|
-
setText(`${base} `);
|
|
126
|
-
setSuggestions([]);
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
// Sort newest at the bottom for chat feel
|
|
131
|
-
const ordered = [...messages].reverse();
|
|
132
|
-
|
|
133
|
-
return (
|
|
134
|
-
<div className="view view-chat">
|
|
135
|
-
<header className="view-header chat-header">
|
|
136
|
-
<div className="view-header-text">
|
|
137
|
-
<h2 className="view-title">
|
|
138
|
-
<MessageSquare size={18} /> Chat
|
|
139
|
-
</h2>
|
|
140
|
-
<p className="view-subtitle">
|
|
141
|
-
Conversation history. Press Enter to send, Shift+Enter for newline.
|
|
142
|
-
</p>
|
|
143
|
-
</div>
|
|
144
|
-
<div className="view-actions">
|
|
145
|
-
<select
|
|
146
|
-
className="select select-sm"
|
|
147
|
-
value={sessionId}
|
|
148
|
-
onChange={(e) => {
|
|
149
|
-
const sid = e.target.value;
|
|
150
|
-
setSessionId(sid);
|
|
151
|
-
loadChat(sid || undefined);
|
|
152
|
-
}}
|
|
153
|
-
>
|
|
154
|
-
<option value="">All sessions ({sessions.length})</option>
|
|
155
|
-
{sessions.map((s) => (
|
|
156
|
-
<option key={s.id} value={s.id}>
|
|
157
|
-
{s.id.slice(0, 28)}… ·{' '}
|
|
158
|
-
{new Date(s.mtime).toLocaleDateString()}
|
|
159
|
-
</option>
|
|
160
|
-
))}
|
|
161
|
-
</select>
|
|
162
|
-
<Button
|
|
163
|
-
variant="secondary"
|
|
164
|
-
size="sm"
|
|
165
|
-
onClick={() => loadChat(sessionId || undefined)}
|
|
166
|
-
>
|
|
167
|
-
<RefreshCw size={14} /> Refresh
|
|
168
|
-
</Button>
|
|
169
|
-
<Button variant="ghost" size="sm" onClick={() => setMessages([])}>
|
|
170
|
-
<Eraser size={14} /> Clear view
|
|
171
|
-
</Button>
|
|
172
|
-
</div>
|
|
173
|
-
</header>
|
|
174
|
-
|
|
175
|
-
<div className="chat-container">
|
|
176
|
-
<div className="chat-list" ref={listRef}>
|
|
177
|
-
{loading ? (
|
|
178
|
-
<div className="view-loading">
|
|
179
|
-
<Spinner />
|
|
180
|
-
</div>
|
|
181
|
-
) : ordered.length === 0 ? (
|
|
182
|
-
<EmptyState
|
|
183
|
-
icon={<MessageSquare size={28} />}
|
|
184
|
-
title="No messages yet"
|
|
185
|
-
message="Type something below to start."
|
|
186
|
-
/>
|
|
187
|
-
) : (
|
|
188
|
-
ordered.map((m, idx) => <ChatBubble key={idx} message={m} />)
|
|
189
|
-
)}
|
|
190
|
-
</div>
|
|
191
|
-
|
|
192
|
-
<div className="chat-composer">
|
|
193
|
-
{suggestions.length > 0 && (
|
|
194
|
-
<div className="chat-suggestions">
|
|
195
|
-
{suggestions.map((s) => (
|
|
196
|
-
<button
|
|
197
|
-
type="button"
|
|
198
|
-
key={s.cmd}
|
|
199
|
-
className="chat-suggestion"
|
|
200
|
-
onClick={() => {
|
|
201
|
-
const base = s.cmd.split(' ')[0];
|
|
202
|
-
setText(`${base} `);
|
|
203
|
-
inputRef.current?.focus();
|
|
204
|
-
}}
|
|
205
|
-
>
|
|
206
|
-
<span className="mono">{s.cmd}</span>
|
|
207
|
-
<span className="chat-suggestion-desc">{s.desc}</span>
|
|
208
|
-
</button>
|
|
209
|
-
))}
|
|
210
|
-
</div>
|
|
211
|
-
)}
|
|
212
|
-
<div className="chat-composer-row">
|
|
213
|
-
<textarea
|
|
214
|
-
ref={inputRef}
|
|
215
|
-
className="chat-input"
|
|
216
|
-
placeholder="Send a message… (Enter to send, Shift+Enter for newline, / for commands)"
|
|
217
|
-
rows={2}
|
|
218
|
-
value={text}
|
|
219
|
-
onChange={(e) => setText(e.target.value)}
|
|
220
|
-
onKeyDown={onKeyDown}
|
|
221
|
-
disabled={sending}
|
|
222
|
-
/>
|
|
223
|
-
<Button
|
|
224
|
-
variant="primary"
|
|
225
|
-
onClick={onSend}
|
|
226
|
-
disabled={sending || !text.trim()}
|
|
227
|
-
>
|
|
228
|
-
{sending ? <Spinner size="sm" /> : <Send size={14} />}{' '}
|
|
229
|
-
<span>Send</span>{' '}
|
|
230
|
-
<CornerDownLeft size={12} className="hint-key" />
|
|
231
|
-
</Button>
|
|
232
|
-
</div>
|
|
233
|
-
</div>
|
|
234
|
-
</div>
|
|
235
|
-
</div>
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function ChatBubble({ message }: { message: ChatMessage }) {
|
|
240
|
-
const role = (message.role || 'assistant').toLowerCase();
|
|
241
|
-
const ts = message.ts ? formatTime(message.ts) : '';
|
|
242
|
-
const content = message.content || message.message || '';
|
|
243
|
-
return (
|
|
244
|
-
<div className={cn('chat-msg', `chat-msg-${role}`)}>
|
|
245
|
-
<div className="chat-msg-meta">
|
|
246
|
-
<span className="chat-msg-role">{role}</span>
|
|
247
|
-
{message.agent && <span className="chat-msg-agent">{message.agent}</span>}
|
|
248
|
-
{ts && <span className="chat-msg-ts tabular-nums">{ts}</span>}
|
|
249
|
-
</div>
|
|
250
|
-
<div className="chat-msg-body markdown-body">
|
|
251
|
-
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
|
252
|
-
</div>
|
|
253
|
-
</div>
|
|
254
|
-
);
|
|
255
|
-
}
|
package/src/views/Config.tsx
DELETED
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
// src/views/Config.tsx — opencode.json editor with live validation.
|
|
2
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import { Settings2, RefreshCw, Save, FileCode2 } from 'lucide-react';
|
|
4
|
-
import { Button } from '../components/Button';
|
|
5
|
-
import { Card, CardTitle, CardMeta } from '../components/Card';
|
|
6
|
-
import { useToast } from '../components/Toast';
|
|
7
|
-
import { api } from '../lib/api';
|
|
8
|
-
import { cn, debounce, hashText } from '../lib/utils';
|
|
9
|
-
import { JsonHighlight } from '../lib/markdown';
|
|
10
|
-
import type { ConfigResponse, Settings, Snapshot } from '../lib/types';
|
|
11
|
-
|
|
12
|
-
type Props = {
|
|
13
|
-
snapshot: Snapshot;
|
|
14
|
-
settings: Settings;
|
|
15
|
-
activeTab: string;
|
|
16
|
-
setActiveTab: (id: string) => void;
|
|
17
|
-
refreshSnapshot: () => Promise<void>;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
type Parsed = {
|
|
21
|
-
raw: string;
|
|
22
|
-
data: unknown | null;
|
|
23
|
-
error: string | null;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export function Config({ snapshot }: Props) {
|
|
27
|
-
const toast = useToast();
|
|
28
|
-
const initial: ConfigResponse | undefined = snapshot.config;
|
|
29
|
-
const [original, setOriginal] = useState<string>(
|
|
30
|
-
initial?.raw || (initial?.data ? JSON.stringify(initial.data, null, 2) : ''),
|
|
31
|
-
);
|
|
32
|
-
const [parsed, setParsed] = useState<Parsed>({
|
|
33
|
-
raw: initial?.raw || '',
|
|
34
|
-
data: initial?.data ?? null,
|
|
35
|
-
error: null,
|
|
36
|
-
});
|
|
37
|
-
const [dirty, setDirty] = useState(false);
|
|
38
|
-
const [saving, setSaving] = useState(false);
|
|
39
|
-
const [path, setPath] = useState<string>(initial?.path || '');
|
|
40
|
-
const [reloadPending, setReloadPending] = useState(false);
|
|
41
|
-
const taRef = useRef<HTMLTextAreaElement>(null);
|
|
42
|
-
|
|
43
|
-
const originalHash = useMemo(() => hashText(original), [original]);
|
|
44
|
-
|
|
45
|
-
// Refresh from server
|
|
46
|
-
const reload = async () => {
|
|
47
|
-
try {
|
|
48
|
-
const d = await api.post<ConfigResponse>('/config/reload');
|
|
49
|
-
const raw = d.raw || (d.data ? JSON.stringify(d.data, null, 2) : '');
|
|
50
|
-
setOriginal(raw);
|
|
51
|
-
setParsed({ raw, data: d.data ?? null, error: null });
|
|
52
|
-
setDirty(false);
|
|
53
|
-
setPath(d.path || '');
|
|
54
|
-
toast.info('Config reloaded.', 1500);
|
|
55
|
-
} catch (err) {
|
|
56
|
-
toast.error(`Reload failed: ${(err as Error).message}`);
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const onChange = (val: string) => {
|
|
61
|
-
setParsed((cur) => {
|
|
62
|
-
const next: Parsed = { raw: val, data: cur.data, error: null };
|
|
63
|
-
try {
|
|
64
|
-
next.data = JSON.parse(val);
|
|
65
|
-
next.error = null;
|
|
66
|
-
} catch (e) {
|
|
67
|
-
next.data = null;
|
|
68
|
-
next.error = (e as Error).message;
|
|
69
|
-
}
|
|
70
|
-
return next;
|
|
71
|
-
});
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
// Debounce sync of dirty state to avoid setState storms
|
|
75
|
-
const onChangeDebounced = useMemo(
|
|
76
|
-
() =>
|
|
77
|
-
debounce((val: string) => {
|
|
78
|
-
setDirty(hashText(val) !== originalHash);
|
|
79
|
-
}, 100),
|
|
80
|
-
[originalHash],
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
84
|
-
const v = e.target.value;
|
|
85
|
-
onChange(v);
|
|
86
|
-
onChangeDebounced(v);
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
// Warn before navigating away with unsaved changes
|
|
90
|
-
useEffect(() => {
|
|
91
|
-
if (!dirty && !reloadPending) return;
|
|
92
|
-
const handler = (e: BeforeUnloadEvent) => {
|
|
93
|
-
if (dirty) {
|
|
94
|
-
e.preventDefault();
|
|
95
|
-
e.returnValue = '';
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
window.addEventListener('beforeunload', handler);
|
|
99
|
-
return () => window.removeEventListener('beforeunload', handler);
|
|
100
|
-
}, [dirty, reloadPending]);
|
|
101
|
-
|
|
102
|
-
const save = async () => {
|
|
103
|
-
if (!parsed.data || parsed.error || saving) return;
|
|
104
|
-
setSaving(true);
|
|
105
|
-
try {
|
|
106
|
-
const result = await api.put<ConfigResponse>('/config', parsed.data);
|
|
107
|
-
const raw = result.raw || JSON.stringify(result.data, null, 2);
|
|
108
|
-
setOriginal(raw);
|
|
109
|
-
setParsed({ raw, data: result.data ?? null, error: null });
|
|
110
|
-
setDirty(false);
|
|
111
|
-
toast.success('Config saved.');
|
|
112
|
-
} catch (err) {
|
|
113
|
-
toast.error(`Save failed: ${(err as Error).message}`);
|
|
114
|
-
} finally {
|
|
115
|
-
setSaving(false);
|
|
116
|
-
}
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const canSave = !!parsed.data && !parsed.error && dirty && !saving;
|
|
120
|
-
|
|
121
|
-
return (
|
|
122
|
-
<div className="view view-config">
|
|
123
|
-
<header className="view-header">
|
|
124
|
-
<div className="view-header-text">
|
|
125
|
-
<h2 className="view-title">
|
|
126
|
-
<Settings2 size={18} /> Config
|
|
127
|
-
</h2>
|
|
128
|
-
<p className="view-subtitle mono ellipsis" title={path}>
|
|
129
|
-
{path}
|
|
130
|
-
</p>
|
|
131
|
-
</div>
|
|
132
|
-
<div className="view-actions">
|
|
133
|
-
<Button
|
|
134
|
-
variant="secondary"
|
|
135
|
-
size="sm"
|
|
136
|
-
onClick={() => {
|
|
137
|
-
if (dirty) {
|
|
138
|
-
setReloadPending(true);
|
|
139
|
-
if (
|
|
140
|
-
// eslint-disable-next-line no-alert
|
|
141
|
-
confirm('Discard unsaved changes and reload from disk?')
|
|
142
|
-
) {
|
|
143
|
-
reload();
|
|
144
|
-
}
|
|
145
|
-
setReloadPending(false);
|
|
146
|
-
} else {
|
|
147
|
-
reload();
|
|
148
|
-
}
|
|
149
|
-
}}
|
|
150
|
-
>
|
|
151
|
-
<RefreshCw size={14} /> Reload from disk
|
|
152
|
-
</Button>
|
|
153
|
-
<Button
|
|
154
|
-
variant="primary"
|
|
155
|
-
size="sm"
|
|
156
|
-
disabled={!canSave}
|
|
157
|
-
onClick={save}
|
|
158
|
-
>
|
|
159
|
-
{saving ? (
|
|
160
|
-
<span className="btn-spinner" />
|
|
161
|
-
) : (
|
|
162
|
-
<Save size={14} />
|
|
163
|
-
)}
|
|
164
|
-
{saving ? 'Saving…' : 'Save'}
|
|
165
|
-
</Button>
|
|
166
|
-
</div>
|
|
167
|
-
</header>
|
|
168
|
-
|
|
169
|
-
<div className="config-grid">
|
|
170
|
-
<Card>
|
|
171
|
-
<CardTitle>
|
|
172
|
-
<FileCode2 size={14} /> JSON tree
|
|
173
|
-
</CardTitle>
|
|
174
|
-
<CardMeta>Parsed from current editor content</CardMeta>
|
|
175
|
-
<div className="json-tree">
|
|
176
|
-
{parsed.data != null ? (
|
|
177
|
-
<JsonHighlight value={parsed.data} />
|
|
178
|
-
) : (
|
|
179
|
-
<span className="muted">
|
|
180
|
-
{parsed.error ? 'Invalid JSON' : 'No data'}
|
|
181
|
-
</span>
|
|
182
|
-
)}
|
|
183
|
-
</div>
|
|
184
|
-
</Card>
|
|
185
|
-
<Card>
|
|
186
|
-
<CardTitle>Raw JSON</CardTitle>
|
|
187
|
-
<CardMeta>
|
|
188
|
-
{parsed.error ? (
|
|
189
|
-
<span className="text-error">{parsed.error}</span>
|
|
190
|
-
) : (
|
|
191
|
-
<span className="muted">Live validation as you type</span>
|
|
192
|
-
)}
|
|
193
|
-
</CardMeta>
|
|
194
|
-
<textarea
|
|
195
|
-
ref={taRef}
|
|
196
|
-
className={cn('textarea config-textarea', parsed.error && 'invalid')}
|
|
197
|
-
spellCheck={false}
|
|
198
|
-
value={parsed.raw}
|
|
199
|
-
onChange={handleInput}
|
|
200
|
-
/>
|
|
201
|
-
</Card>
|
|
202
|
-
</div>
|
|
203
|
-
|
|
204
|
-
<Card className="diff-card">
|
|
205
|
-
<CardTitle>Diff vs last save</CardTitle>
|
|
206
|
-
<CardMeta>
|
|
207
|
-
{parsed.raw === original
|
|
208
|
-
? 'No changes.'
|
|
209
|
-
: 'Line-level changes from disk.'}
|
|
210
|
-
</CardMeta>
|
|
211
|
-
<div className="diff-view mono">
|
|
212
|
-
{renderDiff(original, parsed.raw)}
|
|
213
|
-
</div>
|
|
214
|
-
</Card>
|
|
215
|
-
</div>
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function renderDiff(a: string, b: string) {
|
|
220
|
-
if (a === b) return null;
|
|
221
|
-
const aLines = (a || '').split('\n');
|
|
222
|
-
const bLines = (b || '').split('\n');
|
|
223
|
-
const max = Math.max(aLines.length, bLines.length);
|
|
224
|
-
const out: React.ReactNode[] = [];
|
|
225
|
-
for (let i = 0; i < max; i++) {
|
|
226
|
-
const al = aLines[i] ?? '';
|
|
227
|
-
const bl = bLines[i] ?? '';
|
|
228
|
-
if (al === bl) {
|
|
229
|
-
out.push(
|
|
230
|
-
<div key={i} className="diff-line">
|
|
231
|
-
{al || ' '}
|
|
232
|
-
</div>,
|
|
233
|
-
);
|
|
234
|
-
} else {
|
|
235
|
-
if (al)
|
|
236
|
-
out.push(
|
|
237
|
-
<div key={`a${i}`} className="diff-line diff-line-removed">
|
|
238
|
-
- {al}
|
|
239
|
-
</div>,
|
|
240
|
-
);
|
|
241
|
-
if (bl)
|
|
242
|
-
out.push(
|
|
243
|
-
<div key={`b${i}`} className="diff-line diff-line-added">
|
|
244
|
-
+ {bl}
|
|
245
|
-
</div>,
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
return out;
|
|
250
|
-
}
|