agentgui 1.0.939 → 1.0.941
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/AGENTS.md +12 -7
- package/lib/claude-runner-agents.js +25 -0
- package/lib/ws-handlers-util.js +27 -1
- package/package.json +1 -1
- package/server.js +10 -1
- package/site/app/index.html +14 -37
- package/site/app/js/app.js +506 -104
- package/site/app/js/backend.js +44 -32
- package/site/app/vendor/anentrypoint-design/247420.css +274 -86
- package/site/app/vendor/anentrypoint-design/247420.js +12 -12
- package/site/app/vendor/cdn/dompurify.js +9 -0
- package/site/app/vendor/cdn/fonts/1291de6d401a.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/1ba89a87e0b8.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/3644d51c507b.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/4b91d2650dc2.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/530d036ba64a.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/570a2bdd8f8b.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/5dd6d880fee9.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/62de9143afe3.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/64884efa2f11.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/68cd7063be2e.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/6c252abcf99b.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/71e69e06516a.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/9ea68c62083f.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/c010f9b7d6b2.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/d69723fc74be.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/fonts.css +459 -0
- package/site/app/vendor/cdn/marked.js +8 -0
- package/site/app/vendor/cdn/prismjs/components/prism-bash.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-clike.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-core.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-css.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-diff.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-go.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-javascript.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-json.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-jsx.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-markdown.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-markup.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-python.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-rust.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-sql.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-toml.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-tsx.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-typescript.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-yaml.min.js +1 -0
package/site/app/js/app.js
CHANGED
|
@@ -10,8 +10,11 @@ const state = {
|
|
|
10
10
|
backendDraft: B.getBackend(),
|
|
11
11
|
health: { status: 'unknown' },
|
|
12
12
|
tab: 'chat',
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
agents: [],
|
|
14
|
+
selectedAgent: localStorage.getItem('agentgui.agent') || '',
|
|
15
|
+
agentModels: [],
|
|
16
|
+
selectedModel: localStorage.getItem('agentgui.model') || '',
|
|
17
|
+
chatCwd: localStorage.getItem('agentgui.cwd') || '',
|
|
15
18
|
chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null },
|
|
16
19
|
sessions: [],
|
|
17
20
|
selectedSid: null,
|
|
@@ -23,15 +26,32 @@ const state = {
|
|
|
23
26
|
sessionsLimit: 60,
|
|
24
27
|
projectFilter: '',
|
|
25
28
|
live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0, reconnects: 0 },
|
|
29
|
+
active: [],
|
|
30
|
+
activeTimer: null,
|
|
26
31
|
};
|
|
27
32
|
|
|
28
33
|
function readHash() {
|
|
29
|
-
const
|
|
30
|
-
|
|
34
|
+
const hash = location.hash || '';
|
|
35
|
+
const sidM = hash.match(/sid=([^&]+)/);
|
|
36
|
+
const tabM = hash.match(/tab=([^&]+)/);
|
|
37
|
+
return {
|
|
38
|
+
sid: sidM ? decodeURIComponent(sidM[1]) : null,
|
|
39
|
+
tab: tabM ? decodeURIComponent(tabM[1]) : null,
|
|
40
|
+
};
|
|
31
41
|
}
|
|
32
|
-
function
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
42
|
+
function buildHash(tab, sid) {
|
|
43
|
+
const parts = [];
|
|
44
|
+
if (tab && tab !== 'chat') parts.push('tab=' + encodeURIComponent(tab));
|
|
45
|
+
if (sid) parts.push('sid=' + encodeURIComponent(sid));
|
|
46
|
+
return parts.length ? '#' + parts.join('&') : '';
|
|
47
|
+
}
|
|
48
|
+
function writeHash(sid, { push = false } = {}) {
|
|
49
|
+
const h = buildHash(state.tab, sid);
|
|
50
|
+
const url = location.pathname + location.search + h;
|
|
51
|
+
if (location.hash === h) return;
|
|
52
|
+
// pushState for session selection so Back returns to the session list;
|
|
53
|
+
// replaceState for tab-only changes.
|
|
54
|
+
(push ? history.pushState : history.replaceState).call(history, null, '', url);
|
|
35
55
|
}
|
|
36
56
|
function fmtRelTime(ts) {
|
|
37
57
|
if (!ts) return '';
|
|
@@ -50,7 +70,8 @@ function scheduleRender() {
|
|
|
50
70
|
requestAnimationFrame(() => { renderScheduled = false; render(); });
|
|
51
71
|
}
|
|
52
72
|
|
|
53
|
-
|
|
73
|
+
const NARROW_BP = 640; // unified with the CSS touch-target breakpoint in index.html
|
|
74
|
+
function isNarrow() { return typeof window !== 'undefined' && window.innerWidth < NARROW_BP; }
|
|
54
75
|
function truncate(str, mobileLen, desktopLen) {
|
|
55
76
|
const s = String(str ?? '');
|
|
56
77
|
const max = isNarrow() ? mobileLen : desktopLen;
|
|
@@ -61,6 +82,9 @@ function debounce(fn, ms) {
|
|
|
61
82
|
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
|
62
83
|
}
|
|
63
84
|
|
|
85
|
+
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
|
|
86
|
+
function lsRemove(k) { try { localStorage.removeItem(k); } catch {} }
|
|
87
|
+
|
|
64
88
|
function pillButton(key, label, active, title, onClick) {
|
|
65
89
|
return h('button', {
|
|
66
90
|
key,
|
|
@@ -74,8 +98,11 @@ function pillButton(key, label, active, title, onClick) {
|
|
|
74
98
|
|
|
75
99
|
function scrollChatToBottom() {
|
|
76
100
|
requestAnimationFrame(() => {
|
|
77
|
-
|
|
78
|
-
|
|
101
|
+
// The design system's chat scroll container is .chat-thread; fall back to
|
|
102
|
+
// the main region only if it's not mounted yet.
|
|
103
|
+
const scroller = document.querySelector('.chat-thread')
|
|
104
|
+
|| document.querySelector('#agentgui-main')
|
|
105
|
+
|| document.getElementById('app');
|
|
79
106
|
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
|
80
107
|
});
|
|
81
108
|
}
|
|
@@ -85,16 +112,74 @@ function timeNow() {
|
|
|
85
112
|
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
86
113
|
}
|
|
87
114
|
|
|
115
|
+
async function selectAgent(id) {
|
|
116
|
+
state.selectedAgent = id;
|
|
117
|
+
localStorage.setItem('agentgui.agent', id);
|
|
118
|
+
state.agentModels = [];
|
|
119
|
+
state.selectedModel = '';
|
|
120
|
+
render();
|
|
121
|
+
const models = await B.listAgentModels(state.backend, id);
|
|
122
|
+
if (state.selectedAgent !== id) return; // changed while loading
|
|
123
|
+
state.agentModels = models;
|
|
124
|
+
const saved = localStorage.getItem('agentgui.model');
|
|
125
|
+
state.selectedModel = (saved && models.some(m => m.id === saved)) ? saved : (models[0]?.id || '');
|
|
126
|
+
render();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function selectModel(id) {
|
|
130
|
+
state.selectedModel = id;
|
|
131
|
+
localStorage.setItem('agentgui.model', id);
|
|
132
|
+
render();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function agentById(id) { return state.agents.find(a => a.id === id); }
|
|
136
|
+
function agentAvailable(id) { const a = agentById(id); return !a || a.available !== false; }
|
|
137
|
+
|
|
88
138
|
function navTo(tab) {
|
|
89
139
|
const prev = state.tab;
|
|
90
140
|
state.tab = tab;
|
|
141
|
+
// Live history SSE is only needed on the history tab; active-chat polling
|
|
142
|
+
// runs globally (started at boot) so running chats show on every tab.
|
|
91
143
|
if (tab === 'history') {
|
|
92
144
|
refreshHistory();
|
|
93
145
|
openLiveStream();
|
|
94
146
|
} else if (prev === 'history') {
|
|
95
147
|
closeLiveStream();
|
|
96
148
|
}
|
|
149
|
+
writeHash(state.selectedSid && tab === 'history' ? state.selectedSid : null);
|
|
97
150
|
render();
|
|
151
|
+
// Move focus into the new region for keyboard/AT users.
|
|
152
|
+
requestAnimationFrame(() => {
|
|
153
|
+
const region = document.querySelector('#agentgui-main');
|
|
154
|
+
if (!region) return;
|
|
155
|
+
const heading = region.querySelector('h1, h2');
|
|
156
|
+
const target = heading || region;
|
|
157
|
+
if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
|
|
158
|
+
try { target.focus({ preventScroll: true }); } catch {}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function refreshActive() {
|
|
163
|
+
try { state.active = await B.listActiveChats(state.backend); } catch { return; }
|
|
164
|
+
render();
|
|
165
|
+
}
|
|
166
|
+
function startActivePolling() {
|
|
167
|
+
if (state.activeTimer) return;
|
|
168
|
+
refreshActive();
|
|
169
|
+
// Small jitter so many tabs don't hit the server in lockstep.
|
|
170
|
+
state.activeTimer = setInterval(refreshActive, 3000 + Math.floor(Math.random() * 600));
|
|
171
|
+
}
|
|
172
|
+
function stopActivePolling() {
|
|
173
|
+
if (state.activeTimer) { clearInterval(state.activeTimer); state.activeTimer = null; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Re-render once a minute so relative timestamps ("5s ago") don't sit frozen
|
|
177
|
+
// between events. Cheap: scheduleRender coalesces via rAF.
|
|
178
|
+
let _relTick = null;
|
|
179
|
+
function startRelTimeTick() { if (!_relTick) _relTick = setInterval(() => scheduleRender(), 30000); }
|
|
180
|
+
async function stopActiveChat(sid) {
|
|
181
|
+
try { await B.cancelChat(state.backend, sid); } catch {}
|
|
182
|
+
refreshActive();
|
|
98
183
|
}
|
|
99
184
|
|
|
100
185
|
function openLiveStream() {
|
|
@@ -111,6 +196,8 @@ function openLiveStream() {
|
|
|
111
196
|
} else if (kind === 'event' && data) {
|
|
112
197
|
if (state.selectedSid && data.sid === state.selectedSid) {
|
|
113
198
|
state.events.push(data);
|
|
199
|
+
// Cap retained events so a long live session can't grow unbounded.
|
|
200
|
+
if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000);
|
|
114
201
|
}
|
|
115
202
|
const arr = Array.isArray(state.sessions) ? state.sessions : [];
|
|
116
203
|
const sess = arr.find(s => s.sid === data.sid);
|
|
@@ -120,11 +207,13 @@ function openLiveStream() {
|
|
|
120
207
|
if (data.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
|
|
121
208
|
if (data.isError) sess.errors = (sess.errors || 0) + 1;
|
|
122
209
|
} else {
|
|
123
|
-
|
|
210
|
+
// Unknown session: a burst of events for a new session would trigger
|
|
211
|
+
// a full session-list refetch per event — debounce it into one.
|
|
212
|
+
debouncedRefreshHistory();
|
|
124
213
|
return;
|
|
125
214
|
}
|
|
126
215
|
} else if (kind === 'conversation') {
|
|
127
|
-
|
|
216
|
+
debouncedRefreshHistory();
|
|
128
217
|
return;
|
|
129
218
|
} else if (kind === 'error' && data) {
|
|
130
219
|
state.live.error = data.error || 'stream error';
|
|
@@ -161,26 +250,50 @@ function view() {
|
|
|
161
250
|
: (liveActive ? '● live · ' + state.live.eventCount : (state.live.connected ? '● live' : '◌ connecting…')))
|
|
162
251
|
: (ok ? (state.health.ws === 'reconnecting' ? '◌ ws reconnecting' : '● connected') : '○ offline');
|
|
163
252
|
const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
|
|
164
|
-
|
|
253
|
+
// Split the leading status glyph (● ◌ ○) from the words: glyph is decorative
|
|
254
|
+
// (aria-hidden), only the text is announced, so AT reads "live" not "black circle live".
|
|
255
|
+
const glyphMatch = dotText.match(/^([●◌○])\s*(.*)$/);
|
|
256
|
+
const dotGlyph = glyphMatch ? glyphMatch[1] : '';
|
|
257
|
+
const dotLabel = glyphMatch ? glyphMatch[2] : dotText;
|
|
258
|
+
const dot = h('span', { key: 'dot', class: 'status-dot' + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' },
|
|
259
|
+
dotGlyph ? h('span', { key: 'dg', 'aria-hidden': 'true' }, dotGlyph + ' ') : null,
|
|
260
|
+
h('span', { key: 'dl' }, dotLabel));
|
|
165
261
|
|
|
166
262
|
const topbar = Topbar({
|
|
167
263
|
brand: 'agentgui',
|
|
168
264
|
leaf: state.tab,
|
|
169
|
-
items: [['chat', '#'], ['history', '
|
|
265
|
+
items: [['chat', buildHash('chat', null) || '#'], ['history', buildHash('history', null)], ['settings', buildHash('settings', null)]],
|
|
170
266
|
active: state.tab,
|
|
171
267
|
onNav: (label) => navTo(label),
|
|
172
268
|
});
|
|
173
269
|
|
|
270
|
+
const agentOptions = state.agents.map(a => ({
|
|
271
|
+
value: a.id,
|
|
272
|
+
label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
|
|
273
|
+
disabled: a.available === false && !a.npxInstallable,
|
|
274
|
+
}));
|
|
275
|
+
const showModelPicker = state.tab === 'chat' && state.agentModels.length > 0;
|
|
276
|
+
|
|
174
277
|
const crumbRight = state.tab === 'chat'
|
|
175
278
|
? [h('div', { key: 'cc', class: 'chat-controls' },
|
|
176
279
|
Select({
|
|
177
|
-
key: '
|
|
178
|
-
value: state.
|
|
179
|
-
placeholder: '—
|
|
180
|
-
title: 'Select
|
|
181
|
-
options:
|
|
182
|
-
onChange: (v) => {
|
|
280
|
+
key: 'agentsel',
|
|
281
|
+
value: state.selectedAgent,
|
|
282
|
+
placeholder: '— agent —',
|
|
283
|
+
title: 'Select coding agent',
|
|
284
|
+
options: agentOptions,
|
|
285
|
+
onChange: (v) => { selectAgent(v); },
|
|
183
286
|
}),
|
|
287
|
+
showModelPicker
|
|
288
|
+
? Select({
|
|
289
|
+
key: 'modelsel',
|
|
290
|
+
value: state.selectedModel,
|
|
291
|
+
placeholder: '— model —',
|
|
292
|
+
title: 'Select model for this agent',
|
|
293
|
+
options: state.agentModels.map(m => ({ value: m.id, label: m.name || m.id })),
|
|
294
|
+
onChange: (v) => { selectModel(v); },
|
|
295
|
+
})
|
|
296
|
+
: null,
|
|
184
297
|
state.chat.busy
|
|
185
298
|
? Btn({ key: 'stop', onClick: cancelChat, children: '◼ stop', title: 'Stop streaming' })
|
|
186
299
|
: Btn({ key: 'new', onClick: newChat, children: '+ new', title: 'Start new chat (clears history)' }),
|
|
@@ -200,15 +313,24 @@ function view() {
|
|
|
200
313
|
// sidebar (the topbar already provides primary nav) so main content gets full width.
|
|
201
314
|
const side = state.tab === 'history' ? historySide() : null;
|
|
202
315
|
|
|
316
|
+
const agentLabel = state.selectedAgent
|
|
317
|
+
? '⌘ ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
|
|
318
|
+
: '○ no agent';
|
|
203
319
|
const status = Status({
|
|
204
320
|
left: [state.backend || 'same-origin', ok ? '● live' : '○ offline'],
|
|
205
|
-
right: [
|
|
321
|
+
right: [agentLabel],
|
|
206
322
|
});
|
|
207
323
|
|
|
324
|
+
// The design system now owns the full-height column + inner scroll for
|
|
325
|
+
// .app-main, so chat just needs to be a flex column that fills it.
|
|
208
326
|
const mainStyle = state.tab === 'chat'
|
|
209
|
-
? 'min-height:0;
|
|
210
|
-
: 'min-height:0
|
|
211
|
-
const
|
|
327
|
+
? 'min-height:0;display:flex;flex-direction:column;flex:1'
|
|
328
|
+
: 'min-height:0';
|
|
329
|
+
const shortcutsHint = state.showShortcuts
|
|
330
|
+
? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
|
|
331
|
+
children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
|
|
332
|
+
: null;
|
|
333
|
+
const main = h('div', { id: 'agentgui-main', role: 'main', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
|
|
212
334
|
// settings reads better centered in a measure; chat + history use full width.
|
|
213
335
|
return AppShell({ topbar, crumb, side, main, status, narrow: state.tab === 'settings' });
|
|
214
336
|
}
|
|
@@ -220,41 +342,98 @@ function mainContent() {
|
|
|
220
342
|
}
|
|
221
343
|
|
|
222
344
|
// ── chat ───────────────────────────────────────────────────────────────────
|
|
345
|
+
function canSend() {
|
|
346
|
+
return !!state.selectedAgent && agentAvailable(state.selectedAgent) && !state.chat.busy;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function toolSummary(block) {
|
|
350
|
+
const name = block.name || block.kind || 'tool';
|
|
351
|
+
let arg = '';
|
|
352
|
+
const inp = block.input || block.rawInput;
|
|
353
|
+
if (inp && typeof inp === 'object') {
|
|
354
|
+
arg = inp.command || inp.file_path || inp.path || inp.pattern || inp.query || inp.url || '';
|
|
355
|
+
if (!arg) { try { arg = JSON.stringify(inp).slice(0, 120); } catch {} }
|
|
356
|
+
}
|
|
357
|
+
return '⌘ ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function toolResultSummary(block) {
|
|
361
|
+
const c = block?.content ?? block?.output ?? block;
|
|
362
|
+
let s = typeof c === 'string' ? c : (() => { try { return JSON.stringify(c); } catch { return String(c); } })();
|
|
363
|
+
s = s.replace(/\s+/g, ' ').trim();
|
|
364
|
+
return (block?.is_error ? '⚠ ' : '') + s.slice(0, 160);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function errText(e) {
|
|
368
|
+
if (e == null) return 'unknown error';
|
|
369
|
+
if (typeof e === 'string') return e;
|
|
370
|
+
if (e.message) return e.message;
|
|
371
|
+
try { return JSON.stringify(e); } catch { return String(e); }
|
|
372
|
+
}
|
|
373
|
+
|
|
223
374
|
function chatMain() {
|
|
224
375
|
const lastIdx = state.chat.messages.length - 1;
|
|
376
|
+
const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
|
|
225
377
|
const msgs = state.chat.messages.map((m, i) => {
|
|
226
378
|
const isAssistant = m.role === 'assistant';
|
|
227
379
|
const isStreaming = state.chat.busy && i === lastIdx && isAssistant;
|
|
228
|
-
const
|
|
380
|
+
const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
|
|
381
|
+
const isEmptyStreaming = isStreaming && !m.content && !hasParts;
|
|
382
|
+
const parts = [];
|
|
383
|
+
if (m.content) parts.push({ kind: isAssistant ? 'md' : 'text', text: m.content });
|
|
384
|
+
if (hasParts) for (const p of m.parts) parts.push({ kind: 'text', text: p });
|
|
229
385
|
return {
|
|
230
386
|
key: m.id || String(i),
|
|
231
387
|
who: isAssistant ? 'them' : 'you',
|
|
232
|
-
name: isAssistant ?
|
|
388
|
+
name: isAssistant ? agentName : 'you',
|
|
233
389
|
time: m.time || '',
|
|
234
390
|
typing: isEmptyStreaming,
|
|
235
|
-
parts: isEmptyStreaming
|
|
236
|
-
? undefined
|
|
237
|
-
: [{ kind: isAssistant ? 'md' : 'text', text: m.content || '' }],
|
|
391
|
+
parts: isEmptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
|
|
238
392
|
};
|
|
239
393
|
});
|
|
240
394
|
|
|
395
|
+
const placeholder = !state.selectedAgent
|
|
396
|
+
? 'choose an agent first'
|
|
397
|
+
: (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
|
|
241
398
|
const composer = ChatComposer({
|
|
242
399
|
value: state.chat.draft,
|
|
243
|
-
disabled:
|
|
244
|
-
placeholder
|
|
400
|
+
disabled: !canSend(),
|
|
401
|
+
placeholder,
|
|
245
402
|
onInput: (v) => { state.chat.draft = v; render(); },
|
|
246
403
|
onSend: (v) => { state.chat.draft = v; sendChat(); },
|
|
247
404
|
});
|
|
248
405
|
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
406
|
+
const banners = [];
|
|
407
|
+
if (state.chat.resumeSid) {
|
|
408
|
+
banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
|
|
409
|
+
h('span', { key: 'rbtxt', class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
|
|
410
|
+
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: '× clear' })));
|
|
411
|
+
if (state.chat.resumeNote) {
|
|
412
|
+
banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
banners.push(cwdBanner());
|
|
416
|
+
if (state.selectedAgent && !agentAvailable(state.selectedAgent)) {
|
|
417
|
+
banners.push(Alert({ key: 'unavail', kind: 'warn', title: agentName + ' is not installed',
|
|
418
|
+
children: 'This agent\'s CLI was not found on the server. Pick another agent or install it.' }));
|
|
419
|
+
}
|
|
420
|
+
if (state.confirmingNewChat) {
|
|
421
|
+
banners.push(Alert({ key: 'confnew', kind: 'warn', title: 'Clear chat history?',
|
|
422
|
+
children: [
|
|
423
|
+
h('span', { key: 'cntxt' }, 'This cannot be undone. '),
|
|
424
|
+
Btn({ key: 'cnyes', danger: true, onClick: newChat, children: 'clear' }),
|
|
425
|
+
Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
|
|
426
|
+
}
|
|
427
|
+
// Last stream error surfaced as a proper Alert instead of raw JSON in the bubble.
|
|
428
|
+
const lastErr = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1].error : null;
|
|
429
|
+
if (lastErr && !state.chat.busy) {
|
|
430
|
+
banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error', children: lastErr }));
|
|
431
|
+
}
|
|
254
432
|
return [
|
|
255
|
-
|
|
433
|
+
offlineBanner(),
|
|
434
|
+
...banners,
|
|
256
435
|
Chat({
|
|
257
|
-
title: (state.selectedModel
|
|
436
|
+
title: agentName + (state.selectedModel ? ' · ' + state.selectedModel : '') + (state.chat.resumeSid ? ' · resume' : ''),
|
|
258
437
|
sub: state.chat.busy ? 'streaming…' : undefined,
|
|
259
438
|
messages: msgs,
|
|
260
439
|
composer,
|
|
@@ -262,56 +441,125 @@ function chatMain() {
|
|
|
262
441
|
].filter(Boolean);
|
|
263
442
|
}
|
|
264
443
|
|
|
444
|
+
function offlineBanner() {
|
|
445
|
+
if (state.health.status === 'ok' || state.health.status === 'unknown') return null;
|
|
446
|
+
return Alert({ key: 'offline', kind: 'error', title: 'Backend unreachable',
|
|
447
|
+
children: 'agentgui can\'t reach the server (' + (state.health.error || state.health.status) + '). Chat and history actions will fail until it reconnects.' });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function cwdBanner() {
|
|
451
|
+
if (state.cwdEditing) {
|
|
452
|
+
return h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Set working directory' },
|
|
453
|
+
TextField({ key: 'cwdfield', label: 'working directory (blank = server default)', value: state.cwdDraft ?? state.chatCwd ?? '',
|
|
454
|
+
placeholder: 'absolute path', onInput: (v) => { state.cwdDraft = v; } }),
|
|
455
|
+
Btn({ key: 'cwdsave', primary: true, onClick: () => {
|
|
456
|
+
state.chatCwd = (state.cwdDraft ?? '').trim();
|
|
457
|
+
if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
|
|
458
|
+
state.cwdEditing = false; state.cwdDraft = undefined; render();
|
|
459
|
+
}, children: 'save' }),
|
|
460
|
+
Btn({ key: 'cwdcancel', onClick: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); }, children: 'cancel' }));
|
|
461
|
+
}
|
|
462
|
+
return h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Working directory' },
|
|
463
|
+
h('span', { class: 'lede' }, state.chatCwd ? '▣ cwd: ' + state.chatCwd : '▣ cwd: server default'),
|
|
464
|
+
Btn({ key: 'cwdset', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); }, children: state.chatCwd ? 'change' : 'set' }),
|
|
465
|
+
state.chatCwd ? Btn({ key: 'cwdclr', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); }, children: '× default' }) : null);
|
|
466
|
+
}
|
|
467
|
+
|
|
265
468
|
function newChat() {
|
|
266
|
-
if (
|
|
469
|
+
if (state.chat.messages.length && !state.confirmingNewChat) {
|
|
470
|
+
state.confirmingNewChat = true; render();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
state.confirmingNewChat = false;
|
|
267
474
|
state.chat.abort?.abort();
|
|
268
475
|
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
|
|
476
|
+
try { localStorage.removeItem(CHAT_KEY); } catch {}
|
|
269
477
|
render();
|
|
270
478
|
}
|
|
271
479
|
|
|
272
480
|
function cancelChat() { state.chat.abort?.abort(); }
|
|
273
481
|
|
|
482
|
+
const CHAT_KEY = 'agentgui.chat';
|
|
483
|
+
function persistChat() {
|
|
484
|
+
try {
|
|
485
|
+
const msgs = state.chat.messages.map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, parts: m.parts }));
|
|
486
|
+
if (!msgs.length) { localStorage.removeItem(CHAT_KEY); return; }
|
|
487
|
+
localStorage.setItem(CHAT_KEY, JSON.stringify({ messages: msgs, resumeSid: state.chat.resumeSid, agent: state.selectedAgent, model: state.selectedModel }));
|
|
488
|
+
} catch {}
|
|
489
|
+
}
|
|
490
|
+
function restoreChat() {
|
|
491
|
+
try {
|
|
492
|
+
const raw = localStorage.getItem(CHAT_KEY);
|
|
493
|
+
if (!raw) return;
|
|
494
|
+
const saved = JSON.parse(raw);
|
|
495
|
+
if (Array.isArray(saved.messages) && saved.messages.length) {
|
|
496
|
+
state.chat.messages = saved.messages.map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
|
|
497
|
+
state.chat.resumeSid = saved.resumeSid || null;
|
|
498
|
+
}
|
|
499
|
+
} catch {}
|
|
500
|
+
}
|
|
501
|
+
|
|
274
502
|
async function sendChat() {
|
|
275
503
|
const text = (state.chat.draft || '').trim();
|
|
276
|
-
if (!text || !
|
|
504
|
+
if (!text || !canSend()) return;
|
|
277
505
|
const t = timeNow();
|
|
278
506
|
const userMsg = { id: 'u' + Date.now(), role: 'user', content: text, time: t };
|
|
279
|
-
const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: t };
|
|
507
|
+
const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: t, parts: [] };
|
|
280
508
|
state.chat.messages = [...state.chat.messages, userMsg, curMsg];
|
|
281
509
|
state.chat.draft = '';
|
|
282
510
|
state.chat.busy = true;
|
|
283
511
|
const ctrl = new AbortController();
|
|
284
512
|
state.chat.abort = ctrl;
|
|
513
|
+
persistChat();
|
|
285
514
|
render();
|
|
286
515
|
scrollChatToBottom();
|
|
287
516
|
const cur = state.chat.messages[state.chat.messages.length - 1];
|
|
288
517
|
try {
|
|
289
518
|
for await (const ev of B.streamChat(state.backend, {
|
|
290
|
-
|
|
519
|
+
agentId: state.selectedAgent,
|
|
520
|
+
model: state.selectedModel || undefined,
|
|
521
|
+
cwd: state.chatCwd || undefined,
|
|
291
522
|
messages: state.chat.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
|
|
292
523
|
signal: ctrl.signal,
|
|
293
524
|
resumeSid: state.chat.resumeSid || undefined,
|
|
294
525
|
})) {
|
|
295
526
|
if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
|
|
296
|
-
if (ev.type === '
|
|
527
|
+
else if (ev.type === 'tool') { cur.parts.push(toolSummary(ev.block)); render(); scrollChatToBottom(); }
|
|
528
|
+
else if (ev.type === 'tool_result') { cur.parts.push('↳ ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
|
|
529
|
+
else if (ev.type === 'result') { /* terminal usage/summary block — already reflected via text */ }
|
|
530
|
+
else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
|
|
297
531
|
}
|
|
298
532
|
} catch (e) {
|
|
299
|
-
if (e.name !== 'AbortError') cur.
|
|
533
|
+
if (e.name !== 'AbortError') cur.error = errText(e.message);
|
|
300
534
|
} finally {
|
|
301
535
|
state.chat.busy = false;
|
|
302
536
|
state.chat.abort = null;
|
|
537
|
+
persistChat();
|
|
303
538
|
render();
|
|
304
539
|
scrollChatToBottom();
|
|
305
540
|
}
|
|
306
541
|
}
|
|
307
542
|
|
|
308
543
|
// ── history ────────────────────────────────────────────────────────────────
|
|
544
|
+
function reconnectAlert() {
|
|
545
|
+
if (!state.live.error) return null;
|
|
546
|
+
return Alert({
|
|
547
|
+
key: 'liveerr',
|
|
548
|
+
kind: 'error',
|
|
549
|
+
title: 'Live stream disconnected',
|
|
550
|
+
children: [h('span', { key: 'lemsg' }, state.live.error + ' — '), Btn({ key: 'reco', onClick: openLiveStream, children: 'reconnect', title: 'Reconnect to history stream' })],
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
309
554
|
function historyMain() {
|
|
310
555
|
if (!state.selectedSid) {
|
|
311
|
-
return [
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
556
|
+
return [
|
|
557
|
+
reconnectAlert(),
|
|
558
|
+
PageHeader({
|
|
559
|
+
title: '§ history',
|
|
560
|
+
lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
|
|
561
|
+
}),
|
|
562
|
+
].filter(Boolean);
|
|
315
563
|
}
|
|
316
564
|
|
|
317
565
|
const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
@@ -324,46 +572,73 @@ function historyMain() {
|
|
|
324
572
|
lede,
|
|
325
573
|
});
|
|
326
574
|
|
|
327
|
-
if (!state.selectedSid) {
|
|
328
|
-
return [head, state.live.error ? Alert({
|
|
329
|
-
key: 'err',
|
|
330
|
-
kind: 'error',
|
|
331
|
-
title: 'Connection lost',
|
|
332
|
-
children: [state.live.error, ' — ', Btn({ onClick: openLiveStream, children: 'reconnect', title: 'Reconnect to history stream' })],
|
|
333
|
-
}) : null];
|
|
334
|
-
}
|
|
335
|
-
|
|
336
575
|
const actions = h('div', { key: 'acts', class: 'history-actions' },
|
|
337
576
|
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: '▶ open in chat' }),
|
|
338
|
-
Btn({ key: 'copy', onClick:
|
|
577
|
+
Btn({ key: 'copy', onClick: copySid, children: copyToast || '⎘ copy sid' }),
|
|
339
578
|
);
|
|
340
579
|
|
|
341
580
|
if (state.events.length === 0) {
|
|
342
|
-
|
|
581
|
+
// Distinguish "still loading" from "genuinely empty" so a 0-event session
|
|
582
|
+
// doesn't spin forever.
|
|
583
|
+
const body = state.eventsLoaded
|
|
584
|
+
? h('p', { key: 'noev', class: 'lede empty-state', role: 'status' }, 'no events in this session')
|
|
585
|
+
: h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…');
|
|
586
|
+
return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
|
|
343
587
|
}
|
|
344
588
|
|
|
589
|
+
if (!state.expandedEvents) state.expandedEvents = new Set();
|
|
590
|
+
const total = state.events.length;
|
|
591
|
+
const shown = state.events.slice(-300);
|
|
592
|
+
const hiddenCount = total - shown.length;
|
|
345
593
|
return [
|
|
594
|
+
reconnectAlert(),
|
|
346
595
|
head,
|
|
347
596
|
actions,
|
|
348
597
|
Panel({
|
|
349
|
-
title:
|
|
598
|
+
title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300)' : ''),
|
|
350
599
|
children: EventList({
|
|
351
|
-
items:
|
|
600
|
+
items: shown.map((e, i) => {
|
|
601
|
+
// Stable key: prefer the server-assigned event index, else the
|
|
602
|
+
// event timestamp + position, never a bare array index (which
|
|
603
|
+
// collides between loaded and live-pushed events).
|
|
604
|
+
const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (total - shown.length + i);
|
|
352
605
|
const role = e.role || '?';
|
|
353
606
|
const type = e.type || '?';
|
|
354
607
|
const tool = e.tool ? ' · ⌘ ' + e.tool : '';
|
|
355
608
|
const errMark = e.isError ? ' · ⚠' : '';
|
|
356
|
-
const
|
|
609
|
+
const raw = e.text || '';
|
|
610
|
+
const text = raw.replace(/\s+/g, ' ').trim();
|
|
611
|
+
const expanded = state.expandedEvents.has(key);
|
|
612
|
+
const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
|
|
357
613
|
return {
|
|
358
|
-
key
|
|
359
|
-
code: String(
|
|
360
|
-
title: text.slice(0, 220) || '(' + type + ')',
|
|
361
|
-
sub: new Date(e.ts).toLocaleString() + ' · ' + role + ' · ' + type + tool + errMark,
|
|
614
|
+
key,
|
|
615
|
+
code: String(total - shown.length + i + 1).padStart(4, '0'),
|
|
616
|
+
title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
|
|
617
|
+
sub: new Date(e.ts).toLocaleString() + ' · ' + role + ' · ' + type + tool + errMark + (raw.length > 220 ? ' · ' + (expanded ? 'click to collapse' : 'click to expand') : ''),
|
|
618
|
+
onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
|
|
362
619
|
};
|
|
363
620
|
}),
|
|
364
621
|
}),
|
|
365
622
|
}),
|
|
366
|
-
];
|
|
623
|
+
].filter(Boolean);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
let copyToast = null;
|
|
627
|
+
function copySid() {
|
|
628
|
+
const sid = state.selectedSid;
|
|
629
|
+
if (!sid) return;
|
|
630
|
+
const done = () => { copyToast = '✓ copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
|
|
631
|
+
if (navigator.clipboard?.writeText) {
|
|
632
|
+
navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = '⚠ copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
|
|
633
|
+
} else {
|
|
634
|
+
// Fallback for insecure (http) origins where navigator.clipboard is absent.
|
|
635
|
+
try {
|
|
636
|
+
const ta = document.createElement('textarea');
|
|
637
|
+
ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
638
|
+
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
|
639
|
+
done();
|
|
640
|
+
} catch { copyToast = '⚠ copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
|
|
641
|
+
}
|
|
367
642
|
}
|
|
368
643
|
|
|
369
644
|
function resumeInChat(sess) {
|
|
@@ -372,8 +647,14 @@ function resumeInChat(sess) {
|
|
|
372
647
|
state.chat.resumeSid = sess?.sid || state.selectedSid;
|
|
373
648
|
state.chat.messages = [];
|
|
374
649
|
state.chat.draft = '';
|
|
375
|
-
//
|
|
376
|
-
|
|
650
|
+
// Only claude-code supports --resume by sid; warn if we have to switch the
|
|
651
|
+
// user's selected agent rather than silently discarding it.
|
|
652
|
+
if (state.selectedAgent && state.selectedAgent !== 'claude-code') {
|
|
653
|
+
state.chat.resumeNote = 'Switched to Claude Code — only it supports resuming a session by id.';
|
|
654
|
+
} else {
|
|
655
|
+
state.chat.resumeNote = null;
|
|
656
|
+
}
|
|
657
|
+
if (state.selectedAgent !== 'claude-code') selectAgent('claude-code');
|
|
377
658
|
render();
|
|
378
659
|
}
|
|
379
660
|
|
|
@@ -427,8 +708,22 @@ function historySide() {
|
|
|
427
708
|
);
|
|
428
709
|
const subagentCount = (Array.isArray(state.sessions) ? state.sessions : []).filter(s => s.isSubagent).length;
|
|
429
710
|
const projects = uniqueProjects();
|
|
711
|
+
const running = Array.isArray(state.active) ? state.active : [];
|
|
430
712
|
|
|
431
713
|
return [
|
|
714
|
+
running.length
|
|
715
|
+
? Panel({
|
|
716
|
+
key: 'runningPanel',
|
|
717
|
+
title: '▶ running · ' + running.length,
|
|
718
|
+
children: running.map((r, i) => {
|
|
719
|
+
const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
|
|
720
|
+
const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
|
|
721
|
+
return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
|
|
722
|
+
h('span', { class: 'lede' }, '● ' + agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
|
|
723
|
+
Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: '◼ stop' }));
|
|
724
|
+
}),
|
|
725
|
+
})
|
|
726
|
+
: null,
|
|
432
727
|
Panel({
|
|
433
728
|
title: searching
|
|
434
729
|
? 'matches · ' + (state.searchHits.results?.length || 0)
|
|
@@ -441,11 +736,20 @@ function historySide() {
|
|
|
441
736
|
value: state.searchQ,
|
|
442
737
|
onInput: (v) => { state.searchQ = v; debouncedSearch(); },
|
|
443
738
|
}),
|
|
739
|
+
state.searchBusy
|
|
740
|
+
? h('div', { key: 'searchbusy', class: 'lede empty-state', role: 'status' }, Spinner({ key: 'ss', size: 'sm' }), 'searching…')
|
|
741
|
+
: null,
|
|
444
742
|
searching && state.searchHits.error
|
|
445
743
|
? Alert({ key: 'searcherr', kind: 'error', title: 'Search failed', children: state.searchHits.error })
|
|
446
744
|
: null,
|
|
447
|
-
state.
|
|
448
|
-
?
|
|
745
|
+
searching && !state.searchBusy && !state.searchHits.error && (state.searchHits.results || []).length === 0
|
|
746
|
+
? h('p', { key: 'nomatch', class: 'lede empty-state' }, 'no matches for "' + state.searchQ + '"')
|
|
747
|
+
: null,
|
|
748
|
+
state.searchQ.trim().length === 1
|
|
749
|
+
? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
|
|
750
|
+
: null,
|
|
751
|
+
state.searchQ
|
|
752
|
+
? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: '× clear search' })
|
|
449
753
|
: null,
|
|
450
754
|
!searching && projects.length > 1
|
|
451
755
|
? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
|
|
@@ -466,7 +770,7 @@ function historySide() {
|
|
|
466
770
|
: null,
|
|
467
771
|
],
|
|
468
772
|
}),
|
|
469
|
-
];
|
|
773
|
+
].filter(Boolean);
|
|
470
774
|
}
|
|
471
775
|
|
|
472
776
|
// ── settings ───────────────────────────────────────────────────────────────
|
|
@@ -476,14 +780,16 @@ function isValidUrl(s) {
|
|
|
476
780
|
catch { return false; }
|
|
477
781
|
}
|
|
478
782
|
|
|
479
|
-
function saveBackend() {
|
|
480
|
-
if (!isValidUrl(state.backendDraft)) return;
|
|
481
|
-
if (!confirm('Reconnect to new backend? Current session will be lost.')) return;
|
|
783
|
+
async function saveBackend() {
|
|
784
|
+
if (!isValidUrl(state.backendDraft) || state.backendDraft === state.backend) return;
|
|
482
785
|
B.setBackend(state.backendDraft);
|
|
483
786
|
state.backend = state.backendDraft;
|
|
484
787
|
state.health = { status: 'unknown' };
|
|
788
|
+
state.backendStatus = 'connecting';
|
|
789
|
+
render();
|
|
790
|
+
await init();
|
|
791
|
+
state.backendStatus = state.health.status === 'ok' ? 'ok' : 'failed';
|
|
485
792
|
render();
|
|
486
|
-
init();
|
|
487
793
|
}
|
|
488
794
|
|
|
489
795
|
function healthSummary() {
|
|
@@ -525,36 +831,57 @@ function settingsMain() {
|
|
|
525
831
|
onInput: (v) => { state.backendDraft = v; render(); },
|
|
526
832
|
}),
|
|
527
833
|
!isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, '⚠ Invalid URL format') : null,
|
|
834
|
+
state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, '◌ connecting…') : null,
|
|
835
|
+
state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, '● connected') : null,
|
|
836
|
+
state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, '○ connection failed — check the URL') : null,
|
|
528
837
|
healthSummary(),
|
|
529
838
|
Btn({
|
|
530
839
|
key: 'savebtn',
|
|
531
840
|
type: 'submit',
|
|
532
841
|
primary: true,
|
|
533
|
-
disabled: !isValid,
|
|
842
|
+
disabled: !isValid || state.backendDraft === state.backend || state.backendStatus === 'connecting',
|
|
534
843
|
onClick: (e) => { e.preventDefault(); saveBackend(); },
|
|
535
|
-
children: 'save + reconnect',
|
|
844
|
+
children: state.backendStatus === 'connecting' ? 'connecting…' : 'save + reconnect',
|
|
536
845
|
title: isValid ? 'Save backend URL and reconnect' : 'Fix URL format first',
|
|
537
846
|
}),
|
|
538
847
|
]),
|
|
539
848
|
}),
|
|
540
|
-
|
|
541
|
-
title: 'models',
|
|
542
|
-
children: state.models.length
|
|
543
|
-
? state.models.slice(0, 40).map((m, i) =>
|
|
544
|
-
Row({
|
|
545
|
-
key: 'm' + i,
|
|
546
|
-
rank: String(i + 1).padStart(3, '0'),
|
|
547
|
-
title: m.id,
|
|
548
|
-
sub: m.name ? (m.name + ' · ' + (m.protocol || 'agent')) : (m.protocol || 'agent'),
|
|
549
|
-
rail: m.id === state.selectedModel ? 'green' : 'purple',
|
|
550
|
-
onClick: () => { state.selectedModel = m.id; render(); },
|
|
551
|
-
})
|
|
552
|
-
)
|
|
553
|
-
: h('p', { key: 'none', class: 'lede' }, 'no models loaded'),
|
|
554
|
-
}),
|
|
849
|
+
agentsPanel(),
|
|
555
850
|
];
|
|
556
851
|
}
|
|
557
852
|
|
|
853
|
+
function acpStatusFor(agentId) {
|
|
854
|
+
const acp = Array.isArray(state.health.acp) ? state.health.acp : [];
|
|
855
|
+
return acp.find(a => a.id === agentId) || null;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function agentsPanel() {
|
|
859
|
+
const installed = state.agents.filter(a => a.available !== false);
|
|
860
|
+
return Panel({
|
|
861
|
+
title: 'agents · ' + installed.length + '/' + state.agents.length + ' installed',
|
|
862
|
+
children: state.agents.length
|
|
863
|
+
? state.agents.map((a, i) => {
|
|
864
|
+
const acp = acpStatusFor(a.id);
|
|
865
|
+
const avail = a.available !== false;
|
|
866
|
+
const bits = [a.protocol || 'agent'];
|
|
867
|
+
if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
|
|
868
|
+
if (acp) bits.push(acp.healthy ? 'running·healthy' : (acp.running ? 'running' : 'stopped'));
|
|
869
|
+
if (acp && acp.port) bits.push('port ' + acp.port);
|
|
870
|
+
if (acp && acp.restartCount) bits.push(acp.restartCount + ' restarts');
|
|
871
|
+
return Row({
|
|
872
|
+
key: 'ag' + a.id,
|
|
873
|
+
rank: String(i + 1).padStart(3, '0'),
|
|
874
|
+
title: a.name + (avail ? '' : ' ·'),
|
|
875
|
+
sub: bits.join(' · '),
|
|
876
|
+
rail: a.id === state.selectedAgent ? 'green' : (avail ? 'purple' : 'flame'),
|
|
877
|
+
active: a.id === state.selectedAgent,
|
|
878
|
+
onClick: () => { if (avail || a.npxInstallable) { navTo('chat'); selectAgent(a.id); } },
|
|
879
|
+
});
|
|
880
|
+
})
|
|
881
|
+
: h('p', { key: 'none', class: 'lede' }, 'no agents loaded'),
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
558
885
|
// ── data ──────────────────────────────────────────────────────────────────
|
|
559
886
|
async function refreshHistory() {
|
|
560
887
|
try {
|
|
@@ -567,14 +894,20 @@ async function refreshHistory() {
|
|
|
567
894
|
render();
|
|
568
895
|
}
|
|
569
896
|
}
|
|
897
|
+
const debouncedRefreshHistory = debounce(refreshHistory, 500);
|
|
570
898
|
|
|
571
899
|
async function runSearch() {
|
|
572
|
-
|
|
900
|
+
const q = state.searchQ.trim();
|
|
901
|
+
if (!q) { state.searchHits = null; state.searchBusy = false; render(); return; }
|
|
902
|
+
if (q.length < 2) { state.searchHits = null; state.searchBusy = false; render(); return; }
|
|
903
|
+
state.searchBusy = true;
|
|
904
|
+
render();
|
|
573
905
|
try {
|
|
574
|
-
state.searchHits = await B.searchHistory(state.backend,
|
|
575
|
-
render();
|
|
906
|
+
state.searchHits = await B.searchHistory(state.backend, q, 60);
|
|
576
907
|
} catch (e) {
|
|
577
|
-
state.searchHits = { query:
|
|
908
|
+
state.searchHits = { query: q, results: [], error: e.message };
|
|
909
|
+
} finally {
|
|
910
|
+
state.searchBusy = false;
|
|
578
911
|
render();
|
|
579
912
|
}
|
|
580
913
|
}
|
|
@@ -583,16 +916,22 @@ const debouncedSearch = debounce(runSearch, 300);
|
|
|
583
916
|
async function loadSession(sid) {
|
|
584
917
|
state.selectedSid = sid;
|
|
585
918
|
state.events = [];
|
|
586
|
-
|
|
919
|
+
state.eventsLoaded = false;
|
|
920
|
+
state.expandedEvents = new Set(); // don't carry expansion to the new session
|
|
921
|
+
writeHash(sid, { push: true });
|
|
587
922
|
render();
|
|
588
|
-
try {
|
|
589
|
-
|
|
923
|
+
try {
|
|
924
|
+
state.events = await B.getSessionEvents(state.backend, sid);
|
|
925
|
+
state.eventsLoaded = true;
|
|
926
|
+
render();
|
|
927
|
+
} catch (e) {
|
|
590
928
|
state.events = [{
|
|
591
929
|
ts: Date.now(),
|
|
592
930
|
role: 'error',
|
|
593
931
|
type: 'fetch',
|
|
594
932
|
text: 'Failed to load session: ' + e.message + ' — retry via sidebar',
|
|
595
933
|
}];
|
|
934
|
+
state.eventsLoaded = true;
|
|
596
935
|
render();
|
|
597
936
|
}
|
|
598
937
|
}
|
|
@@ -606,18 +945,34 @@ async function init() {
|
|
|
606
945
|
}
|
|
607
946
|
render();
|
|
608
947
|
try {
|
|
609
|
-
state.
|
|
610
|
-
if
|
|
948
|
+
state.agents = await B.listAgents(state.backend);
|
|
949
|
+
// Restore the saved agent if still present; else first available, else first.
|
|
950
|
+
let target = state.agents.find(a => a.id === state.selectedAgent);
|
|
951
|
+
if (!target) target = state.agents.find(a => a.available !== false) || state.agents[0];
|
|
952
|
+
if (target) await selectAgent(target.id);
|
|
611
953
|
render();
|
|
612
|
-
} catch (e) { console.warn('
|
|
954
|
+
} catch (e) { console.warn('agents fetch failed:', e.message); }
|
|
613
955
|
|
|
614
|
-
const initialSid = readHash();
|
|
956
|
+
const { sid: initialSid, tab: initialTab } = readHash();
|
|
615
957
|
if (initialSid) {
|
|
616
958
|
navTo('history');
|
|
617
959
|
await refreshHistory();
|
|
618
960
|
await loadSession(initialSid);
|
|
961
|
+
} else if (initialTab && initialTab !== state.tab) {
|
|
962
|
+
navTo(initialTab);
|
|
619
963
|
}
|
|
620
964
|
|
|
965
|
+
registerWsStatusOnce();
|
|
966
|
+
startActivePolling(); // surface running chats on any tab, not just history
|
|
967
|
+
startRelTimeTick();
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// init() runs both at boot and on every saveBackend(); registering the WS
|
|
971
|
+
// status listener inside it leaked a listener per save. Register exactly once.
|
|
972
|
+
let wsStatusRegistered = false;
|
|
973
|
+
function registerWsStatusOnce() {
|
|
974
|
+
if (wsStatusRegistered) return;
|
|
975
|
+
wsStatusRegistered = true;
|
|
621
976
|
B.onWsStatus?.((s) => {
|
|
622
977
|
if (s === 'closed' || s === 'error') {
|
|
623
978
|
if (state.health.status === 'ok') { state.health = { ...state.health, ws: 'reconnecting' }; render(); }
|
|
@@ -627,6 +982,53 @@ async function init() {
|
|
|
627
982
|
});
|
|
628
983
|
}
|
|
629
984
|
|
|
985
|
+
restoreChat();
|
|
630
986
|
render = mount(document.getElementById('app'), view);
|
|
987
|
+
|
|
988
|
+
// Re-render on resize so isNarrow()/truncate() reflect the current width
|
|
989
|
+
// (they read window.innerWidth only at render time).
|
|
990
|
+
window.addEventListener('resize', debounce(() => scheduleRender(), 150));
|
|
991
|
+
|
|
992
|
+
// Browser Back/forward: re-sync tab + selected session from the hash.
|
|
993
|
+
window.addEventListener('popstate', () => {
|
|
994
|
+
const { sid, tab } = readHash();
|
|
995
|
+
if (sid && sid !== state.selectedSid) { state.tab = 'history'; loadSession(sid); }
|
|
996
|
+
else if (!sid && tab && tab !== state.tab) navTo(tab);
|
|
997
|
+
else if (!sid && !tab && state.tab !== 'chat') navTo('chat');
|
|
998
|
+
});
|
|
999
|
+
|
|
631
1000
|
window.__agentgui = { state, render };
|
|
1001
|
+
|
|
1002
|
+
// Keyboard shortcuts. 'g' then c/h/s switches tabs; 'n' new chat; '/' focuses
|
|
1003
|
+
// search (history) or composer (chat). Ignored while typing in a field.
|
|
1004
|
+
let gPending = false;
|
|
1005
|
+
function focusComposer() {
|
|
1006
|
+
const el = document.querySelector('#agentgui-main textarea, #agentgui-main [contenteditable="true"], #agentgui-main input[type="text"]');
|
|
1007
|
+
el?.focus();
|
|
1008
|
+
}
|
|
1009
|
+
function focusSearch() {
|
|
1010
|
+
const el = document.querySelector('#app input[type="search"]');
|
|
1011
|
+
el?.focus();
|
|
1012
|
+
}
|
|
1013
|
+
window.addEventListener('keydown', (e) => {
|
|
1014
|
+
const t = e.target;
|
|
1015
|
+
const typing = t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable);
|
|
1016
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
1017
|
+
if (typing) {
|
|
1018
|
+
if (e.key === 'Escape') t.blur();
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (gPending) {
|
|
1022
|
+
gPending = false;
|
|
1023
|
+
if (e.key === 'c') { navTo('chat'); return; }
|
|
1024
|
+
if (e.key === 'h') { navTo('history'); return; }
|
|
1025
|
+
if (e.key === 's') { navTo('settings'); return; }
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
|
|
1029
|
+
if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
|
|
1030
|
+
if (e.key === '/') { e.preventDefault(); state.tab === 'history' ? focusSearch() : focusComposer(); return; }
|
|
1031
|
+
if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
|
|
1032
|
+
});
|
|
1033
|
+
|
|
632
1034
|
init();
|