agentgui 1.0.1122 → 1.0.1124
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 +1 -5
- package/lib/http-routes/shared.js +15 -3
- package/package.json +1 -1
- package/site/app/js/app.js +16 -414
- package/site/app/js/history.js +435 -0
package/.gm/prd.yml
CHANGED
|
@@ -1,5 +1 @@
|
|
|
1
|
-
|
|
2
|
-
subject: Extract site/app/js/app.js's mixed concerns into per-concern modules
|
|
3
|
-
witness: 'Slice 1/4 DONE (chat-persistence, sha d211f32cbd, live-witnessed). Slice 2 (keyboard shortcuts, app.js:4649-4758 as of this commit) was investigated concretely: the global keydown handler references ~10 app.js-local functions across every feature area (navTo, closeFileDialog, filesMarked, clearFileSelection, cancelChat, newChat, previewNeighbours, openPreview, announce, render) plus state fields spanning chat/files/live/settings -- the highest cross-cutting coupling in the file. This is the exact code AGENTS.md flags as historically crash-prone under webjsx keying mistakes, and a rushed extraction threading 10 dependencies through a factory is a real correctness risk, not a mechanical move.'
|
|
4
|
-
description: 'Slice 2 (shortcuts) needs a dedicated pass: enumerate the full dependency list precisely (grep every identifier referenced inside the keydown handler + SHORTCUTS array + focusComposer/focusSearch/focusFilter), decide whether those ~10 functions move WITH the shortcuts module (turning it into a larger cut than ''just shortcuts'') or stay in app.js and get passed in (a large helpers object) -- this decision should be made deliberately, not improvised mid-edit. Slices 3 (hash-routing) and 4 (settings/history) still queued after. Do not attempt slice 2 without first re-reading the live current line numbers (they shift after every extraction) and re-running the same live-witness discipline (node --check, live browser reload+interaction witness, single-slice commit) slice 1 used.'
|
|
5
|
-
status: pending
|
|
1
|
+
[]
|
|
@@ -124,15 +124,27 @@ export function safeErrMsg(err) {
|
|
|
124
124
|
|
|
125
125
|
// Read a request body with a hard size cap; resolves a Buffer or rejects with
|
|
126
126
|
// .code='TOO_LARGE' so the caller can answer 413 without buffering the rest.
|
|
127
|
+
// Stops accumulating further chunks (req.pause()) but does NOT destroy the
|
|
128
|
+
// socket - a destroyed request cannot carry the caller's 413 response back to
|
|
129
|
+
// the client, which instead sees a raw ECONNRESET with zero information
|
|
130
|
+
// (live-witnessed: fetch() reported only "fetch failed", no status code).
|
|
131
|
+
// The still-open connection is closed normally once the 413 response is sent.
|
|
127
132
|
export function readBody(req, maxBytes) {
|
|
128
133
|
return new Promise((resolve, reject) => {
|
|
129
|
-
const chunks = []; let total = 0;
|
|
134
|
+
const chunks = []; let total = 0; let tooLarge = false;
|
|
130
135
|
req.on('data', (c) => {
|
|
136
|
+
if (tooLarge) return;
|
|
131
137
|
total += c.length;
|
|
132
|
-
if (total > maxBytes) {
|
|
138
|
+
if (total > maxBytes) {
|
|
139
|
+
tooLarge = true;
|
|
140
|
+
req.pause();
|
|
141
|
+
const e = new Error('body too large'); e.code = 'TOO_LARGE';
|
|
142
|
+
reject(e);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
133
145
|
chunks.push(c);
|
|
134
146
|
});
|
|
135
|
-
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
147
|
+
req.on('end', () => { if (!tooLarge) resolve(Buffer.concat(chunks)); });
|
|
136
148
|
req.on('error', reject);
|
|
137
149
|
});
|
|
138
150
|
}
|
package/package.json
CHANGED
package/site/app/js/app.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as B from './backend.js';
|
|
|
3
3
|
import { createChatPersistence, CHAT_KEY } from './chat-persistence.js';
|
|
4
4
|
import { installShortcuts } from './shortcuts.js';
|
|
5
5
|
import { readHash as readHashRouting, buildHash as buildHashRouting, writeHash as writeHashRouting } from './hash-routing.js';
|
|
6
|
+
import { createHistory } from './history.js';
|
|
6
7
|
|
|
7
8
|
installStyles().catch(() => {});
|
|
8
9
|
|
|
@@ -3232,249 +3233,16 @@ function eventMatchesFilter(e, f) {
|
|
|
3232
3233
|
|
|
3233
3234
|
// Scroll to + flash the first error event, widening the render window (and
|
|
3234
3235
|
// clearing the type filter) so the row is actually rendered.
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
const rows = document.querySelectorAll('.ds-event-list .row');
|
|
3246
|
-
const row = rows[rowPos];
|
|
3247
|
-
if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
|
|
3248
|
-
});
|
|
3249
|
-
}
|
|
3250
|
-
function jumpToFirstError() {
|
|
3251
|
-
const idx = state.events.findIndex(e => e.isError);
|
|
3252
|
-
jumpToEvent(idx);
|
|
3253
|
-
}
|
|
3254
|
-
// Persistent next/prev navigation between error events - jumpToFirstError
|
|
3255
|
-
// alone only reaches the FIRST error once; a session with multiple errors had
|
|
3256
|
-
// no way to step through them without manually scanning the event list.
|
|
3257
|
-
function jumpToNextError(dir) {
|
|
3258
|
-
const errIdxs = state.events.reduce((acc, e, i) => { if (e.isError) acc.push(i); return acc; }, []);
|
|
3259
|
-
if (!errIdxs.length) return;
|
|
3260
|
-
const cur = state._errorNavIdx;
|
|
3261
|
-
let pos = errIdxs.indexOf(cur);
|
|
3262
|
-
pos = pos < 0 ? (dir > 0 ? 0 : errIdxs.length - 1) : (pos + dir + errIdxs.length) % errIdxs.length;
|
|
3263
|
-
jumpToEvent(errIdxs[pos]);
|
|
3264
|
-
}
|
|
3265
|
-
|
|
3266
|
-
function historyMain() {
|
|
3267
|
-
if (!state.selectedSid) {
|
|
3268
|
-
const count = (Array.isArray(state.sessions) ? state.sessions : []).length;
|
|
3269
|
-
return [
|
|
3270
|
-
reconnectAlert(),
|
|
3271
|
-
PageHeader({
|
|
3272
|
-
compact: true,
|
|
3273
|
-
dense: true,
|
|
3274
|
-
title: 'History',
|
|
3275
|
-
lede: 'Pick a conversation to inspect its events as they happen.',
|
|
3276
|
-
}),
|
|
3277
|
-
h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
|
|
3278
|
-
h('p', { key: 'gt', class: 'history-empty-title' },
|
|
3279
|
-
count ? 'Select a conversation to view its events' : 'No conversations yet'),
|
|
3280
|
-
h('p', { key: 'gs', class: 'history-empty-sub' },
|
|
3281
|
-
count
|
|
3282
|
-
? count + ' conversation' + (count === 1 ? '' : 's') + ' available · use the search box or press / to filter'
|
|
3283
|
-
: 'Start a chat or run a local coding agent - its conversation will appear here live.'),
|
|
3284
|
-
count ? h('div', { key: 'gh', class: 'history-empty-hints' },
|
|
3285
|
-
ShortcutList({ shortcuts: SHORTCUTS.slice(0, 4) })) : null),
|
|
3286
|
-
].filter(Boolean);
|
|
3287
|
-
}
|
|
3288
|
-
|
|
3289
|
-
const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
3290
|
-
// sess.model is the raw ccsniff-sourced field (41st-run fix); ccsniff only
|
|
3291
|
-
// reads Claude Code's own JSONL so the agent is always constant. The
|
|
3292
|
-
// History detail header previously never surfaced either, reading
|
|
3293
|
-
// identity-thin next to the same session's Running-panel/Live-dashboard
|
|
3294
|
-
// rows which both show an agent+model badge.
|
|
3295
|
-
const agentModelBit = sess?.model ? ((agentById('claude-code')?.name || 'Claude Code') + ' · ' + sess.model) : null;
|
|
3296
|
-
const lede = sess
|
|
3297
|
-
? (projectLabel(sess.project) || pathBasename(sess.cwd) || 'unknown location') + (agentModelBit ? ' · ' + agentModelBit : '') + ' · ' + plural(sess.events || 0, 'event') + ' · ' + plural(sess.userTurns || 0, 'turn') + ' · ' + fmtRelTime(sess.last)
|
|
3298
|
-
: UNTITLED_CONVERSATION;
|
|
3299
|
-
|
|
3300
|
-
const head = PageHeader({
|
|
3301
|
-
compact: true,
|
|
3302
|
-
dense: true,
|
|
3303
|
-
title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || pathBasename(sess?.cwd) || state.selectedSid || UNTITLED_CONVERSATION, 40, 80),
|
|
3304
|
-
lede,
|
|
3305
|
-
});
|
|
3306
|
-
|
|
3307
|
-
const hasErrors = state.events.some(e => e.isError);
|
|
3308
|
-
const actions = h('div', { key: 'acts', class: 'history-actions' }, [
|
|
3309
|
-
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
|
|
3310
|
-
Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy conversation id' }),
|
|
3311
|
-
Btn({ key: 'exportsess', disabled: !state.eventsLoaded, title: 'Download this session\'s events as JSON',
|
|
3312
|
-
onClick: () => downloadBlob(JSON.stringify(state.events, null, 2), (projectLabel(sess?.project) || 'session') + '-' + state.selectedSid + '.json', 'application/json'),
|
|
3313
|
-
children: 'export' }),
|
|
3314
|
-
hasErrors ? Btn({ key: 'jumperr', onClick: jumpToFirstError, children: 'jump to first error' }) : null,
|
|
3315
|
-
// Persistent next/prev stepping between errors - jump-to-first alone only
|
|
3316
|
-
// ever reaches the FIRST one; a session with several errors had no way to
|
|
3317
|
-
// step through the rest without manually scrolling/scanning.
|
|
3318
|
-
hasErrors ? Btn({ key: 'errprev', title: 'previous error', 'aria-label': 'previous error', onClick: () => jumpToNextError(-1), children: 'prev error' }) : null,
|
|
3319
|
-
hasErrors ? Btn({ key: 'errnext', title: 'next error', 'aria-label': 'next error', onClick: () => jumpToNextError(1), children: 'next error' }) : null,
|
|
3320
|
-
].filter(Boolean));
|
|
3321
|
-
|
|
3322
|
-
if (state.events.length === 0) {
|
|
3323
|
-
// Distinguish "still loading" from "genuinely empty" so a 0-event session
|
|
3324
|
-
// doesn't spin forever. After 5s of an unresolved first fetch, swap to the
|
|
3325
|
-
// indexing copy (ccsniff's first JSONL walk can take a minute).
|
|
3326
|
-
const body = state.eventsLoaded
|
|
3327
|
-
? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
|
|
3328
|
-
h('span', { key: 'noevtxt' }, 'no events in this conversation'),
|
|
3329
|
-
Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
|
|
3330
|
-
// Shape-matched skeleton rows (kit EventList loading state) instead of a
|
|
3331
|
-
// lone spinner collapsing the slowest pane in the product.
|
|
3332
|
-
: h('div', { key: 'loading' }, EventList({ items: [], loading: true,
|
|
3333
|
-
loadingText: state.eventsSlow ? 'Indexing your Claude history — the first load can take a minute…' : 'loading events…' }));
|
|
3334
|
-
return [reconnectAlert(), head, actions, Panel({ title: 'events', kind: 'wide', children: body })].filter(Boolean);
|
|
3335
|
-
}
|
|
3336
|
-
|
|
3337
|
-
if (!state.expandedEvents) state.expandedEvents = new Set();
|
|
3338
|
-
// Event-type filter applies BEFORE the render-window slice so "errors" shows
|
|
3339
|
-
// every error in the session, not only errors among the most-recent 300.
|
|
3340
|
-
const ef = state.eventFilter || 'all';
|
|
3341
|
-
const filteredEvents = ef === 'all' ? state.events : state.events.filter(e => eventMatchesFilter(e, ef));
|
|
3342
|
-
const filterPills = FilterPills({
|
|
3343
|
-
options: [
|
|
3344
|
-
{ id: 'all', label: 'all' },
|
|
3345
|
-
{ id: 'text', label: 'text' },
|
|
3346
|
-
{ id: 'tool', label: 'tools' },
|
|
3347
|
-
{ id: 'errors', label: 'errors' },
|
|
3348
|
-
{ id: 'thinking', label: 'thinking' },
|
|
3349
|
-
],
|
|
3350
|
-
selected: ef,
|
|
3351
|
-
onSelect: (id) => { state.eventFilter = id && id.id ? id.id : id; render(); },
|
|
3352
|
-
label: 'Filter events by type',
|
|
3353
|
-
});
|
|
3354
|
-
// Single pass over state.events for all three counters (replaces three separate .filter() calls).
|
|
3355
|
-
const evCounters = state.events.reduce((c, e) => {
|
|
3356
|
-
if (e.role === 'user') c.turns++;
|
|
3357
|
-
if (e.type === 'tool_use') c.tools++;
|
|
3358
|
-
if (e.isError) c.errors++;
|
|
3359
|
-
return c;
|
|
3360
|
-
}, { turns: 0, tools: 0, errors: 0 });
|
|
3361
|
-
const meta = SessionMeta({
|
|
3362
|
-
items: [
|
|
3363
|
-
sess && sess.cwd ? { label: 'directory', value: sess.cwd, title: sess.cwd,
|
|
3364
|
-
actionLabel: 'use as chat cwd',
|
|
3365
|
-
onAction: () => { state.chatCwd = sess.cwd; lsSet('agentgui.cwd', sess.cwd); pushRecentCwd(sess.cwd); announce('working directory set to ' + sess.cwd); render(); } } : null,
|
|
3366
|
-
(() => { const dur = sessionDuration(); return dur ? { label: 'duration', value: dur } : null; })(),
|
|
3367
|
-
{ label: 'session id', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'session id copied') },
|
|
3368
|
-
// Spelled counter vocabulary in the detail strip (events/turns/tools/
|
|
3369
|
-
// errors); the abbreviated 'ev/tools/err' triple stays compact-row-only.
|
|
3370
|
-
{ label: 'events', value: String(state.events.length) },
|
|
3371
|
-
{ label: 'turns', value: String(sess?.userTurns ?? evCounters.turns) },
|
|
3372
|
-
{ label: 'tools', value: String(evCounters.tools) },
|
|
3373
|
-
{ label: 'errors', value: String(evCounters.errors) },
|
|
3374
|
-
sess && sess.cost != null ? { label: 'cost', value: '$' + Number(sess.cost).toFixed(4) } : null,
|
|
3375
|
-
].filter(Boolean),
|
|
3376
|
-
});
|
|
3377
|
-
if (filteredEvents.length === 0) {
|
|
3378
|
-
return [reconnectAlert(), head, actions, Panel({ title: 'events', kind: 'wide', children: [
|
|
3379
|
-
h('div', { key: 'evmeta' }, meta),
|
|
3380
|
-
h('div', { key: 'evfp' }, filterPills),
|
|
3381
|
-
h('div', { key: 'nofilt', class: 'lede empty-state', role: 'status' },
|
|
3382
|
-
h('span', { key: 'noftxt' }, 'no events match this filter'),
|
|
3383
|
-
Btn({ key: 'clearf', onClick: () => { state.eventFilter = 'all'; render(); }, children: 'clear filter' })),
|
|
3384
|
-
] })].filter(Boolean);
|
|
3385
|
-
}
|
|
3386
|
-
const total = filteredEvents.length;
|
|
3387
|
-
const limit = state.eventsLimit;
|
|
3388
|
-
const shown = filteredEvents.slice(-limit);
|
|
3389
|
-
const hiddenCount = total - shown.length;
|
|
3390
|
-
// Keys of the currently-shown rows, so expand-all toggles only what's rendered.
|
|
3391
|
-
const shownKeys = shown.map((e, i) => e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + (e._idx ?? (total - shown.length + i)));
|
|
3392
|
-
const allExpanded = shownKeys.length > 0 && shownKeys.every(k => state.expandedEvents.has(k));
|
|
3393
|
-
const eventControls = h('div', { key: 'evctrl', class: 'history-actions', role: 'group', 'aria-label': 'event controls' },
|
|
3394
|
-
Btn({ key: 'expall', onClick: () => {
|
|
3395
|
-
if (allExpanded) { shownKeys.forEach(k => state.expandedEvents.delete(k)); }
|
|
3396
|
-
else { shownKeys.forEach(k => state.expandedEvents.add(k)); }
|
|
3397
|
-
render();
|
|
3398
|
-
}, children: allExpanded ? 'collapse shown' : 'expand shown' }),
|
|
3399
|
-
hiddenCount > 0
|
|
3400
|
-
? Btn({ key: 'older', onClick: () => { const added = Math.min(300, hiddenCount); state.eventsLimit += 300; announce('loaded ' + added + ' more events'); render(); }, children: 'load ' + Math.min(300, hiddenCount) + ' older (' + hiddenCount + ' hidden)' })
|
|
3401
|
-
: null,
|
|
3402
|
-
// A per-click 300-event step is fine for casual scanning, but a huge
|
|
3403
|
-
// session (thousands hidden) makes that a lot of repeat clicks - a
|
|
3404
|
-
// secondary "load all" jumps straight to the full transcript.
|
|
3405
|
-
hiddenCount > 1000
|
|
3406
|
-
? Btn({ key: 'loadall', onClick: () => { state.eventsLimit = total; announce('loaded all ' + total + ' events'); render(); }, children: 'load all (' + hiddenCount + ' hidden)' })
|
|
3407
|
-
: null,
|
|
3408
|
-
);
|
|
3409
|
-
return [
|
|
3410
|
-
reconnectAlert(),
|
|
3411
|
-
head,
|
|
3412
|
-
actions,
|
|
3413
|
-
Panel({
|
|
3414
|
-
title: plural(total, 'event') + (ef !== 'all' ? ' (' + ef + ' filter)' : '') + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
|
|
3415
|
-
kind: 'wide',
|
|
3416
|
-
children: [h('div', { key: 'evmeta' }, meta), h('div', { key: 'evfp' }, filterPills), eventControls, EventList({
|
|
3417
|
-
items: shown.map((e, i) => {
|
|
3418
|
-
// Stable key: prefer the server-assigned event index, else the
|
|
3419
|
-
// event timestamp + position, never a bare array index (which
|
|
3420
|
-
// collides between loaded and live-pushed events).
|
|
3421
|
-
// Stable key: server event index when present, else ts + the event's
|
|
3422
|
-
// ABSOLUTE position in state.events (not the sliced-view index, which
|
|
3423
|
-
// shifts when live events append and would collide loaded vs live rows).
|
|
3424
|
-
const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + (e._idx ?? (total - shown.length + i));
|
|
3425
|
-
const role = e.role || '?';
|
|
3426
|
-
const type = e.type || '?';
|
|
3427
|
-
const tool = e.tool ? ' · tool: ' + e.tool : '';
|
|
3428
|
-
const errMark = e.isError ? ' · error' : '';
|
|
3429
|
-
const raw = e.text || '';
|
|
3430
|
-
const text = raw.replace(/\s+/g, ' ').trim() || (e.type === 'tool_use' && e.toolInput ? toolLabel(e.toolInput) : '');
|
|
3431
|
-
const toolNamePrefix = (e.type === 'tool_use' && e.tool) ? e.tool + ': ' : '';
|
|
3432
|
-
const typePrefix = e.type === 'tool_result' ? '(result) ' : (e.type === 'tool_use' ? ('(tool call) ' + toolNamePrefix) : '');
|
|
3433
|
-
const expanded = state.expandedEvents.has(key);
|
|
3434
|
-
// Only build the expanded body (JSON.stringify tool input) when the row is
|
|
3435
|
-
// expanded - doing it for all ~300 rows every frame wastes work mid-stream.
|
|
3436
|
-
const full = expanded ? (e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw) : '';
|
|
3437
|
-
// Rail tone matches the session/agents rail semantics so an event's
|
|
3438
|
-
// kind is visible at a glance, consistent across the GUI:
|
|
3439
|
-
// flame = error, purple = tool activity, green = normal turn.
|
|
3440
|
-
const rail = e.isError ? 'flame' : (e.type === 'tool_use' || e.type === 'tool_result' ? 'purple' : 'green');
|
|
3441
|
-
// When the session was opened from a search hit, window the collapsed
|
|
3442
|
-
// title AROUND the first query match (a match at char 5000 would
|
|
3443
|
-
// otherwise be invisible behind the 0-220 slice).
|
|
3444
|
-
let collapsedTitle = typePrefix + text.slice(0, 220);
|
|
3445
|
-
const q = state.sessionSearchQ;
|
|
3446
|
-
if (q && !expanded) {
|
|
3447
|
-
const qi = text.toLowerCase().indexOf(q.toLowerCase());
|
|
3448
|
-
if (qi > 60) collapsedTitle = '…' + text.slice(qi - 60, qi - 60 + 220);
|
|
3449
|
-
}
|
|
3450
|
-
return {
|
|
3451
|
-
key,
|
|
3452
|
-
code: String(total - shown.length + i + 1).padStart(4, '0'),
|
|
3453
|
-
rail,
|
|
3454
|
-
expanded, // disclosure state -> kit Row sets aria-expanded
|
|
3455
|
-
highlight: q || undefined,
|
|
3456
|
-
// Copy is available whether or not the row is expanded - a user
|
|
3457
|
-
// scanning collapsed rows for a specific payload shouldn't have
|
|
3458
|
-
// to expand every row first just to copy one.
|
|
3459
|
-
actions: [{
|
|
3460
|
-
label: 'copy', title: 'copy event',
|
|
3461
|
-
onClick: () => copyText(full || raw || ('(' + type + ')'), 'event copied'),
|
|
3462
|
-
}],
|
|
3463
|
-
title: expanded ? (typePrefix + (text || '(' + type + ')')) : (collapsedTitle || typePrefix + '(' + type + ')'),
|
|
3464
|
-
detail: expanded && e.toolInput ? JSON.stringify(e.toolInput, null, 2) : undefined,
|
|
3465
|
-
// Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
|
|
3466
|
-
// Every row is click-to-expand, so always show the affordance word
|
|
3467
|
-
// (not only when text overflows 220 chars).
|
|
3468
|
-
// Relative time matches every other surface; the absolute stamp
|
|
3469
|
-
// appears when the row is expanded (forensic precision preserved).
|
|
3470
|
-
sub: (e.ts ? (expanded ? new Date(e.ts).toLocaleString() : fmtRelTime(e.ts)) : 'no time') + ' · ' + role + ' · ' + type + tool + errMark + ' · ' + (expanded ? 'collapse' : 'expand'),
|
|
3471
|
-
onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
|
|
3472
|
-
};
|
|
3473
|
-
}),
|
|
3474
|
-
})],
|
|
3475
|
-
}),
|
|
3476
|
-
].filter(Boolean);
|
|
3477
|
-
}
|
|
3236
|
+
// History tab (session list, event viewer, search) - extracted to
|
|
3237
|
+
// history.js. Local names kept identical to every existing call site.
|
|
3238
|
+
const { jumpToEvent, jumpToFirstError, jumpToNextError, historyMain, refreshHistory: _refreshHistory, runSearch: _runSearch, loadSession } = createHistory(state, () => render(), B, {
|
|
3239
|
+
h, PageHeader, ShortcutList, Btn, Panel, EventList, FilterPills, SessionMeta,
|
|
3240
|
+
SHORTCUTS, UNTITLED_CONVERSATION,
|
|
3241
|
+
reconnectAlert, agentById, projectLabel, pathBasename, plural, fmtRelTime, truncate,
|
|
3242
|
+
eventMatchesFilter, sessionDuration, resumeInChat, downloadBlob, toolLabel, copyText,
|
|
3243
|
+
pushRecentCwd, announce, lsSet, errText, writeHash,
|
|
3244
|
+
getCopyToast: () => copyToast, copySid: (...args) => copySid(...args),
|
|
3245
|
+
});
|
|
3478
3246
|
|
|
3479
3247
|
let copyToast = null;
|
|
3480
3248
|
// Hold the toast long enough to read (2.5s); the copy button label is inside a
|
|
@@ -4103,184 +3871,18 @@ function agentsPanel() {
|
|
|
4103
3871
|
}
|
|
4104
3872
|
|
|
4105
3873
|
// --- data ---
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
if (state._historyFetching) return;
|
|
4111
|
-
state._historyFetching = true;
|
|
4112
|
-
// Warmup copy: the FIRST sessions fetch can sit behind ccsniff's 30-90s
|
|
4113
|
-
// JSONL walk; after 5s swap the loading copy to indexing language.
|
|
4114
|
-
const firstLoad = !state._historyLoadedOnce;
|
|
4115
|
-
const slowTimer = firstLoad
|
|
4116
|
-
? setTimeout(() => { if (!state._historyLoadedOnce) { state.historySlow = true; render(); } }, 5000)
|
|
4117
|
-
: null;
|
|
4118
|
-
try {
|
|
4119
|
-
state.sessions = await B.listSessions(state.backend);
|
|
4120
|
-
state._historyLoadedOnce = true;
|
|
4121
|
-
state._historyLoadedAt = Date.now();
|
|
4122
|
-
state.historySlow = false;
|
|
4123
|
-
// Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
|
|
4124
|
-
// linear scan per event during a burst load.
|
|
4125
|
-
state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
|
|
4126
|
-
state._sessionGroupsCache = null;
|
|
4127
|
-
// Bound the live tally: drop entries with no activity in 24h and cap the
|
|
4128
|
-
// Map at ~200 most-recent sids (a long-lived tab otherwise accumulates
|
|
4129
|
-
// every sid ever seen, and dead entries could resurrect wrong externals).
|
|
4130
|
-
if (state.live.tally) {
|
|
4131
|
-
const cutoff = Date.now() - 24 * 3600 * 1000;
|
|
4132
|
-
for (const [sid, t] of [...state.live.tally]) {
|
|
4133
|
-
if (!t.last || t.last < cutoff) state.live.tally.delete(sid);
|
|
4134
|
-
}
|
|
4135
|
-
if (state.live.tally.size > 200) {
|
|
4136
|
-
state.live.tally = new Map([...state.live.tally.entries()]
|
|
4137
|
-
.sort((a, b) => (b[1].last || 0) - (a[1].last || 0))
|
|
4138
|
-
.slice(0, 200));
|
|
4139
|
-
}
|
|
4140
|
-
}
|
|
4141
|
-
// If the selected session vanished from the list (deleted/aged out server-side),
|
|
4142
|
-
// drop the selection so the main pane doesn't sit on stale events that can no
|
|
4143
|
-
// longer be reloaded; fall back to the no-selection empty state.
|
|
4144
|
-
if (state.selectedSid && !state.sessionsBySid.has(state.selectedSid)) {
|
|
4145
|
-
state.selectedSid = null;
|
|
4146
|
-
state.events = [];
|
|
4147
|
-
state.eventsLoaded = false;
|
|
4148
|
-
writeHash();
|
|
4149
|
-
}
|
|
4150
|
-
state.historyError = null;
|
|
4151
|
-
} catch (e) {
|
|
4152
|
-
// Only a genuine fetch/list failure is a history error. A render exception
|
|
4153
|
-
// must not masquerade as one (it would poison the sessions panel with a
|
|
4154
|
-
// render-stack string and never clear), so render() lives outside this try.
|
|
4155
|
-
state.historyError = errText(e);
|
|
4156
|
-
console.warn('history fetch failed:', e.message);
|
|
4157
|
-
} finally {
|
|
4158
|
-
state._historyFetching = false;
|
|
4159
|
-
if (slowTimer) clearTimeout(slowTimer);
|
|
4160
|
-
render();
|
|
4161
|
-
}
|
|
4162
|
-
}
|
|
3874
|
+
// refreshHistory/runSearch/loadSession - extracted to history.js, bound to
|
|
3875
|
+
// the local names _refreshHistory/_runSearch above (loadSession keeps its
|
|
3876
|
+
// name directly since nothing else in app.js shadows it).
|
|
3877
|
+
const refreshHistory = _refreshHistory;
|
|
4163
3878
|
const debouncedRefreshHistory = debounce(refreshHistory, 500);
|
|
4164
3879
|
// Debounced files filter: toLowerCase() on every entry runs on every keystroke;
|
|
4165
3880
|
// 150ms coalesces rapid typing into one filter pass (perf-003).
|
|
4166
3881
|
const debouncedFilesFilter = debounce((v) => { state.files.filter = v; state.files.shown = null; if (state.tab === 'files') writeHash(); render(); }, 150);
|
|
4167
3882
|
|
|
4168
|
-
|
|
4169
|
-
const q = state.searchQ.trim();
|
|
4170
|
-
if (!q) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
|
|
4171
|
-
if (q.length < 2) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
|
|
4172
|
-
// The project-filter pills are hidden while searching; clear the filter so it
|
|
4173
|
-
// doesn't silently re-apply (and surprise the user) when they later clear the
|
|
4174
|
-
// search and the now-visible session list is unexpectedly narrowed.
|
|
4175
|
-
state.projectFilter = '';
|
|
4176
|
-
// The debounced search keeps the URL's q=/project= in sync via replaceState
|
|
4177
|
-
// so a reload (or share) restores the search, without flooding history.
|
|
4178
|
-
writeHash();
|
|
4179
|
-
state.searchBusy = true;
|
|
4180
|
-
render();
|
|
4181
|
-
try {
|
|
4182
|
-
state.searchHits = await B.searchHistory(state.backend, q, 60);
|
|
4183
|
-
// Announce the settled count for AT - the sessions-column count is only
|
|
4184
|
-
// rendered visually (the history actions row is the only aria-live region).
|
|
4185
|
-
const n = (state.searchHits.results || []).length;
|
|
4186
|
-
announce((n || 'no') + ' matches for ' + q);
|
|
4187
|
-
} catch (e) {
|
|
4188
|
-
state.searchHits = { query: q, results: [], error: errText(e) };
|
|
4189
|
-
} finally {
|
|
4190
|
-
state.searchBusy = false;
|
|
4191
|
-
render();
|
|
4192
|
-
}
|
|
4193
|
-
}
|
|
3883
|
+
const runSearch = _runSearch;
|
|
4194
3884
|
const debouncedSearch = debounce(runSearch, 300);
|
|
4195
3885
|
|
|
4196
|
-
async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromHash = false } = {}) {
|
|
4197
|
-
// Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
|
|
4198
|
-
if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
|
|
4199
|
-
if (sid === state.selectedSid && state.eventsLoaded && !fromHash && focusEventI == null && focusEventTs == null) {
|
|
4200
|
-
render();
|
|
4201
|
-
requestAnimationFrame(() => { document.querySelector('.app-side .row.active')?.scrollIntoView({ block: 'nearest' }); });
|
|
4202
|
-
return;
|
|
4203
|
-
}
|
|
4204
|
-
state.selectedSid = sid;
|
|
4205
|
-
// A plain (non-search-hit) session open must not carry a stale event
|
|
4206
|
-
// anchor forward into the URL - only reset it when this call ISN'T itself
|
|
4207
|
-
// the one supplying a fresh focusEventTs.
|
|
4208
|
-
if (focusEventTs == null) state._focusEventTs = null;
|
|
4209
|
-
state.events = [];
|
|
4210
|
-
state.events._seen = new Set(); // O(1) dedupe by event index
|
|
4211
|
-
state.eventsLoaded = false;
|
|
4212
|
-
state.eventsSlow = false;
|
|
4213
|
-
state.eventsLimit = 300; // reset the render window per session
|
|
4214
|
-
state.eventFilter = 'all'; // don't carry the type filter across sessions
|
|
4215
|
-
state.expandedEvents = new Set(); // don't carry expansion to the new session
|
|
4216
|
-
// Remember the query this session was opened FROM (search hit) so the event
|
|
4217
|
-
// rows can highlight + window around the match; a plain selection clears it.
|
|
4218
|
-
state.sessionSearchQ = (focusEventI != null || focusEventTs != null) && state.searchQ.trim().length >= 2
|
|
4219
|
-
? state.searchQ.trim() : null;
|
|
4220
|
-
// The live "live · N" crumb counter reads as the selected session's activity,
|
|
4221
|
-
// so reset it per selection rather than letting it accrue across all sessions.
|
|
4222
|
-
state.live.eventCount = 0;
|
|
4223
|
-
writeHash({ push: !fromHash });
|
|
4224
|
-
// Warmup copy: a first events fetch can sit behind ccsniff's JSONL walk.
|
|
4225
|
-
const slowTimer = setTimeout(() => { if (!state.eventsLoaded && state.selectedSid === sid) { state.eventsSlow = true; render(); } }, 5000);
|
|
4226
|
-
// Close the mobile sidebar drawer on selection. The DS only auto-closes when
|
|
4227
|
-
// the clicked element is an <a>; agentgui's session rows are onClick divs, so
|
|
4228
|
-
// we close it explicitly here.
|
|
4229
|
-
// Close the WorkspaceShell mobile sessions drawer on session selection.
|
|
4230
|
-
if (state.wsSessions) { state.wsSessions = false; }
|
|
4231
|
-
document.querySelector('[data-ws-sessions-open]')?.removeAttribute('data-ws-sessions-open');
|
|
4232
|
-
render();
|
|
4233
|
-
// Bring the now-active sidebar row into view (deep-link / back-forward may
|
|
4234
|
-
// select a row that's scrolled out of the session list).
|
|
4235
|
-
requestAnimationFrame(() => {
|
|
4236
|
-
document.querySelector('.app-side .row.active')?.scrollIntoView({ block: 'nearest' });
|
|
4237
|
-
});
|
|
4238
|
-
try {
|
|
4239
|
-
state.events = await B.getSessionEvents(state.backend, sid);
|
|
4240
|
-
// ccsniff's events route has no ?limit= (checked: router.js returns the
|
|
4241
|
-
// whole session) - cap in-memory state at the most-recent 5000 so a
|
|
4242
|
-
// monster session can't pin the tab; the render window stays 300+load-older.
|
|
4243
|
-
if (state.events.length > 5000) state.events = state.events.slice(-5000);
|
|
4244
|
-
// Stamp stable _idx so EventList keys are stable regardless of slice/cap.
|
|
4245
|
-
state.events.forEach((e, i) => { if (e._idx == null) e._idx = i; });
|
|
4246
|
-
clearTimeout(slowTimer);
|
|
4247
|
-
state.eventsSlow = false;
|
|
4248
|
-
state.eventsLoaded = true;
|
|
4249
|
-
// If we arrived from a search hit, make sure the matched event is within the
|
|
4250
|
-
// render window, then scroll to + flash it so the match isn't lost.
|
|
4251
|
-
if (focusEventI != null || focusEventTs != null) {
|
|
4252
|
-
const idx = state.events.findIndex(e => (focusEventI != null && e.i === focusEventI) || (focusEventTs != null && e.ts === focusEventTs));
|
|
4253
|
-
if (idx >= 0) {
|
|
4254
|
-
const fromEnd = state.events.length - idx;
|
|
4255
|
-
if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
|
|
4256
|
-
render();
|
|
4257
|
-
// The rendered EventList shows the last eventsLimit events in order, so
|
|
4258
|
-
// the matched event's row is at (idx - sliceStart) among .ds-event-list rows.
|
|
4259
|
-
const sliceStart = Math.max(0, state.events.length - state.eventsLimit);
|
|
4260
|
-
const rowPos = idx - sliceStart;
|
|
4261
|
-
requestAnimationFrame(() => {
|
|
4262
|
-
const rows = document.querySelectorAll('.ds-event-list .row');
|
|
4263
|
-
const row = rows[rowPos];
|
|
4264
|
-
if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
|
|
4265
|
-
});
|
|
4266
|
-
return;
|
|
4267
|
-
}
|
|
4268
|
-
}
|
|
4269
|
-
render();
|
|
4270
|
-
} catch (e) {
|
|
4271
|
-
state.events = [{
|
|
4272
|
-
ts: Date.now(),
|
|
4273
|
-
role: 'error',
|
|
4274
|
-
type: 'fetch',
|
|
4275
|
-
text: 'Failed to load session: ' + errText(e) + ' - retry via the rail',
|
|
4276
|
-
}];
|
|
4277
|
-
clearTimeout(slowTimer);
|
|
4278
|
-
state.eventsSlow = false;
|
|
4279
|
-
state.eventsLoaded = true;
|
|
4280
|
-
render();
|
|
4281
|
-
}
|
|
4282
|
-
}
|
|
4283
|
-
|
|
4284
3886
|
// Fetch agents + pick the active one. Reusable: boot, backend save, and the
|
|
4285
3887
|
// reconnect path all re-run it. Returns true on success; failure lands in
|
|
4286
3888
|
// state.agentsError so the chat tab can surface it with a retry control.
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// History tab: session list, event viewer, search - extracted from app.js
|
|
2
|
+
// (vertical slice per AGENTS.md SOLID/Clean-Architecture preferences). The
|
|
3
|
+
// factory takes every app.js dependency explicitly (state, render, backend
|
|
4
|
+
// module B, kit components, and helper functions) rather than closing over
|
|
5
|
+
// app.js's module scope implicitly - the same pattern used successfully for
|
|
6
|
+
// chat-persistence.js and shortcuts.js earlier in this split.
|
|
7
|
+
export function createHistory(state, render, B, deps) {
|
|
8
|
+
const {
|
|
9
|
+
h, PageHeader, ShortcutList, Btn, Panel, EventList, FilterPills, SessionMeta,
|
|
10
|
+
SHORTCUTS, UNTITLED_CONVERSATION,
|
|
11
|
+
reconnectAlert, agentById, projectLabel, pathBasename, plural, fmtRelTime, truncate,
|
|
12
|
+
eventMatchesFilter, sessionDuration, resumeInChat, downloadBlob, toolLabel, copyText,
|
|
13
|
+
pushRecentCwd, announce, lsSet, errText, writeHash,
|
|
14
|
+
getCopyToast, copySid,
|
|
15
|
+
} = deps;
|
|
16
|
+
|
|
17
|
+
function jumpToEvent(idx) {
|
|
18
|
+
if (idx < 0) return;
|
|
19
|
+
state.eventFilter = 'all';
|
|
20
|
+
state._errorNavIdx = idx;
|
|
21
|
+
const fromEnd = state.events.length - idx;
|
|
22
|
+
if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
|
|
23
|
+
render();
|
|
24
|
+
const sliceStart = Math.max(0, state.events.length - state.eventsLimit);
|
|
25
|
+
const rowPos = idx - sliceStart;
|
|
26
|
+
requestAnimationFrame(() => {
|
|
27
|
+
const rows = document.querySelectorAll('.ds-event-list .row');
|
|
28
|
+
const row = rows[rowPos];
|
|
29
|
+
if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function jumpToFirstError() {
|
|
33
|
+
const idx = state.events.findIndex(e => e.isError);
|
|
34
|
+
jumpToEvent(idx);
|
|
35
|
+
}
|
|
36
|
+
// Persistent next/prev navigation between error events - jumpToFirstError
|
|
37
|
+
// alone only reaches the FIRST error once; a session with multiple errors had
|
|
38
|
+
// no way to step through them without manually scanning the event list.
|
|
39
|
+
function jumpToNextError(dir) {
|
|
40
|
+
const errIdxs = state.events.reduce((acc, e, i) => { if (e.isError) acc.push(i); return acc; }, []);
|
|
41
|
+
if (!errIdxs.length) return;
|
|
42
|
+
const cur = state._errorNavIdx;
|
|
43
|
+
let pos = errIdxs.indexOf(cur);
|
|
44
|
+
pos = pos < 0 ? (dir > 0 ? 0 : errIdxs.length - 1) : (pos + dir + errIdxs.length) % errIdxs.length;
|
|
45
|
+
jumpToEvent(errIdxs[pos]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function historyMain() {
|
|
49
|
+
if (!state.selectedSid) {
|
|
50
|
+
const count = (Array.isArray(state.sessions) ? state.sessions : []).length;
|
|
51
|
+
return [
|
|
52
|
+
reconnectAlert(),
|
|
53
|
+
PageHeader({
|
|
54
|
+
compact: true,
|
|
55
|
+
dense: true,
|
|
56
|
+
title: 'History',
|
|
57
|
+
lede: 'Pick a conversation to inspect its events as they happen.',
|
|
58
|
+
}),
|
|
59
|
+
h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
|
|
60
|
+
h('p', { key: 'gt', class: 'history-empty-title' },
|
|
61
|
+
count ? 'Select a conversation to view its events' : 'No conversations yet'),
|
|
62
|
+
h('p', { key: 'gs', class: 'history-empty-sub' },
|
|
63
|
+
count
|
|
64
|
+
? count + ' conversation' + (count === 1 ? '' : 's') + ' available · use the search box or press / to filter'
|
|
65
|
+
: 'Start a chat or run a local coding agent - its conversation will appear here live.'),
|
|
66
|
+
count ? h('div', { key: 'gh', class: 'history-empty-hints' },
|
|
67
|
+
ShortcutList({ shortcuts: SHORTCUTS.slice(0, 4) })) : null),
|
|
68
|
+
].filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
72
|
+
// sess.model is the raw ccsniff-sourced field (41st-run fix); ccsniff only
|
|
73
|
+
// reads Claude Code's own JSONL so the agent is always constant. The
|
|
74
|
+
// History detail header previously never surfaced either, reading
|
|
75
|
+
// identity-thin next to the same session's Running-panel/Live-dashboard
|
|
76
|
+
// rows which both show an agent+model badge.
|
|
77
|
+
const agentModelBit = sess?.model ? ((agentById('claude-code')?.name || 'Claude Code') + ' · ' + sess.model) : null;
|
|
78
|
+
const lede = sess
|
|
79
|
+
? (projectLabel(sess.project) || pathBasename(sess.cwd) || 'unknown location') + (agentModelBit ? ' · ' + agentModelBit : '') + ' · ' + plural(sess.events || 0, 'event') + ' · ' + plural(sess.userTurns || 0, 'turn') + ' · ' + fmtRelTime(sess.last)
|
|
80
|
+
: UNTITLED_CONVERSATION;
|
|
81
|
+
|
|
82
|
+
const head = PageHeader({
|
|
83
|
+
compact: true,
|
|
84
|
+
dense: true,
|
|
85
|
+
title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || pathBasename(sess?.cwd) || state.selectedSid || UNTITLED_CONVERSATION, 40, 80),
|
|
86
|
+
lede,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const hasErrors = state.events.some(e => e.isError);
|
|
90
|
+
const actions = h('div', { key: 'acts', class: 'history-actions' }, [
|
|
91
|
+
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
|
|
92
|
+
Btn({ key: 'copy', onClick: copySid, children: getCopyToast() || 'copy conversation id' }),
|
|
93
|
+
Btn({ key: 'exportsess', disabled: !state.eventsLoaded, title: 'Download this session\'s events as JSON',
|
|
94
|
+
onClick: () => downloadBlob(JSON.stringify(state.events, null, 2), (projectLabel(sess?.project) || 'session') + '-' + state.selectedSid + '.json', 'application/json'),
|
|
95
|
+
children: 'export' }),
|
|
96
|
+
hasErrors ? Btn({ key: 'jumperr', onClick: jumpToFirstError, children: 'jump to first error' }) : null,
|
|
97
|
+
// Persistent next/prev stepping between errors - jump-to-first alone only
|
|
98
|
+
// ever reaches the FIRST one; a session with several errors had no way to
|
|
99
|
+
// step through the rest without manually scrolling/scanning.
|
|
100
|
+
hasErrors ? Btn({ key: 'errprev', title: 'previous error', 'aria-label': 'previous error', onClick: () => jumpToNextError(-1), children: 'prev error' }) : null,
|
|
101
|
+
hasErrors ? Btn({ key: 'errnext', title: 'next error', 'aria-label': 'next error', onClick: () => jumpToNextError(1), children: 'next error' }) : null,
|
|
102
|
+
].filter(Boolean));
|
|
103
|
+
|
|
104
|
+
if (state.events.length === 0) {
|
|
105
|
+
// Distinguish "still loading" from "genuinely empty" so a 0-event session
|
|
106
|
+
// doesn't spin forever. After 5s of an unresolved first fetch, swap to the
|
|
107
|
+
// indexing copy (ccsniff's first JSONL walk can take a minute).
|
|
108
|
+
const body = state.eventsLoaded
|
|
109
|
+
? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
|
|
110
|
+
h('span', { key: 'noevtxt' }, 'no events in this conversation'),
|
|
111
|
+
Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
|
|
112
|
+
// Shape-matched skeleton rows (kit EventList loading state) instead of a
|
|
113
|
+
// lone spinner collapsing the slowest pane in the product.
|
|
114
|
+
: h('div', { key: 'loading' }, EventList({ items: [], loading: true,
|
|
115
|
+
loadingText: state.eventsSlow ? 'Indexing your Claude history — the first load can take a minute…' : 'loading events…' }));
|
|
116
|
+
return [reconnectAlert(), head, actions, Panel({ title: 'events', kind: 'wide', children: body })].filter(Boolean);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!state.expandedEvents) state.expandedEvents = new Set();
|
|
120
|
+
// Event-type filter applies BEFORE the render-window slice so "errors" shows
|
|
121
|
+
// every error in the session, not only errors among the most-recent 300.
|
|
122
|
+
const ef = state.eventFilter || 'all';
|
|
123
|
+
const filteredEvents = ef === 'all' ? state.events : state.events.filter(e => eventMatchesFilter(e, ef));
|
|
124
|
+
const filterPills = FilterPills({
|
|
125
|
+
options: [
|
|
126
|
+
{ id: 'all', label: 'all' },
|
|
127
|
+
{ id: 'text', label: 'text' },
|
|
128
|
+
{ id: 'tool', label: 'tools' },
|
|
129
|
+
{ id: 'errors', label: 'errors' },
|
|
130
|
+
{ id: 'thinking', label: 'thinking' },
|
|
131
|
+
],
|
|
132
|
+
selected: ef,
|
|
133
|
+
onSelect: (id) => { state.eventFilter = id && id.id ? id.id : id; render(); },
|
|
134
|
+
label: 'Filter events by type',
|
|
135
|
+
});
|
|
136
|
+
// Single pass over state.events for all three counters (replaces three separate .filter() calls).
|
|
137
|
+
const evCounters = state.events.reduce((c, e) => {
|
|
138
|
+
if (e.role === 'user') c.turns++;
|
|
139
|
+
if (e.type === 'tool_use') c.tools++;
|
|
140
|
+
if (e.isError) c.errors++;
|
|
141
|
+
return c;
|
|
142
|
+
}, { turns: 0, tools: 0, errors: 0 });
|
|
143
|
+
const meta = SessionMeta({
|
|
144
|
+
items: [
|
|
145
|
+
sess && sess.cwd ? { label: 'directory', value: sess.cwd, title: sess.cwd,
|
|
146
|
+
actionLabel: 'use as chat cwd',
|
|
147
|
+
onAction: () => { state.chatCwd = sess.cwd; lsSet('agentgui.cwd', sess.cwd); pushRecentCwd(sess.cwd); announce('working directory set to ' + sess.cwd); render(); } } : null,
|
|
148
|
+
(() => { const dur = sessionDuration(); return dur ? { label: 'duration', value: dur } : null; })(),
|
|
149
|
+
{ label: 'session id', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'session id copied') },
|
|
150
|
+
// Spelled counter vocabulary in the detail strip (events/turns/tools/
|
|
151
|
+
// errors); the abbreviated 'ev/tools/err' triple stays compact-row-only.
|
|
152
|
+
{ label: 'events', value: String(state.events.length) },
|
|
153
|
+
{ label: 'turns', value: String(sess?.userTurns ?? evCounters.turns) },
|
|
154
|
+
{ label: 'tools', value: String(evCounters.tools) },
|
|
155
|
+
{ label: 'errors', value: String(evCounters.errors) },
|
|
156
|
+
sess && sess.cost != null ? { label: 'cost', value: '$' + Number(sess.cost).toFixed(4) } : null,
|
|
157
|
+
].filter(Boolean),
|
|
158
|
+
});
|
|
159
|
+
if (filteredEvents.length === 0) {
|
|
160
|
+
return [reconnectAlert(), head, actions, Panel({ title: 'events', kind: 'wide', children: [
|
|
161
|
+
h('div', { key: 'evmeta' }, meta),
|
|
162
|
+
h('div', { key: 'evfp' }, filterPills),
|
|
163
|
+
h('div', { key: 'nofilt', class: 'lede empty-state', role: 'status' },
|
|
164
|
+
h('span', { key: 'noftxt' }, 'no events match this filter'),
|
|
165
|
+
Btn({ key: 'clearf', onClick: () => { state.eventFilter = 'all'; render(); }, children: 'clear filter' })),
|
|
166
|
+
] })].filter(Boolean);
|
|
167
|
+
}
|
|
168
|
+
const total = filteredEvents.length;
|
|
169
|
+
const limit = state.eventsLimit;
|
|
170
|
+
const shown = filteredEvents.slice(-limit);
|
|
171
|
+
const hiddenCount = total - shown.length;
|
|
172
|
+
// Keys of the currently-shown rows, so expand-all toggles only what's rendered.
|
|
173
|
+
const shownKeys = shown.map((e, i) => e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + (e._idx ?? (total - shown.length + i)));
|
|
174
|
+
const allExpanded = shownKeys.length > 0 && shownKeys.every(k => state.expandedEvents.has(k));
|
|
175
|
+
const eventControls = h('div', { key: 'evctrl', class: 'history-actions', role: 'group', 'aria-label': 'event controls' },
|
|
176
|
+
Btn({ key: 'expall', onClick: () => {
|
|
177
|
+
if (allExpanded) { shownKeys.forEach(k => state.expandedEvents.delete(k)); }
|
|
178
|
+
else { shownKeys.forEach(k => state.expandedEvents.add(k)); }
|
|
179
|
+
render();
|
|
180
|
+
}, children: allExpanded ? 'collapse shown' : 'expand shown' }),
|
|
181
|
+
hiddenCount > 0
|
|
182
|
+
? Btn({ key: 'older', onClick: () => { const added = Math.min(300, hiddenCount); state.eventsLimit += 300; announce('loaded ' + added + ' more events'); render(); }, children: 'load ' + Math.min(300, hiddenCount) + ' older (' + hiddenCount + ' hidden)' })
|
|
183
|
+
: null,
|
|
184
|
+
// A per-click 300-event step is fine for casual scanning, but a huge
|
|
185
|
+
// session (thousands hidden) makes that a lot of repeat clicks - a
|
|
186
|
+
// secondary "load all" jumps straight to the full transcript.
|
|
187
|
+
hiddenCount > 1000
|
|
188
|
+
? Btn({ key: 'loadall', onClick: () => { state.eventsLimit = total; announce('loaded all ' + total + ' events'); render(); }, children: 'load all (' + hiddenCount + ' hidden)' })
|
|
189
|
+
: null,
|
|
190
|
+
);
|
|
191
|
+
return [
|
|
192
|
+
reconnectAlert(),
|
|
193
|
+
head,
|
|
194
|
+
actions,
|
|
195
|
+
Panel({
|
|
196
|
+
title: plural(total, 'event') + (ef !== 'all' ? ' (' + ef + ' filter)' : '') + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
|
|
197
|
+
kind: 'wide',
|
|
198
|
+
children: [h('div', { key: 'evmeta' }, meta), h('div', { key: 'evfp' }, filterPills), eventControls, EventList({
|
|
199
|
+
items: shown.map((e, i) => {
|
|
200
|
+
// Stable key: prefer the server-assigned event index, else the
|
|
201
|
+
// event timestamp + position, never a bare array index (which
|
|
202
|
+
// collides between loaded and live-pushed events).
|
|
203
|
+
// Stable key: server event index when present, else ts + the event's
|
|
204
|
+
// ABSOLUTE position in state.events (not the sliced-view index, which
|
|
205
|
+
// shifts when live events append and would collide loaded vs live rows).
|
|
206
|
+
const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + (e._idx ?? (total - shown.length + i));
|
|
207
|
+
const role = e.role || '?';
|
|
208
|
+
const type = e.type || '?';
|
|
209
|
+
const tool = e.tool ? ' · tool: ' + e.tool : '';
|
|
210
|
+
const errMark = e.isError ? ' · error' : '';
|
|
211
|
+
const raw = e.text || '';
|
|
212
|
+
const text = raw.replace(/\s+/g, ' ').trim() || (e.type === 'tool_use' && e.toolInput ? toolLabel(e.toolInput) : '');
|
|
213
|
+
const toolNamePrefix = (e.type === 'tool_use' && e.tool) ? e.tool + ': ' : '';
|
|
214
|
+
const typePrefix = e.type === 'tool_result' ? '(result) ' : (e.type === 'tool_use' ? ('(tool call) ' + toolNamePrefix) : '');
|
|
215
|
+
const expanded = state.expandedEvents.has(key);
|
|
216
|
+
// Only build the expanded body (JSON.stringify tool input) when the row is
|
|
217
|
+
// expanded - doing it for all ~300 rows every frame wastes work mid-stream.
|
|
218
|
+
const full = expanded ? (e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw) : '';
|
|
219
|
+
// Rail tone matches the session/agents rail semantics so an event's
|
|
220
|
+
// kind is visible at a glance, consistent across the GUI:
|
|
221
|
+
// flame = error, purple = tool activity, green = normal turn.
|
|
222
|
+
const rail = e.isError ? 'flame' : (e.type === 'tool_use' || e.type === 'tool_result' ? 'purple' : 'green');
|
|
223
|
+
// When the session was opened from a search hit, window the collapsed
|
|
224
|
+
// title AROUND the first query match (a match at char 5000 would
|
|
225
|
+
// otherwise be invisible behind the 0-220 slice).
|
|
226
|
+
let collapsedTitle = typePrefix + text.slice(0, 220);
|
|
227
|
+
const q = state.sessionSearchQ;
|
|
228
|
+
if (q && !expanded) {
|
|
229
|
+
const qi = text.toLowerCase().indexOf(q.toLowerCase());
|
|
230
|
+
if (qi > 60) collapsedTitle = '…' + text.slice(qi - 60, qi - 60 + 220);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
key,
|
|
234
|
+
code: String(total - shown.length + i + 1).padStart(4, '0'),
|
|
235
|
+
rail,
|
|
236
|
+
expanded, // disclosure state -> kit Row sets aria-expanded
|
|
237
|
+
highlight: q || undefined,
|
|
238
|
+
// Copy is available whether or not the row is expanded - a user
|
|
239
|
+
// scanning collapsed rows for a specific payload shouldn't have
|
|
240
|
+
// to expand every row first just to copy one.
|
|
241
|
+
actions: [{
|
|
242
|
+
label: 'copy', title: 'copy event',
|
|
243
|
+
onClick: () => copyText(full || raw || ('(' + type + ')'), 'event copied'),
|
|
244
|
+
}],
|
|
245
|
+
title: expanded ? (typePrefix + (text || '(' + type + ')')) : (collapsedTitle || typePrefix + '(' + type + ')'),
|
|
246
|
+
detail: expanded && e.toolInput ? JSON.stringify(e.toolInput, null, 2) : undefined,
|
|
247
|
+
// Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
|
|
248
|
+
// Every row is click-to-expand, so always show the affordance word
|
|
249
|
+
// (not only when text overflows 220 chars).
|
|
250
|
+
// Relative time matches every other surface; the absolute stamp
|
|
251
|
+
// appears when the row is expanded (forensic precision preserved).
|
|
252
|
+
sub: (e.ts ? (expanded ? new Date(e.ts).toLocaleString() : fmtRelTime(e.ts)) : 'no time') + ' · ' + role + ' · ' + type + tool + errMark + ' · ' + (expanded ? 'collapse' : 'expand'),
|
|
253
|
+
onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
|
|
254
|
+
};
|
|
255
|
+
}),
|
|
256
|
+
})],
|
|
257
|
+
}),
|
|
258
|
+
].filter(Boolean);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function refreshHistory() {
|
|
262
|
+
// Guard against concurrent calls: a slow first fetch followed by a polling
|
|
263
|
+
// trigger would otherwise stack two in-flight requests; the second would
|
|
264
|
+
// overwrite state mid-render with a stale response.
|
|
265
|
+
if (state._historyFetching) return;
|
|
266
|
+
state._historyFetching = true;
|
|
267
|
+
// Warmup copy: the FIRST sessions fetch can sit behind ccsniff's 30-90s
|
|
268
|
+
// JSONL walk; after 5s swap the loading copy to indexing language.
|
|
269
|
+
const firstLoad = !state._historyLoadedOnce;
|
|
270
|
+
const slowTimer = firstLoad
|
|
271
|
+
? setTimeout(() => { if (!state._historyLoadedOnce) { state.historySlow = true; render(); } }, 5000)
|
|
272
|
+
: null;
|
|
273
|
+
try {
|
|
274
|
+
state.sessions = await B.listSessions(state.backend);
|
|
275
|
+
state._historyLoadedOnce = true;
|
|
276
|
+
state._historyLoadedAt = Date.now();
|
|
277
|
+
state.historySlow = false;
|
|
278
|
+
// Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
|
|
279
|
+
// linear scan per event during a burst load.
|
|
280
|
+
state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
|
|
281
|
+
state._sessionGroupsCache = null;
|
|
282
|
+
// Bound the live tally: drop entries with no activity in 24h and cap the
|
|
283
|
+
// Map at ~200 most-recent sids (a long-lived tab otherwise accumulates
|
|
284
|
+
// every sid ever seen, and dead entries could resurrect wrong externals).
|
|
285
|
+
if (state.live.tally) {
|
|
286
|
+
const cutoff = Date.now() - 24 * 3600 * 1000;
|
|
287
|
+
for (const [sid, t] of [...state.live.tally]) {
|
|
288
|
+
if (!t.last || t.last < cutoff) state.live.tally.delete(sid);
|
|
289
|
+
}
|
|
290
|
+
if (state.live.tally.size > 200) {
|
|
291
|
+
state.live.tally = new Map([...state.live.tally.entries()]
|
|
292
|
+
.sort((a, b) => (b[1].last || 0) - (a[1].last || 0))
|
|
293
|
+
.slice(0, 200));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// If the selected session vanished from the list (deleted/aged out server-side),
|
|
297
|
+
// drop the selection so the main pane doesn't sit on stale events that can no
|
|
298
|
+
// longer be reloaded; fall back to the no-selection empty state.
|
|
299
|
+
if (state.selectedSid && !state.sessionsBySid.has(state.selectedSid)) {
|
|
300
|
+
state.selectedSid = null;
|
|
301
|
+
state.events = [];
|
|
302
|
+
state.eventsLoaded = false;
|
|
303
|
+
writeHash();
|
|
304
|
+
}
|
|
305
|
+
state.historyError = null;
|
|
306
|
+
} catch (e) {
|
|
307
|
+
// Only a genuine fetch/list failure is a history error. A render exception
|
|
308
|
+
// must not masquerade as one (it would poison the sessions panel with a
|
|
309
|
+
// render-stack string and never clear), so render() lives outside this try.
|
|
310
|
+
state.historyError = errText(e);
|
|
311
|
+
console.warn('history fetch failed:', e.message);
|
|
312
|
+
} finally {
|
|
313
|
+
state._historyFetching = false;
|
|
314
|
+
if (slowTimer) clearTimeout(slowTimer);
|
|
315
|
+
render();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function runSearch() {
|
|
320
|
+
const q = state.searchQ.trim();
|
|
321
|
+
if (!q) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
|
|
322
|
+
if (q.length < 2) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
|
|
323
|
+
// The project-filter pills are hidden while searching; clear the filter so it
|
|
324
|
+
// doesn't silently re-apply (and surprise the user) when they later clear the
|
|
325
|
+
// search and the now-visible session list is unexpectedly narrowed.
|
|
326
|
+
state.projectFilter = '';
|
|
327
|
+
// The debounced search keeps the URL's q=/project= in sync via replaceState
|
|
328
|
+
// so a reload (or share) restores the search, without flooding history.
|
|
329
|
+
writeHash();
|
|
330
|
+
state.searchBusy = true;
|
|
331
|
+
render();
|
|
332
|
+
try {
|
|
333
|
+
state.searchHits = await B.searchHistory(state.backend, q, 60);
|
|
334
|
+
// Announce the settled count for AT - the sessions-column count is only
|
|
335
|
+
// rendered visually (the history actions row is the only aria-live region).
|
|
336
|
+
const n = (state.searchHits.results || []).length;
|
|
337
|
+
announce((n || 'no') + ' matches for ' + q);
|
|
338
|
+
} catch (e) {
|
|
339
|
+
state.searchHits = { query: q, results: [], error: errText(e) };
|
|
340
|
+
} finally {
|
|
341
|
+
state.searchBusy = false;
|
|
342
|
+
render();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromHash = false } = {}) {
|
|
347
|
+
// Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
|
|
348
|
+
if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
|
|
349
|
+
if (sid === state.selectedSid && state.eventsLoaded && !fromHash && focusEventI == null && focusEventTs == null) {
|
|
350
|
+
render();
|
|
351
|
+
requestAnimationFrame(() => { document.querySelector('.app-side .row.active')?.scrollIntoView({ block: 'nearest' }); });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
state.selectedSid = sid;
|
|
355
|
+
// A plain (non-search-hit) session open must not carry a stale event
|
|
356
|
+
// anchor forward into the URL - only reset it when this call ISN'T itself
|
|
357
|
+
// the one supplying a fresh focusEventTs.
|
|
358
|
+
if (focusEventTs == null) state._focusEventTs = null;
|
|
359
|
+
state.events = [];
|
|
360
|
+
state.events._seen = new Set(); // O(1) dedupe by event index
|
|
361
|
+
state.eventsLoaded = false;
|
|
362
|
+
state.eventsSlow = false;
|
|
363
|
+
state.eventsLimit = 300; // reset the render window per session
|
|
364
|
+
state.eventFilter = 'all'; // don't carry the type filter across sessions
|
|
365
|
+
state.expandedEvents = new Set(); // don't carry expansion to the new session
|
|
366
|
+
// Remember the query this session was opened FROM (search hit) so the event
|
|
367
|
+
// rows can highlight + window around the match; a plain selection clears it.
|
|
368
|
+
state.sessionSearchQ = (focusEventI != null || focusEventTs != null) && state.searchQ.trim().length >= 2
|
|
369
|
+
? state.searchQ.trim() : null;
|
|
370
|
+
// The live "live · N" crumb counter reads as the selected session's activity,
|
|
371
|
+
// so reset it per selection rather than letting it accrue across all sessions.
|
|
372
|
+
state.live.eventCount = 0;
|
|
373
|
+
writeHash({ push: !fromHash });
|
|
374
|
+
// Warmup copy: a first events fetch can sit behind ccsniff's JSONL walk.
|
|
375
|
+
const slowTimer = setTimeout(() => { if (!state.eventsLoaded && state.selectedSid === sid) { state.eventsSlow = true; render(); } }, 5000);
|
|
376
|
+
// Close the mobile sidebar drawer on selection. The DS only auto-closes when
|
|
377
|
+
// the clicked element is an <a>; agentgui's session rows are onClick divs, so
|
|
378
|
+
// we close it explicitly here.
|
|
379
|
+
// Close the WorkspaceShell mobile sessions drawer on session selection.
|
|
380
|
+
if (state.wsSessions) { state.wsSessions = false; }
|
|
381
|
+
document.querySelector('[data-ws-sessions-open]')?.removeAttribute('data-ws-sessions-open');
|
|
382
|
+
render();
|
|
383
|
+
// Bring the now-active sidebar row into view (deep-link / back-forward may
|
|
384
|
+
// select a row that's scrolled out of the session list).
|
|
385
|
+
requestAnimationFrame(() => {
|
|
386
|
+
document.querySelector('.app-side .row.active')?.scrollIntoView({ block: 'nearest' });
|
|
387
|
+
});
|
|
388
|
+
try {
|
|
389
|
+
state.events = await B.getSessionEvents(state.backend, sid);
|
|
390
|
+
// ccsniff's events route has no ?limit= (checked: router.js returns the
|
|
391
|
+
// whole session) - cap in-memory state at the most-recent 5000 so a
|
|
392
|
+
// monster session can't pin the tab; the render window stays 300+load-older.
|
|
393
|
+
if (state.events.length > 5000) state.events = state.events.slice(-5000);
|
|
394
|
+
// Stamp stable _idx so EventList keys are stable regardless of slice/cap.
|
|
395
|
+
state.events.forEach((e, i) => { if (e._idx == null) e._idx = i; });
|
|
396
|
+
clearTimeout(slowTimer);
|
|
397
|
+
state.eventsSlow = false;
|
|
398
|
+
state.eventsLoaded = true;
|
|
399
|
+
// If we arrived from a search hit, make sure the matched event is within the
|
|
400
|
+
// render window, then scroll to + flash it so the match isn't lost.
|
|
401
|
+
if (focusEventI != null || focusEventTs != null) {
|
|
402
|
+
const idx = state.events.findIndex(e => (focusEventI != null && e.i === focusEventI) || (focusEventTs != null && e.ts === focusEventTs));
|
|
403
|
+
if (idx >= 0) {
|
|
404
|
+
const fromEnd = state.events.length - idx;
|
|
405
|
+
if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
|
|
406
|
+
render();
|
|
407
|
+
// The rendered EventList shows the last eventsLimit events in order, so
|
|
408
|
+
// the matched event's row is at (idx - sliceStart) among .ds-event-list rows.
|
|
409
|
+
const sliceStart = Math.max(0, state.events.length - state.eventsLimit);
|
|
410
|
+
const rowPos = idx - sliceStart;
|
|
411
|
+
requestAnimationFrame(() => {
|
|
412
|
+
const rows = document.querySelectorAll('.ds-event-list .row');
|
|
413
|
+
const row = rows[rowPos];
|
|
414
|
+
if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
|
|
415
|
+
});
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
render();
|
|
420
|
+
} catch (e) {
|
|
421
|
+
state.events = [{
|
|
422
|
+
ts: Date.now(),
|
|
423
|
+
role: 'error',
|
|
424
|
+
type: 'fetch',
|
|
425
|
+
text: 'Failed to load session: ' + errText(e) + ' - retry via the rail',
|
|
426
|
+
}];
|
|
427
|
+
clearTimeout(slowTimer);
|
|
428
|
+
state.eventsSlow = false;
|
|
429
|
+
state.eventsLoaded = true;
|
|
430
|
+
render();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return { jumpToEvent, jumpToFirstError, jumpToNextError, historyMain, refreshHistory, runSearch, loadSession };
|
|
435
|
+
}
|