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.
@@ -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 PASSWORD is set the server holds credentials a cross-origin
105
- // page must not be able to spend, so we do NOT advertise a wildcard origin
106
- // (a wildcard + a leaked Bearer/token would let any site the user visits
107
- // drive this server). Reflect an explicitly-allowed origin (CORS_ORIGIN)
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
- } else if (!process.env.PASSWORD) {
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', '.svg': 'image/svg+xml' };
688
- // Only serve known image types - never an octet-stream fallback, which
689
- // would turn this into a generic file reader for any extension.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.974",
3
+ "version": "1.0.976",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -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.' }));
@@ -131,7 +131,8 @@ function scheduleStreamRender() {
131
131
  requestAnimationFrame(() => {
132
132
  streamRenderScheduled = false;
133
133
  render();
134
- scrollChatToBottom();
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) scheduleRender();
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...' : 'rename', cancelLabel: 'cancel',
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...' : 'delete', cancelLabel: 'cancel', destructive: true,
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...' : 'delete ' + n, cancelLabel: 'cancel', destructive: true,
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...' : 'move ' + n, cancelLabel: 'cancel',
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...' : 'create', cancelLabel: 'cancel',
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 content = block?.content ?? block?.output ?? block;
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: sessionDuration() } : null,
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 ?? state.events.filter(e => e.role === 'user').length) },
2491
- { label: 'tools', value: String(state.events.filter(e => e.type === 'tool_use').length) },
2492
- { label: 'errors', value: String(state.events.filter(e => e.isError).length) },
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 absIdx = total - shown.length + i;
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(absIdx + 1).padStart(4, '0'),
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
- const u = new URL(s.startsWith('http') ? s : 'http://' + s);
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.startsWith('http') ? s : 'http://' + s).origin; }
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) || state.backendDraft === state.backend) return;
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
- 'aria-describedby': !isValid ? 'backend-url-error' : undefined,
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.backendDraft === state.backend || state.backendStatus === 'connecting',
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 backend from this browser. This cannot be undone. '),
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 > 1) bits.push('restarted ' + acp.restartCount + ' times');
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 || avail) ? 'green' : 'flame',
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;
@@ -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[class*="kit-"], .ds-247420 .ds-stage {
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 *, .ds-247420[class*="kit-"] * { min-width: 0; }
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: auto;
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,