agentgui 1.0.1061 → 1.0.1062

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/.gm/prd.yml CHANGED
@@ -3106,3 +3106,24 @@
3106
3106
  - id: docstudio-cue-session-group-eyebrow
3107
3107
  subject: Verify ConversationList's session-group headers (Today/Yesterday/etc) use the --tr-label token + --fg-3 tone consistently, matching docstudio's uppercase letter-spaced low-opacity eyebrow label idiom
3108
3108
  status: pending
3109
+ - id: gc-delete-undo-trash
3110
+ subject: No undo-after-delete (soft-delete/trash) anywhere in the confined delete surface
3111
+ status: pending
3112
+ - id: gc-search-hit-hash-anchor
3113
+ subject: Search-hit event anchor (focusEventI/focusEventTs) not carried in the hash - reload/Back loses highlighted line
3114
+ status: pending
3115
+ - id: gc-settings-section-scrollspy
3116
+ subject: Settings section anchor is deep-link-in only - scrolling never updates state.settingsSection or the URL
3117
+ status: pending
3118
+ - id: gc-context-budget-affordance
3119
+ subject: No context-size/token-budget affordance - only turn count and dollar cost shown, never remaining context headroom
3120
+ status: pending
3121
+ - id: gc-expanded-body-highlight
3122
+ subject: Search-result highlighting inside expanded event bodies never highlights the matched query term
3123
+ status: pending
3124
+ - id: gc-search-results-export
3125
+ subject: No per-session or global export of History search results themselves
3126
+ status: pending
3127
+ - id: gc-settings-storage-estimate
3128
+ subject: Settings has no cache/data breakdown beyond agentgui's own localStorage keys - no navigator.storage.estimate()
3129
+ status: pending
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.1061",
3
+ "version": "1.0.1062",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -1012,6 +1012,15 @@ function fileMutationCopy(e) {
1012
1012
  if (e.status === 413) return 'Too large (50MB upload cap).';
1013
1013
  return e.message || 'The operation failed.';
1014
1014
  }
1015
+ // On a 409 name-collision, suggest 'name (2)' / 'name (3)' etc instead of
1016
+ // leaving the user to retype the identical blocked string from scratch -
1017
+ // mirrors the numbering convention most desktop file managers use.
1018
+ function suggestAlternateName(name) {
1019
+ const m = /^(.*) \((\d+)\)(\.[^.]*)?$/.exec(name);
1020
+ if (m) return m[1] + ' (' + (parseInt(m[2], 10) + 1) + ')' + (m[3] || '');
1021
+ const dot = name.lastIndexOf('.');
1022
+ return dot > 0 ? name.slice(0, dot) + ' (2)' + name.slice(dot) : name + ' (2)';
1023
+ }
1015
1024
  function openFileDialog(kind, file) {
1016
1025
  const trigger = typeof document !== 'undefined' ? document.activeElement : null;
1017
1026
  state.files.dialog = { kind, file: file || null, error: null, busy: false, _trigger: trigger || null };
@@ -1285,7 +1294,7 @@ function fileDialog() {
1285
1294
  // flow, so a sibling alert was invisible and outside the focus trap).
1286
1295
  if (d.kind === 'rename') {
1287
1296
  return PromptDialog({
1288
- title: 'Rename ' + d.file.name, value: d.file.name, placeholder: 'new name',
1297
+ title: 'Rename ' + d.file.name, value: d.suggestedValue ?? d.file.name, placeholder: 'new name',
1289
1298
  error: d.error || null, busy: !!d.busy,
1290
1299
  confirmLabel: d.busy ? 'renaming…' : 'rename', cancelLabel: 'cancel',
1291
1300
  onCancel: closeFileDialog,
@@ -1295,7 +1304,17 @@ function fileDialog() {
1295
1304
  runFileMutation(() => B.renameEntry(state.backend, d.file.path, v), 'renamed to ' + v,
1296
1305
  (entries) => entries.map((e) => (e.path || e.name) === (d.file.path || d.file.name)
1297
1306
  ? { ...e, name: v, path: e.path ? e.path.slice(0, e.path.length - d.file.name.length) + v : v }
1298
- : e));
1307
+ : e))
1308
+ .then(() => {
1309
+ // On a 409 name-collision, prefill the retry input with a
1310
+ // suggested alternate instead of leaving the identical blocked
1311
+ // string - matching the pattern uploadFiles' 409-retry rows
1312
+ // already establish for the same class of conflict.
1313
+ if (state.files.dialog === d && d.error && /already exists/i.test(d.error)) {
1314
+ d.suggestedValue = suggestAlternateName(v);
1315
+ render();
1316
+ }
1317
+ });
1299
1318
  },
1300
1319
  });
1301
1320
  }
