agentgui 1.0.974 → 1.0.976
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/lib/http-handler.js +11 -13
- package/package.json +1 -1
- package/scripts/validate-mutations.mjs +3 -0
- package/site/app/js/app.js +57 -33
- package/site/app/js/backend.js +1 -0
- package/site/app/vendor/anentrypoint-design/247420.css +6 -6
- package/site/app/vendor/anentrypoint-design/247420.js +12 -12
- package/test.js +0 -89
package/lib/http-handler.js
CHANGED
|
@@ -101,19 +101,15 @@ function isAllowRoot(realPath, allowRoots) {
|
|
|
101
101
|
|
|
102
102
|
export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
|
|
103
103
|
return async function httpHandler(req, res) {
|
|
104
|
-
// CORS: when
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// when configured, else same-origin only. With no PASSWORD the server is
|
|
109
|
-
// already open, so the permissive wildcard is harmless and kept for tools.
|
|
104
|
+
// CORS: emit ACAO only when CORS_ORIGIN is explicitly set. A wildcard would
|
|
105
|
+
// let any webpage the user visits make credentialless fetches to /api/list,
|
|
106
|
+
// /api/file/*, etc. and read ~/.claude/projects content — even on a no-
|
|
107
|
+
// PASSWORD localhost deploy. Set CORS_ORIGIN=<origin> for cross-origin tools.
|
|
110
108
|
const _corsOrigin = process.env.CORS_ORIGIN;
|
|
111
109
|
if (_corsOrigin) {
|
|
112
110
|
res.setHeader('Access-Control-Allow-Origin', _corsOrigin);
|
|
113
111
|
res.setHeader('Vary', 'Origin');
|
|
114
|
-
}
|
|
115
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
116
|
-
} // else: no ACAO header -> browsers block cross-origin reads (same-origin still works)
|
|
112
|
+
} // no ACAO header -> browsers enforce same-origin by default
|
|
117
113
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
118
114
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
119
115
|
// The password can ride in a ?token= query param (EventSource/deep-links
|
|
@@ -684,12 +680,14 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
684
680
|
const normalizedPath = conf.realPath;
|
|
685
681
|
try {
|
|
686
682
|
const ext = path.extname(normalizedPath).toLowerCase();
|
|
687
|
-
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp'
|
|
688
|
-
//
|
|
689
|
-
//
|
|
683
|
+
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
684
|
+
// SVG is intentionally excluded: browsers render SVG as a live document
|
|
685
|
+
// in the app's origin, so an agent-written SVG with a <script src=CDN>
|
|
686
|
+
// would execute in the agentgui origin (CSP allows unpkg/jsdelivr).
|
|
687
|
+
// Files preview uses /api/file/download (attachment) for SVG.
|
|
690
688
|
const contentType = mimeTypes[ext];
|
|
691
689
|
if (!contentType) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
692
|
-
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
|
|
690
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' });
|
|
693
691
|
res.end(fs.readFileSync(normalizedPath));
|
|
694
692
|
} catch (err) { sendJSON(req, res, 400, { error: err.message }); }
|
|
695
693
|
return;
|
package/package.json
CHANGED
|
@@ -28,6 +28,9 @@ const TRAVERSAL = join(ROOT, '..', '..', ...(WIN ? ['Windows', 'win.ini'] : ['et
|
|
|
28
28
|
await check('rename-traversal', 403, await post('/api/rename', { path: TRAVERSAL, newName: 'x.txt' }));
|
|
29
29
|
await check('delete-out-of-roots', 403, await post('/api/delete', { path: OUTSIDE }));
|
|
30
30
|
await check('delete-root-refused', 403, await post('/api/delete', { path: ROOT }));
|
|
31
|
+
await check('rename-to-secret-403', 403, await post('/api/rename', { path: join(ROOT, 'package.json'), newName: '.env' }));
|
|
32
|
+
await check('upload-secret-403', 403, await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(ROOT) + '&name=.env', { method: 'PUT', headers: AUTH, body: 'secret' }).then(async r => ({ status: r.status, body: await r.text() })));
|
|
33
|
+
await check('mkdir-secret-403', 403, await post('/api/mkdir', { dir: ROOT, name: '.env' }));
|
|
31
34
|
await check('mkdir-reserved-name', 400, await post('/api/mkdir', { dir: ROOT, name: 'CON' }));
|
|
32
35
|
await check('mkdir-name-with-separator', 400, await post('/api/mkdir', { dir: ROOT, name: 'a/b' }));
|
|
33
36
|
await check('mkdir-name-trailing-dot', 400, await post('/api/mkdir', { dir: ROOT, name: 'evil.' }));
|
package/site/app/js/app.js
CHANGED
|
@@ -131,7 +131,8 @@ function scheduleStreamRender() {
|
|
|
131
131
|
requestAnimationFrame(() => {
|
|
132
132
|
streamRenderScheduled = false;
|
|
133
133
|
render();
|
|
134
|
-
|
|
134
|
+
// Kit AgentChat's IntersectionObserver sentinel handles streaming auto-scroll;
|
|
135
|
+
// calling scrollChatToBottom here forces a synchronous scrollHeight layout reflow.
|
|
135
136
|
});
|
|
136
137
|
}
|
|
137
138
|
|
|
@@ -262,6 +263,7 @@ function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
|
|
|
262
263
|
// Leaving chat clears any pending new-chat confirmation so it doesn't linger
|
|
263
264
|
// as a stale banner when the user returns.
|
|
264
265
|
if (prev === 'chat' && tab !== 'chat') state.confirmingNewChat = false;
|
|
266
|
+
if (prev !== tab) state.confirmingClearData = false;
|
|
265
267
|
state.tab = tab;
|
|
266
268
|
// Live history SSE feeds both the History tab (event log) and the Live
|
|
267
269
|
// dashboard (per-session activity tally + stream-health signal); open it on
|
|
@@ -367,7 +369,11 @@ let _liveTick = null;
|
|
|
367
369
|
function startLiveTick() {
|
|
368
370
|
if (_liveTick) return;
|
|
369
371
|
_liveTick = setInterval(() => {
|
|
370
|
-
if (state.tab === 'live' && Array.isArray(state.active) && state.active.length)
|
|
372
|
+
if (state.tab === 'live' && Array.isArray(state.active) && state.active.length) {
|
|
373
|
+
scheduleRender();
|
|
374
|
+
} else if (state.tab !== 'live' && !(Array.isArray(state.active) && state.active.length)) {
|
|
375
|
+
clearInterval(_liveTick); _liveTick = null;
|
|
376
|
+
}
|
|
371
377
|
}, 1000);
|
|
372
378
|
}
|
|
373
379
|
async function stopActiveChat(sid) {
|
|
@@ -415,6 +421,7 @@ function openLiveStream() {
|
|
|
415
421
|
// Dedupe against the snapshot/prior pushes by event index - a
|
|
416
422
|
// reconnect or overlap would otherwise double-append the same event.
|
|
417
423
|
if (ev.i == null || !state.events.some(e => e.i === ev.i)) {
|
|
424
|
+
ev._idx = state.events.length;
|
|
418
425
|
state.events.push(ev);
|
|
419
426
|
// Cap retained events so a long live session can't grow unbounded.
|
|
420
427
|
if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000);
|
|
@@ -511,9 +518,9 @@ function view() {
|
|
|
511
518
|
const liveActive = state.tab === 'history' && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
|
|
512
519
|
const dotLabel = state.tab === 'history'
|
|
513
520
|
? (state.live.error
|
|
514
|
-
? state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
|
|
515
|
-
: (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting…')))
|
|
516
|
-
: (ok ? (state.health.ws === 'reconnecting' ? 'connecting' : 'connected') : 'offline');
|
|
521
|
+
? 'stream: ' + state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
|
|
522
|
+
: (liveActive ? 'stream: live · ' + state.live.eventCount : (state.live.connected ? 'stream: live' : 'stream: connecting…')))
|
|
523
|
+
: (ok ? (state.health.ws === 'reconnecting' ? 'connecting…' : 'connected') : 'offline');
|
|
517
524
|
const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
|
|
518
525
|
// The status dot is drawn entirely by CSS (.status-dot::before) - a small
|
|
519
526
|
// colored disc, real product design, not a text glyph. State drives its colour
|
|
@@ -1049,7 +1056,7 @@ function fileDialog() {
|
|
|
1049
1056
|
return PromptDialog({
|
|
1050
1057
|
title: 'Rename ' + d.file.name, value: d.file.name, placeholder: 'new name',
|
|
1051
1058
|
error: d.error || null, busy: !!d.busy,
|
|
1052
|
-
confirmLabel: d.busy ? 'renaming
|
|
1059
|
+
confirmLabel: d.busy ? 'renaming…' : 'rename', cancelLabel: 'cancel',
|
|
1053
1060
|
onCancel: closeFileDialog,
|
|
1054
1061
|
onConfirm: (v) => {
|
|
1055
1062
|
// Every confirm press produces visible feedback - never a silent no-op.
|
|
@@ -1066,7 +1073,7 @@ function fileDialog() {
|
|
|
1066
1073
|
? 'Delete this folder and everything inside it? This cannot be undone.'
|
|
1067
1074
|
: 'Delete this file? This cannot be undone.',
|
|
1068
1075
|
error: d.error || null, busy: !!d.busy,
|
|
1069
|
-
confirmLabel: d.busy ? 'deleting
|
|
1076
|
+
confirmLabel: d.busy ? 'deleting…' : 'delete', cancelLabel: 'cancel', destructive: true,
|
|
1070
1077
|
onCancel: closeFileDialog,
|
|
1071
1078
|
onConfirm: () => runFileMutation(() => B.deleteEntry(state.backend, d.file.path, isDir), 'deleted ' + d.file.name),
|
|
1072
1079
|
});
|
|
@@ -1080,7 +1087,7 @@ function fileDialog() {
|
|
|
1080
1087
|
? 'Folders are deleted with everything inside them. '
|
|
1081
1088
|
: '') + 'This cannot be undone.',
|
|
1082
1089
|
error: d.error || null, busy: !!d.busy,
|
|
1083
|
-
confirmLabel: d.busy ? 'deleting
|
|
1090
|
+
confirmLabel: d.busy ? 'deleting…' : 'delete ' + n, cancelLabel: 'cancel', destructive: true,
|
|
1084
1091
|
onCancel: closeFileDialog,
|
|
1085
1092
|
onConfirm: runBulkDelete,
|
|
1086
1093
|
});
|
|
@@ -1091,7 +1098,7 @@ function fileDialog() {
|
|
|
1091
1098
|
title: 'Move ' + n + ' selected ' + (n === 1 ? 'entry' : 'entries'),
|
|
1092
1099
|
value: state.files.path || '', placeholder: 'destination folder path',
|
|
1093
1100
|
error: d.error || null, busy: !!d.busy,
|
|
1094
|
-
confirmLabel: d.busy ? 'moving
|
|
1101
|
+
confirmLabel: d.busy ? 'moving…' : 'move ' + n, cancelLabel: 'cancel',
|
|
1095
1102
|
onCancel: closeFileDialog,
|
|
1096
1103
|
onConfirm: (v) => {
|
|
1097
1104
|
if (!v) { d.error = 'enter a destination folder'; render(); return; }
|
|
@@ -1104,7 +1111,7 @@ function fileDialog() {
|
|
|
1104
1111
|
return PromptDialog({
|
|
1105
1112
|
title: 'New folder', value: '', placeholder: 'folder name',
|
|
1106
1113
|
error: d.error || null, busy: !!d.busy,
|
|
1107
|
-
confirmLabel: d.busy ? 'creating
|
|
1114
|
+
confirmLabel: d.busy ? 'creating…' : 'create', cancelLabel: 'cancel',
|
|
1108
1115
|
onCancel: closeFileDialog,
|
|
1109
1116
|
onConfirm: (v) => {
|
|
1110
1117
|
if (!v) { d.error = 'enter a folder name'; render(); return; }
|
|
@@ -1699,7 +1706,9 @@ function appendText(parts, text) {
|
|
|
1699
1706
|
// standalone tool_result part. Sets the card's result + done/error status.
|
|
1700
1707
|
function applyToolResult(parts, block) {
|
|
1701
1708
|
const id = block.tool_use_id || block.id || null;
|
|
1702
|
-
const
|
|
1709
|
+
const raw = block?.content ?? block?.output ?? block;
|
|
1710
|
+
// Claude API delivers content as an array of {type,text} objects; flatten to plain text.
|
|
1711
|
+
const content = Array.isArray(raw) ? (raw.filter(b => b.type === 'text').map(b => b.text).join('\n') || JSON.stringify(raw, null, 2)) : raw;
|
|
1703
1712
|
const isError = !!(block?.is_error);
|
|
1704
1713
|
const byId = id ? [...parts].reverse().find(p => p && p.kind === 'tool' && p._id === id) : null;
|
|
1705
1714
|
const target = byId || [...parts].reverse().find(p => p && p.kind === 'tool' && p.status === 'running');
|
|
@@ -2323,6 +2332,9 @@ async function sendChat(textArg) {
|
|
|
2323
2332
|
} finally {
|
|
2324
2333
|
state.chat.busy = false;
|
|
2325
2334
|
state.chat.abort = null;
|
|
2335
|
+
// Prune an empty assistant shell (WS-drop before any content arrived).
|
|
2336
|
+
const msgs = state.chat.messages;
|
|
2337
|
+
if (msgs.length && isEmptyTurn(msgs[msgs.length - 1])) msgs.pop();
|
|
2326
2338
|
persistChat();
|
|
2327
2339
|
refreshActive(); // settle the running panel/dashboard now, not at the next poll
|
|
2328
2340
|
render();
|
|
@@ -2479,17 +2491,24 @@ function historyMain() {
|
|
|
2479
2491
|
onSelect: (id) => { state.eventFilter = id && id.id ? id.id : id; render(); },
|
|
2480
2492
|
label: 'Filter events by type',
|
|
2481
2493
|
});
|
|
2494
|
+
// Single pass over state.events for all three counters (replaces three separate .filter() calls).
|
|
2495
|
+
const evCounters = state.events.reduce((c, e) => {
|
|
2496
|
+
if (e.role === 'user') c.turns++;
|
|
2497
|
+
if (e.type === 'tool_use') c.tools++;
|
|
2498
|
+
if (e.isError) c.errors++;
|
|
2499
|
+
return c;
|
|
2500
|
+
}, { turns: 0, tools: 0, errors: 0 });
|
|
2482
2501
|
const meta = SessionMeta({
|
|
2483
2502
|
items: [
|
|
2484
2503
|
sess && sess.cwd ? { label: 'cwd', value: sess.cwd, title: sess.cwd } : null,
|
|
2485
|
-
sessionDuration() ? { label: 'duration', value:
|
|
2504
|
+
(() => { const dur = sessionDuration(); return dur ? { label: 'duration', value: dur } : null; })(),
|
|
2486
2505
|
{ label: 'session id', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'session id copied') },
|
|
2487
2506
|
// Spelled counter vocabulary in the detail strip (events/turns/tools/
|
|
2488
2507
|
// errors); the abbreviated 'ev/tools/err' triple stays compact-row-only.
|
|
2489
2508
|
{ label: 'events', value: String(state.events.length) },
|
|
2490
|
-
{ label: 'turns', value: String(sess?.userTurns ??
|
|
2491
|
-
{ label: 'tools', value: String(
|
|
2492
|
-
{ label: 'errors', value: String(
|
|
2509
|
+
{ label: 'turns', value: String(sess?.userTurns ?? evCounters.turns) },
|
|
2510
|
+
{ label: 'tools', value: String(evCounters.tools) },
|
|
2511
|
+
{ label: 'errors', value: String(evCounters.errors) },
|
|
2493
2512
|
].filter(Boolean),
|
|
2494
2513
|
});
|
|
2495
2514
|
if (filteredEvents.length === 0) {
|
|
@@ -2506,10 +2525,7 @@ function historyMain() {
|
|
|
2506
2525
|
const shown = filteredEvents.slice(-limit);
|
|
2507
2526
|
const hiddenCount = total - shown.length;
|
|
2508
2527
|
// Keys of the currently-shown rows, so expand-all toggles only what's rendered.
|
|
2509
|
-
const shownKeys = shown.map((e, i) =>
|
|
2510
|
-
const absIdx = total - shown.length + i;
|
|
2511
|
-
return e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + absIdx;
|
|
2512
|
-
});
|
|
2528
|
+
const shownKeys = shown.map((e, i) => e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + (e._idx ?? (total - shown.length + i)));
|
|
2513
2529
|
const allExpanded = shownKeys.length > 0 && shownKeys.every(k => state.expandedEvents.has(k));
|
|
2514
2530
|
const eventControls = h('div', { key: 'evctrl', class: 'history-actions', role: 'group', 'aria-label': 'event controls' },
|
|
2515
2531
|
Btn({ key: 'expall', onClick: () => {
|
|
@@ -2535,8 +2551,7 @@ function historyMain() {
|
|
|
2535
2551
|
// Stable key: server event index when present, else ts + the event's
|
|
2536
2552
|
// ABSOLUTE position in state.events (not the sliced-view index, which
|
|
2537
2553
|
// shifts when live events append and would collide loaded vs live rows).
|
|
2538
|
-
const
|
|
2539
|
-
const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + absIdx;
|
|
2554
|
+
const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + (e._idx ?? (total - shown.length + i));
|
|
2540
2555
|
const role = e.role || '?';
|
|
2541
2556
|
const type = e.type || '?';
|
|
2542
2557
|
const tool = e.tool ? ' · tool: ' + e.tool : '';
|
|
@@ -2562,7 +2577,7 @@ function historyMain() {
|
|
|
2562
2577
|
}
|
|
2563
2578
|
return {
|
|
2564
2579
|
key,
|
|
2565
|
-
code: String(
|
|
2580
|
+
code: String(total - shown.length + i + 1).padStart(4, '0'),
|
|
2566
2581
|
rail,
|
|
2567
2582
|
expanded, // disclosure state -> kit Row sets aria-expanded
|
|
2568
2583
|
highlight: q || undefined,
|
|
@@ -2802,7 +2817,9 @@ function historySide() {
|
|
|
2802
2817
|
function isValidUrl(s) {
|
|
2803
2818
|
if (!s) return true; // blank = same-origin is valid
|
|
2804
2819
|
try {
|
|
2805
|
-
|
|
2820
|
+
// Only add http:// prefix for schemeless inputs (no ://); inputs with an
|
|
2821
|
+
// explicit non-http scheme (ftp://, ws://) must fail the protocol check.
|
|
2822
|
+
const u = new URL(s.includes('://') ? s : 'http://' + s);
|
|
2806
2823
|
return u.protocol === 'http:' || u.protocol === 'https:'; // reject ftp:/ws:/etc
|
|
2807
2824
|
} catch { return false; }
|
|
2808
2825
|
}
|
|
@@ -2812,12 +2829,15 @@ function isValidUrl(s) {
|
|
|
2812
2829
|
// becomes the same string we validated. Blank stays blank (same-origin).
|
|
2813
2830
|
function normalizeBackend(s) {
|
|
2814
2831
|
if (!s) return '';
|
|
2815
|
-
try { return new URL(s.
|
|
2832
|
+
try { return new URL(s.includes('://') ? s : 'http://' + s).origin; }
|
|
2816
2833
|
catch { return s; }
|
|
2817
2834
|
}
|
|
2818
2835
|
|
|
2819
2836
|
async function saveBackend() {
|
|
2820
|
-
if (!isValidUrl(state.backendDraft)
|
|
2837
|
+
if (!isValidUrl(state.backendDraft)) return;
|
|
2838
|
+
// Re-submitting the current URL (e.g. after a failed health check) re-runs
|
|
2839
|
+
// the health probe and shows connecting… so the user gets visible feedback.
|
|
2840
|
+
if (state.backendDraft === state.backend) { state.backendStatus = 'connecting'; render(); await recheckHealth(); return; }
|
|
2821
2841
|
// Switching backend orphans the local chat transcript (it belongs to the old
|
|
2822
2842
|
// server's sessions). Confirm once if there's a transcript to lose - and the
|
|
2823
2843
|
// confirmation binds to the EXACT value confirmed: editing the URL after
|
|
@@ -2847,7 +2867,7 @@ function healthSummary() {
|
|
|
2847
2867
|
// (connected/offline/connecting) so the same state reads the same word
|
|
2848
2868
|
// everywhere, instead of the raw health.status ('ok'/'down').
|
|
2849
2869
|
const connWord = hh.status === 'ok' ? 'connected'
|
|
2850
|
-
: hh.status === 'unknown' ? 'connecting'
|
|
2870
|
+
: hh.status === 'unknown' ? 'connecting…'
|
|
2851
2871
|
: 'offline';
|
|
2852
2872
|
bits.push([connWord, 'Backend connection status']);
|
|
2853
2873
|
if (hh.version) bits.push(['v' + hh.version, 'Server version']);
|
|
@@ -2890,8 +2910,7 @@ function settingsMain() {
|
|
|
2890
2910
|
label: 'backend url',
|
|
2891
2911
|
value: state.backendDraft,
|
|
2892
2912
|
placeholder: '(blank = same origin)',
|
|
2893
|
-
|
|
2894
|
-
'aria-invalid': !isValid ? 'true' : 'false',
|
|
2913
|
+
error: !isValid ? 'Invalid URL format' : undefined,
|
|
2895
2914
|
title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
|
|
2896
2915
|
onInput: (v) => {
|
|
2897
2916
|
state.backendDraft = v;
|
|
@@ -2900,7 +2919,6 @@ function settingsMain() {
|
|
|
2900
2919
|
render();
|
|
2901
2920
|
},
|
|
2902
2921
|
}),
|
|
2903
|
-
!isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
|
|
2904
2922
|
state.backendStatus === 'connecting' ? h('p', { key: 'bst-connecting', class: 'lede', role: 'status' }, 'connecting…') : null,
|
|
2905
2923
|
state.backendStatus === 'ok' ? h('p', { key: 'bst-ok', class: 'lede', role: 'status' }, 'connected') : null,
|
|
2906
2924
|
state.backendStatus === 'failed' ? h('p', { key: 'bst-failed', class: 'lede field-error', role: 'alert' }, 'connection failed - check the URL') : null,
|
|
@@ -2911,7 +2929,7 @@ function settingsMain() {
|
|
|
2911
2929
|
key: 'savebtn',
|
|
2912
2930
|
type: 'submit',
|
|
2913
2931
|
primary: true,
|
|
2914
|
-
disabled: !isValid || state.
|
|
2932
|
+
disabled: !isValid || state.backendStatus === 'connecting',
|
|
2915
2933
|
onClick: (e) => { e.preventDefault(); saveBackend(); },
|
|
2916
2934
|
children: state.backendStatus === 'connecting' ? 'connecting…' : 'save + reconnect',
|
|
2917
2935
|
title: isValid ? 'Save backend URL and reconnect' : 'Fix URL format first',
|
|
@@ -2977,6 +2995,8 @@ function clearLocalData() {
|
|
|
2977
2995
|
// defaults with the keys gone.
|
|
2978
2996
|
state.chat.abort?.abort(); // stop any in-flight stream before we drop the page
|
|
2979
2997
|
for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend', 'agentgui.live', 'agentgui.files']) lsRemove(k);
|
|
2998
|
+
// Also wipe kit WorkspaceShell layout keys (collapse state + resizer widths).
|
|
2999
|
+
try { for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith('ds.ws.')) localStorage.removeItem(k); } } catch {}
|
|
2980
3000
|
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, confirmingEdit: null, totalCost: 0 };
|
|
2981
3001
|
location.reload();
|
|
2982
3002
|
}
|
|
@@ -3013,7 +3033,7 @@ function preferencesPanel() {
|
|
|
3013
3033
|
state.confirmingClearData
|
|
3014
3034
|
? Alert({ key: 'cld', kind: 'warn', title: 'Clear all local data?',
|
|
3015
3035
|
children: [
|
|
3016
|
-
h('span', { key: 'cldtxt' }, 'Removes saved chat, agent/model/cwd, and
|
|
3036
|
+
h('span', { key: 'cldtxt' }, 'Removes saved chat, agent/model/cwd, backend, and layout preferences from this browser. This cannot be undone. '),
|
|
3017
3037
|
Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' }),
|
|
3018
3038
|
Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' })] })
|
|
3019
3039
|
: Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
|
|
@@ -3046,7 +3066,9 @@ function agentsPanel() {
|
|
|
3046
3066
|
const bits = [PROTOCOL_WORDS[a.protocol] || 'agent'];
|
|
3047
3067
|
if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
|
|
3048
3068
|
if (acp) bits.push(acp.healthy ? 'running healthy' : (acp.running ? 'running' : 'stopped'));
|
|
3049
|
-
if (acp && acp.restartCount
|
|
3069
|
+
if (acp && acp.restartCount >= 1) bits.push('restarted ' + acp.restartCount + (acp.restartCount === 1 ? ' time' : ' times'));
|
|
3070
|
+
if (acp && !acp.healthy && acp.providerInfo?.error) bits.push(acp.providerInfo.error);
|
|
3071
|
+
if (acp && acp.idleMs > 3_600_000) bits.push(fmtDuration(acp.idleMs) + ' idle');
|
|
3050
3072
|
return Row({
|
|
3051
3073
|
key: 'ag' + a.id,
|
|
3052
3074
|
rank: String(i + 1).padStart(3, '0'),
|
|
@@ -3055,7 +3077,7 @@ function agentsPanel() {
|
|
|
3055
3077
|
// Rail tone keeps its GUI-wide meaning: green=ok/selected,
|
|
3056
3078
|
// flame=error/unavailable. Selection is shown via `active`, not by
|
|
3057
3079
|
// borrowing purple (purple is reserved for subagents).
|
|
3058
|
-
rail: (a.id === state.selectedAgent
|
|
3080
|
+
rail: !avail ? 'flame' : (a.id === state.selectedAgent ? 'green' : undefined),
|
|
3059
3081
|
active: a.id === state.selectedAgent,
|
|
3060
3082
|
// Non-installable agents are genuinely inert: mark them disabled (no
|
|
3061
3083
|
// click, no button role) instead of looking clickable but doing nothing.
|
|
@@ -3192,6 +3214,8 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
|
|
|
3192
3214
|
// whole session) - cap in-memory state at the most-recent 5000 so a
|
|
3193
3215
|
// monster session can't pin the tab; the render window stays 300+load-older.
|
|
3194
3216
|
if (state.events.length > 5000) state.events = state.events.slice(-5000);
|
|
3217
|
+
// Stamp stable _idx so EventList keys are stable regardless of slice/cap.
|
|
3218
|
+
state.events.forEach((e, i) => { if (e._idx == null) e._idx = i; });
|
|
3195
3219
|
clearTimeout(slowTimer);
|
|
3196
3220
|
state.eventsSlow = false;
|
|
3197
3221
|
state.eventsLoaded = true;
|
package/site/app/js/backend.js
CHANGED
|
@@ -377,6 +377,7 @@ export async function* streamChat(base, { model, messages, signal, agentId, resu
|
|
|
377
377
|
} else if (ev.type === 'streaming_progress') {
|
|
378
378
|
const block = ev.block;
|
|
379
379
|
if (block?.type === 'text' && block.text) push({ type: 'text', text: block.text });
|
|
380
|
+
else if (block?.type === 'thinking' && block.thinking) push({ type: 'thinking', text: block.thinking });
|
|
380
381
|
else if (block?.type === 'tool_use') push({ type: 'tool', block });
|
|
381
382
|
else if (block?.type === 'tool_result') push({ type: 'tool_result', block });
|
|
382
383
|
else if (block?.type === 'result') push({ type: 'result', block });
|
|
@@ -498,12 +498,12 @@
|
|
|
498
498
|
.ds-247420 ::selection { background: color-mix(in oklab, var(--accent) 24%, var(--bg-2)); color: var(--fg); }
|
|
499
499
|
|
|
500
500
|
/* Every root has a CQ container so fluid type can resolve to a meaningful inline-size. */
|
|
501
|
-
.ds-247420 .app, .ds-247420
|
|
501
|
+
.ds-247420 .app, .ds-247420 .ds-stage {
|
|
502
502
|
container-type: inline-size;
|
|
503
503
|
overflow-x: clip;
|
|
504
504
|
min-width: 0;
|
|
505
505
|
}
|
|
506
|
-
.ds-247420 .app
|
|
506
|
+
.ds-247420 .app * { min-width: 0; }
|
|
507
507
|
|
|
508
508
|
/* ============================================================
|
|
509
509
|
Typography
|
|
@@ -2675,9 +2675,10 @@
|
|
|
2675
2675
|
re-declare it; the sizing block now states the truth). */
|
|
2676
2676
|
line-height: 1.5; resize: none;
|
|
2677
2677
|
min-height: 28px; max-height: 200px;
|
|
2678
|
-
box-sizing: border-box; overflow-y:
|
|
2678
|
+
box-sizing: border-box; overflow-y: hidden;
|
|
2679
2679
|
scrollbar-width: thin;
|
|
2680
2680
|
}
|
|
2681
|
+
.ds-247420 .chat-composer textarea:focus { overflow-y: auto; }
|
|
2681
2682
|
.ds-247420 .chat-composer textarea::placeholder { color: var(--fg-3); }
|
|
2682
2683
|
.ds-247420 .chat-composer textarea:focus { background: none; border: none; box-shadow: none; outline: none; }
|
|
2683
2684
|
.ds-247420 .chat-composer .send {
|
|
@@ -3709,6 +3710,7 @@
|
|
|
3709
3710
|
@media (pointer: coarse) {
|
|
3710
3711
|
.ds-247420 .ws-rail-toggle { width: 44px; height: 44px; }
|
|
3711
3712
|
.ds-247420 .ws-drawer-toggle { width: 44px; height: 44px; }
|
|
3713
|
+
.ds-247420 .ws-desktop-toggle { width: 44px; height: 44px; }
|
|
3712
3714
|
}
|
|
3713
3715
|
|
|
3714
3716
|
/* Drawer toggles and the scrim are hidden by default and revealed by the staged
|
|
@@ -3726,6 +3728,7 @@
|
|
|
3726
3728
|
pane becomes a mobile overlay drawer at <=1480px, reached via its own
|
|
3727
3729
|
drawer-toggle), so hide this crumb control past that breakpoint. */
|
|
3728
3730
|
@media (max-width: 1480px) { .ds-247420 .ws-pane-toggle { display: none; } }
|
|
3731
|
+
@media (max-width: 480px) { .ds-247420 .ws-crumb { padding-left: var(--space-2); padding-right: var(--space-2); } }
|
|
3729
3732
|
.ds-247420 .ws-scrim { display: none; }
|
|
3730
3733
|
|
|
3731
3734
|
/* Responsive: the columns yield to the CONTENT in stages - the main column is
|
|
@@ -7902,9 +7905,6 @@
|
|
|
7902
7905
|
.ds-247420 .ds-event-list .row[role="button"]:hover { background: color-mix(in srgb, var(--fg) 5%, transparent); }
|
|
7903
7906
|
.ds-247420 .ds-event-list .row.event-flash { animation: agentgui-event-flash 2s ease-out; }
|
|
7904
7907
|
|
|
7905
|
-
/* Chat composer: hide the idle scrollbar on the (empty/short) textarea. */
|
|
7906
|
-
.ds-247420 .chat-composer textarea { overflow-y: auto; scrollbar-width: thin; }
|
|
7907
|
-
.ds-247420 .chat-composer textarea:not(:focus) { overflow-y: hidden; }
|
|
7908
7908
|
|
|
7909
7909
|
/* Generic interactive focus ring for app-emitted controls. */
|
|
7910
7910
|
.ds-247420 button:focus-visible,
|