@@ -1348,11 +1367,18 @@ function fileDialog() {
1348
1367
  }
1349
1368
  return PromptDialog({
1350
1369
  title: 'Move ' + n + ' selected ' + (n === 1 ? 'entry' : 'entries'),
1351
- value: state.files.path || '', placeholder: 'destination folder path',
1370
+ value: d._draft ?? (state.files.path || ''), placeholder: 'destination folder path',
1352
1371
  error: d.error || null, busy: !!d.busy,
1353
1372
  confirmLabel: d.busy ? 'moving…' : 'move ' + n, cancelLabel: 'cancel',
1373
+ // A second/third allowed root has no discoverable path other than
1374
+ // typing it from memory - one-click chips for every accessible root,
1375
+ // matching the same practicality upgrade the cwd editor got.
1376
+ roots: (Array.isArray(state.files.roots) && state.files.roots.length > 1)
1377
+ ? state.files.roots.map((r) => ({ path: r, label: truncate(projectLabel(r) || r, 14, 24) }))
1378
+ : undefined,
1354
1379
  onCancel: closeFileDialog,
1355
- onInput: (v) => { d.error = null; d._validateDest(v); },
1380
+ onInput: (v) => { d._draft = v; d.error = null; d._validateDest(v); },
1381
+ onPickRoot: (v) => { d._draft = v; d.error = null; d._validateDest(v); render(); },
1356
1382
  onConfirm: (v) => {
1357
1383
  if (!v) { d.error = 'enter a destination folder'; render(); return; }
1358
1384
  if (v === state.files.path) { d.error = 'already in that folder - enter a different destination'; render(); return; }
@@ -1362,7 +1388,7 @@ function fileDialog() {
1362
1388
  }
1363
1389
  if (d.kind === 'mkdir') {
1364
1390
  return PromptDialog({
1365
- title: 'New folder', value: '', placeholder: 'folder name',
1391
+ title: 'New folder', value: d.suggestedValue ?? '', placeholder: 'folder name',
1366
1392
  error: d.error || null, busy: !!d.busy,
1367
1393
  confirmLabel: d.busy ? 'creating…' : 'create', cancelLabel: 'cancel',
1368
1394
  onCancel: closeFileDialog,
@@ -1371,7 +1397,13 @@ function fileDialog() {
1371
1397
  runFileMutation(() => B.makeDir(state.backend, state.files.path, v), 'created ' + v,
1372
1398
  (entries) => entries.some((e) => e.name === v)
1373
1399
  ? entries
1374
- : [...entries, { name: v, path: (state.files.path ? state.files.path.replace(/\/$/, '') + '/' : '') + v, type: 'dir' }]);
1400
+ : [...entries, { name: v, path: (state.files.path ? state.files.path.replace(/\/$/, '') + '/' : '') + v, type: 'dir' }])
1401
+ .then(() => {
1402
+ if (state.files.dialog === d && d.error && /already exists/i.test(d.error)) {
1403
+ d.suggestedValue = suggestAlternateName(v);
1404
+ render();
1405
+ }
1406
+ });
1375
1407
  },
1376
1408
  });
1377
1409
  }
@@ -2104,6 +2136,7 @@ function applyToolResult(parts, block) {
2104
2136
  const ERR_COPY = [
2105
2137
  [/^ws closed$/, 'Lost connection to the server.'],
2106
2138
  [/connection lost during stream/, 'Lost connection while the agent was responding - use retry.'],
2139
+ [/connection dropped mid-turn/, 'Connection dropped mid-turn - the response above may be incomplete (events weren\'t replayed). Retry to try again.'],
2107
2140
  [/no sessionId from server/, 'The server could not start the agent - check it is installed.'],
2108
2141
  [/^sessions: \d+/, 'History is still indexing - try again in a moment.'],
2109
2142
  ];
@@ -2166,11 +2199,17 @@ function chatMain() {
2166
2199
  banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
2167
2200
  }
2168
2201
  if (state.chat.externalUpdate) {
2202
+ const hasDraft = !!(state.chat.draft && state.chat.draft.trim());
2169
2203
  banners.push(Alert({ key: 'xupd', kind: 'info', title: 'This chat was updated in another tab',
2170
2204
  children: [
2171
- h('span', { key: 'xutxt' }, 'Reload it to see the latest turns, or dismiss to keep this tab\'s view. '),
2205
+ h('span', { key: 'xutxt' }, 'Reload it to see the latest turns, or dismiss to keep this tab\'s view. '
2206
+ // "reload it" re-pulls the transcript from localStorage, which
2207
+ // silently discarded whatever was typed but not yet sent in THIS
2208
+ // tab - warn before that data-loss, not just after.
2209
+ + (hasDraft ? 'This tab has an unsent draft that reloading will discard. ' : '')),
2172
2210
  Btn({ key: 'xureload', disabled: state.chat.busy, onClick: () => {
2173
2211
  if (state.chat.busy) return;
2212
+ if (hasDraft && !window.confirm('Reloading will discard your unsent draft in this tab. Continue?')) return;
2174
2213
  state.chat.externalUpdate = false;
2175
2214
  state.chat.messages = []; state.chat.resumeSid = null; state.chat.totalCost = 0;
2176
2215
  restoreChat(); render();
@@ -2189,9 +2228,15 @@ function chatMain() {
2189
2228
  Btn({ key: 'ptruncdismiss', onClick: () => { state.chat.persistTruncated = false; render(); }, children: 'dismiss' })] }));
2190
2229
  }
2191
2230
  if (state.agentsError) {
2231
+ // A failed agents.list call (server/network issue) is a distinct case
2232
+ // from "the list loaded but nothing is installed" (installHint below,
2233
+ // which only fires on a genuinely non-empty, all-unavailable list) -
2234
+ // recommending npx-install commands here would be actively wrong, since
2235
+ // we don't actually know whether anything is installed. Just clarify the
2236
+ // failure is transient/server-side, not a "nothing is installed" state.
2192
2237
  banners.push(Alert({ key: 'agerr', kind: 'error', title: 'Could not load agents from the server',
2193
2238
  children: [
2194
- h('span', { key: 'agtxt', title: state.agentsError }, 'The agent list failed to load. '),
2239
+ h('span', { key: 'agtxt', title: state.agentsError }, 'The agent list failed to load - this is a connection issue, not necessarily a sign nothing is installed. '),
2195
2240
  Btn({ key: 'agretry', onClick: () => loadAgents(), children: 'retry' })] }));
2196
2241
  }
2197
2242
  if (state.chat.loadingTranscript) {
@@ -2200,9 +2245,19 @@ function chatMain() {
2200
2245
  }
2201
2246
  if (state.chat.confirmingEdit) {
2202
2247
  const isRetry = state.chat.confirmingEdit.kind === 'retry';
2248
+ // Name the actual count/cost about to be discarded instead of the vague
2249
+ // "the later turns" - a user retrying/editing message 2 of 40 needs to
2250
+ // know whether that's 1 turn or 38 before confirming a destructive undo.
2251
+ const truncIdx = state.chat.confirmingEdit.idx;
2252
+ const discarded = state.chat.messages.slice(truncIdx + 1);
2253
+ const discardedTurns = discarded.filter((m) => m.role === 'user').length;
2254
+ const discardedCost = discarded.reduce((s, m) => s + (typeof m.costUsd === 'number' ? m.costUsd : 0), 0);
2255
+ const discardSummary = discardedTurns
2256
+ ? plural(discardedTurns, 'turn') + (discardedCost > 0 ? ' ($' + discardedCost.toFixed(4) + ')' : '')
2257
+ : null;
2203
2258
  banners.push(Alert({ key: 'confedit', kind: 'warn', title: isRetry ? 'Retry this turn?' : 'Edit this message?',
2204
2259
  children: [
2205
- h('span', { key: 'cetext' }, (isRetry ? 'Retrying' : 'Editing') + ' will remove the later turns - continue? '),
2260
+ h('span', { key: 'cetext' }, (isRetry ? 'Retrying' : 'Editing') + ' will remove ' + (discardSummary ? discardSummary + ' after this point' : 'the later turns') + ' - continue? '),
2206
2261
  Btn({ key: 'ceno', onClick: cancelEditAndResend, children: 'cancel' }),
2207
2262
  Btn({ key: 'ceyes', danger: true, onClick: confirmEditAndResend, children: isRetry ? 'retry' : 'continue' })] }));
2208
2263
  }
@@ -2466,9 +2521,25 @@ function cwdBrowsePick(path) {
2466
2521
  }
2467
2522
 
2468
2523
  function offlineBanner() {
2524
+ // A session-expired 401 is a distinct, more actionable case than a plain
2525
+ // network-down offline state (the fix is "reload", not "wait for
2526
+ // reconnect") - surfaced first since it takes priority as an explanation.
2527
+ if (state.sessionExpired) {
2528
+ return Alert({ key: 'sessionexpired', kind: 'error', title: 'Session expired',
2529
+ children: [
2530
+ h('span', { key: 'setxt' }, 'Your access token is no longer valid (it may have rotated or expired). Reload the page to sign in again. '),
2531
+ Btn({ key: 'sereload', onClick: () => window.location.reload(), children: 'reload now' }),
2532
+ ] });
2533
+ }
2469
2534
  if (state.health.status === 'ok' || state.health.status === 'unknown') return null;
2470
2535
  return Alert({ key: 'offline', kind: 'error', title: 'Backend unreachable',
2471
- children: 'agentgui can\'t reach the server (' + (state.health.error || state.health.status) + '). Chat and history actions will fail until it reconnects.' });
2536
+ children: [
2537
+ h('span', { key: 'otxt' }, 'agentgui can\'t reach the server (' + (state.health.error || state.health.status) + '). Chat and history actions will fail until it reconnects. '),
2538
+ // Previously the only recovery was waiting for the WS backoff timer or
2539
+ // a manual page reload - a direct retry button re-probes /health
2540
+ // immediately instead of making the user guess how long to wait.
2541
+ Btn({ key: 'oretry', disabled: !!state.healthChecking, onClick: () => recheckHealth(), children: state.healthChecking ? 'checking…' : 'retry now' }),
2542
+ ] });
2472
2543
  }
2473
2544
 
2474
2545
  // (The working-directory bar now lives in the AgentChat kit; agentgui wires its
@@ -2972,10 +3043,10 @@ function eventMatchesFilter(e, f) {
2972
3043
 
2973
3044
  // Scroll to + flash the first error event, widening the render window (and
2974
3045
  // clearing the type filter) so the row is actually rendered.
2975
- function jumpToFirstError() {
2976
- const idx = state.events.findIndex(e => e.isError);
3046
+ function jumpToEvent(idx) {
2977
3047
  if (idx < 0) return;
2978
3048
  state.eventFilter = 'all';
3049
+ state._errorNavIdx = idx;
2979
3050
  const fromEnd = state.events.length - idx;
2980
3051
  if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
2981
3052
  render();
@@ -2987,6 +3058,21 @@ function jumpToFirstError() {
2987
3058
  if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
2988
3059
  });
2989
3060
  }
3061
+ function jumpToFirstError() {
3062
+ const idx = state.events.findIndex(e => e.isError);
3063
+ jumpToEvent(idx);
3064
+ }
3065
+ // Persistent next/prev navigation between error events - jumpToFirstError
3066
+ // alone only reaches the FIRST error once; a session with multiple errors had
3067
+ // no way to step through them without manually scanning the event list.
3068
+ function jumpToNextError(dir) {
3069
+ const errIdxs = state.events.reduce((acc, e, i) => { if (e.isError) acc.push(i); return acc; }, []);
3070
+ if (!errIdxs.length) return;
3071
+ const cur = state._errorNavIdx;
3072
+ let pos = errIdxs.indexOf(cur);
3073
+ pos = pos < 0 ? (dir > 0 ? 0 : errIdxs.length - 1) : (pos + dir + errIdxs.length) % errIdxs.length;
3074
+ jumpToEvent(errIdxs[pos]);
3075
+ }
2990
3076
 
2991
3077
  function historyMain() {
2992
3078
  if (!state.selectedSid) {
@@ -3012,8 +3098,14 @@ function historyMain() {
3012
3098
  }
3013
3099
 
3014
3100
  const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
3101
+ // sess.model is the raw ccsniff-sourced field (41st-run fix); ccsniff only
3102
+ // reads Claude Code's own JSONL so the agent is always constant. The
3103
+ // History detail header previously never surfaced either, reading
3104
+ // identity-thin next to the same session's Running-panel/Live-dashboard
3105
+ // rows which both show an agent+model badge.
3106
+ const agentModelBit = sess?.model ? ((agentById('claude-code')?.name || 'Claude Code') + ' · ' + sess.model) : null;
3015
3107
  const lede = sess
3016
- ? (projectLabel(sess.project) || pathBasename(sess.cwd) || 'unknown location') + ' · ' + plural(sess.events || 0, 'event') + ' · ' + plural(sess.userTurns || 0, 'turn') + ' · ' + fmtRelTime(sess.last)
3108
+ ? (projectLabel(sess.project) || pathBasename(sess.cwd) || 'unknown location') + (agentModelBit ? ' · ' + agentModelBit : '') + ' · ' + plural(sess.events || 0, 'event') + ' · ' + plural(sess.userTurns || 0, 'turn') + ' · ' + fmtRelTime(sess.last)
3017
3109
  : UNTITLED_CONVERSATION;
3018
3110
 
3019
3111
  const head = PageHeader({
@@ -3031,6 +3123,11 @@ function historyMain() {
3031
3123
  onClick: () => downloadBlob(JSON.stringify(state.events, null, 2), (projectLabel(sess?.project) || 'session') + '-' + state.selectedSid + '.json', 'application/json'),
3032
3124
  children: 'export' }),
3033
3125
  hasErrors ? Btn({ key: 'jumperr', onClick: jumpToFirstError, children: 'jump to first error' }) : null,
3126
+ // Persistent next/prev stepping between errors - jump-to-first alone only
3127
+ // ever reaches the FIRST one; a session with several errors had no way to
3128
+ // step through the rest without manually scrolling/scanning.
3129
+ hasErrors ? Btn({ key: 'errprev', title: 'previous error', 'aria-label': 'previous error', onClick: () => jumpToNextError(-1), children: 'prev error' }) : null,
3130
+ hasErrors ? Btn({ key: 'errnext', title: 'next error', 'aria-label': 'next error', onClick: () => jumpToNextError(1), children: 'next error' }) : null,
3034
3131
  ].filter(Boolean));
3035
3132
 
3036
3133
  if (state.events.length === 0) {
@@ -4047,6 +4144,13 @@ function registerWsStatusOnce() {
4047
4144
  if (state.health.ws) { delete state.health.ws; render(); }
4048
4145
  }
4049
4146
  });
4147
+ // backend.js already detects a mid-session 401 (a token that stopped being
4148
+ // valid - e.g. PASSWORD rotated, cookie expired) and exports this hook, but
4149
+ // nothing ever subscribed to it: every subsequent fetch just failed
4150
+ // silently with no on-screen indication of WHY. Surface it as a persistent
4151
+ // banner instructing a reload (the only real recovery - the token lives in
4152
+ // window.__WS_TOKEN, injected server-side at page load).
4153
+ B.onSessionExpired?.(() => { state.sessionExpired = true; render(); });
4050
4154
  }
4051
4155
 
4052
4156
  hydratePrefs();
@@ -2686,6 +2686,17 @@
2686
2686
  @media (hover: none), (pointer: coarse) {
2687
2687
  .ds-247420 .ds-modal-input { font-size: 16px; min-height: 44px; }
2688
2688
  }
2689
+ /* PromptDialog's optional roots-chip row (destination-path prompts with more
2690
+ than one allowed root) - same chip idiom the cwd editor's roots row uses. */
2691
+ .ds-247420 .ds-prompt-roots { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
2692
+ .ds-247420 .ds-prompt-root-chip {
2693
+ background: var(--bg-2); border: var(--bw-hair) solid var(--rule); color: var(--fg-2);
2694
+ border-radius: var(--r-pill); padding: 3px 10px; font: inherit; font-size: var(--fs-tiny);
2695
+ cursor: pointer;
2696
+ }
2697
+ .ds-247420 .ds-prompt-root-chip:hover { border-color: var(--accent); color: var(--fg); }
2698
+ .ds-247420 .ds-prompt-root-chip:focus-visible { outline: var(--focus-w) solid var(--focus-color); outline-offset: var(--focus-offset); }
2699
+ @media (pointer: coarse) { .ds-247420 .ds-prompt-root-chip { min-height: 44px; } }
2689
2700
  /* In-body modal error (role=alert): mutation failures (409/403) surface INSIDE
2690
2701
  the dialog, not behind the fixed overlay. */
2691
2702
  .ds-247420 .ds-modal-error {