agentgui 1.0.954 → 1.0.956
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/.claude/workflows/gui-overhaul.js +114 -0
- package/.claude/workflows/gui-predictability-polish.js +90 -0
- package/AGENTS.md +18 -0
- package/PUNCHLIST-POLISH.md +329 -0
- package/lib/http-handler.js +147 -11
- package/package.json +1 -1
- package/site/app/js/app.js +527 -37
- package/site/app/js/backend.js +33 -0
- package/site/app/vendor/anentrypoint-design/247420.css +319 -0
- package/site/app/vendor/anentrypoint-design/247420.js +15 -15
package/site/app/js/app.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as B from './backend.js';
|
|
|
3
3
|
|
|
4
4
|
installStyles().catch(() => {});
|
|
5
5
|
|
|
6
|
-
const { AppShell, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, EventList, Spinner, Alert } = C;
|
|
6
|
+
const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, BreadcrumbPath, EmptyState, FileViewer, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane } = C;
|
|
7
7
|
|
|
8
8
|
const state = {
|
|
9
9
|
backend: B.getBackend(),
|
|
@@ -29,6 +29,7 @@ const state = {
|
|
|
29
29
|
active: [],
|
|
30
30
|
activeTimer: null,
|
|
31
31
|
eventsLimit: 300, // how many of the most-recent events to render; grows via "load older"
|
|
32
|
+
files: { path: '', segments: [], entries: [], roots: [], loading: false, error: null, preview: null, sort: 'name', sortDir: 'asc', filter: '' },
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
function readHash() {
|
|
@@ -219,6 +220,14 @@ function navTo(tab) {
|
|
|
219
220
|
} else if (prev === 'history') {
|
|
220
221
|
closeLiveStream();
|
|
221
222
|
}
|
|
223
|
+
// The conversation column now lives on the chat tab too, so populate the
|
|
224
|
+
// session list when entering chat if it hasn't been loaded yet (it used to
|
|
225
|
+
// load only on a History visit, leaving the rail empty on a fresh chat).
|
|
226
|
+
if (tab === 'chat' && !state.sessions.length && !state.historyError) refreshHistory();
|
|
227
|
+
// Kick the first directory load on entering Files HERE (an effect), not inside
|
|
228
|
+
// filesMain (which runs during render) - a render-time fetch is fragile under
|
|
229
|
+
// a double-render and re-enters render() while building the tree.
|
|
230
|
+
if (tab === 'files' && !state.files.path && !state.files.loading && !state.files.error) loadDir('');
|
|
222
231
|
writeHash(state.selectedSid && tab === 'history' ? state.selectedSid : null);
|
|
223
232
|
announce('Now on ' + tab + ' tab');
|
|
224
233
|
render();
|
|
@@ -252,9 +261,21 @@ function syncAriaCurrent() {
|
|
|
252
261
|
});
|
|
253
262
|
}
|
|
254
263
|
|
|
264
|
+
// A cheap signature of the active-chats list so an unchanged 3s poll does not
|
|
265
|
+
// trigger a full ConversationList + dashboard rebuild every tick (the rail
|
|
266
|
+
// re-renders every row on each render). Only re-render when the set actually
|
|
267
|
+
// changed (or while on the live tab, where elapsed advances every second).
|
|
268
|
+
function activeSig(list) {
|
|
269
|
+
return (Array.isArray(list) ? list : []).map(a => a.sessionId + ':' + (a.model || '') + ':' + (a.startedAt || '')).sort().join('|');
|
|
270
|
+
}
|
|
255
271
|
async function refreshActive() {
|
|
256
|
-
|
|
257
|
-
|
|
272
|
+
let next;
|
|
273
|
+
try { next = await B.listActiveChats(state.backend); } catch { return; }
|
|
274
|
+
const changed = activeSig(next) !== activeSig(state.active);
|
|
275
|
+
state.active = next;
|
|
276
|
+
// The live tab needs the steady elapsed tick regardless; elsewhere only
|
|
277
|
+
// re-render when the active set genuinely changed.
|
|
278
|
+
if (changed || state.tab === 'live') render();
|
|
258
279
|
}
|
|
259
280
|
function startActivePolling() {
|
|
260
281
|
if (state.activeTimer) return;
|
|
@@ -270,6 +291,17 @@ function stopActivePolling() {
|
|
|
270
291
|
// between events. Cheap: scheduleRender coalesces via rAF.
|
|
271
292
|
let _relTick = null;
|
|
272
293
|
function startRelTimeTick() { if (!_relTick) _relTick = setInterval(() => { if (state.tab === 'history' || (Array.isArray(state.active) && state.active.length)) scheduleRender(); }, 30000); }
|
|
294
|
+
// The live dashboard's per-card elapsed must advance every second, not in 3s
|
|
295
|
+
// poll-sized jumps - a frozen counter reads as a stalled session. A dedicated
|
|
296
|
+
// 1s tick runs ONLY while the live tab is showing running sessions, so it costs
|
|
297
|
+
// nothing on other tabs (and stops when there's nothing in flight).
|
|
298
|
+
let _liveTick = null;
|
|
299
|
+
function startLiveTick() {
|
|
300
|
+
if (_liveTick) return;
|
|
301
|
+
_liveTick = setInterval(() => {
|
|
302
|
+
if (state.tab === 'live' && Array.isArray(state.active) && state.active.length) scheduleRender();
|
|
303
|
+
}, 1000);
|
|
304
|
+
}
|
|
273
305
|
async function stopActiveChat(sid) {
|
|
274
306
|
try { await B.cancelChat(state.backend, sid); } catch {}
|
|
275
307
|
refreshActive();
|
|
@@ -361,20 +393,9 @@ function view() {
|
|
|
361
393
|
h('span', { key: 'dd', class: 'status-dot-disc', 'aria-hidden': 'true' }),
|
|
362
394
|
h('span', { key: 'dl' }, dotLabel));
|
|
363
395
|
|
|
364
|
-
const topbar = Topbar({
|
|
365
|
-
brand: 'agentgui',
|
|
366
|
-
leaf: state.tab,
|
|
367
|
-
items: [['chat', buildHash('chat', null) || '#'], ['history', buildHash('history', null)], ['settings', buildHash('settings', null)]],
|
|
368
|
-
active: state.tab,
|
|
369
|
-
onNav: (label) => navTo(label),
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
// The crumb's right slot carries the live/connection status dot on every tab.
|
|
373
|
-
const crumbRight = [dot];
|
|
374
|
-
|
|
375
396
|
// Give the crumb contextual content on the left so it isn't a bare bar holding
|
|
376
|
-
// only the dot: on history it names the selected session
|
|
377
|
-
//
|
|
397
|
+
// only the dot: on history/chat it names the selected session/agent, on files
|
|
398
|
+
// the current dir, on live the live count, on settings "configuration".
|
|
378
399
|
let crumbLeaf = '';
|
|
379
400
|
if (state.tab === 'history') {
|
|
380
401
|
const sel = state.selectedSid && (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
@@ -383,18 +404,14 @@ function view() {
|
|
|
383
404
|
: 'all sessions';
|
|
384
405
|
} else if (state.tab === 'chat') {
|
|
385
406
|
crumbLeaf = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : 'no agent';
|
|
407
|
+
} else if (state.tab === 'files') {
|
|
408
|
+
crumbLeaf = truncate(state.files?.path || 'files', 24, 64);
|
|
409
|
+
} else if (state.tab === 'live') {
|
|
410
|
+
crumbLeaf = 'live · ' + ((state.active && state.active.length) || 0);
|
|
386
411
|
} else if (state.tab === 'settings') {
|
|
387
412
|
crumbLeaf = 'configuration';
|
|
388
413
|
}
|
|
389
|
-
const crumb = Crumb({
|
|
390
|
-
trail: [],
|
|
391
|
-
leaf: crumbLeaf,
|
|
392
|
-
right: crumbRight,
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
// Sidebar is contextual: history shows the session list; chat/settings have no
|
|
396
|
-
// sidebar (the topbar already provides primary nav) so main content gets full width.
|
|
397
|
-
const side = state.tab === 'history' ? historySide() : null;
|
|
414
|
+
const crumb = Crumb({ trail: [], leaf: crumbLeaf, right: [dot] });
|
|
398
415
|
|
|
399
416
|
const agentLabel = state.selectedAgent
|
|
400
417
|
? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
|
|
@@ -404,28 +421,400 @@ function view() {
|
|
|
404
421
|
right: [agentLabel, 'press ? for shortcuts'],
|
|
405
422
|
});
|
|
406
423
|
|
|
407
|
-
// The design system now owns the full-height column + inner scroll for
|
|
408
|
-
// .app-main, so chat just needs to be a flex column that fills it.
|
|
409
424
|
const mainStyle = state.tab === 'chat'
|
|
410
425
|
? 'min-height:0;display:flex;flex-direction:column;flex:1'
|
|
411
426
|
: 'min-height:0';
|
|
412
427
|
const shortcutsHint = state.showShortcuts
|
|
413
428
|
? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
|
|
414
|
-
children: 'g then c/h/s - chat/history/settings · n - new chat · / - focus search/composer · ? - toggle this · Esc - blur field' })
|
|
429
|
+
children: 'g then c/h/f/l/s - chat/history/files/live/settings · n - new chat · / - focus search/composer · ? - toggle this · Esc - blur field' })
|
|
415
430
|
: null;
|
|
416
431
|
const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
|
|
417
|
-
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
|
|
432
|
+
|
|
433
|
+
// Claude-Desktop three-column shell: a persistent left rail (workspace nav), an
|
|
434
|
+
// optional sessions column (chat + history share the conversation list), the
|
|
435
|
+
// main content, and an optional right context pane. The rail replaces the old
|
|
436
|
+
// Topbar tabs; it collapses to icon-only and, on mobile, behind its own toggle.
|
|
437
|
+
const rail = workspaceRail();
|
|
438
|
+
const sessions = (state.tab === 'chat' || state.tab === 'history') ? sessionsColumn() : null;
|
|
439
|
+
// Right context pane only on the chat tab: agent/model/cwd + a live count of
|
|
440
|
+
// running tool calls in the current turn.
|
|
441
|
+
const pane = state.tab === 'chat' ? ContextPane({
|
|
442
|
+
agent: state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : '',
|
|
443
|
+
model: state.selectedModel || '',
|
|
444
|
+
cwd: state.chatCwd || '',
|
|
445
|
+
toolCount: runningToolCount(),
|
|
446
|
+
usage: state.chat.usage || null,
|
|
447
|
+
onSetCwd: () => { navTo('files'); },
|
|
448
|
+
}) : null;
|
|
449
|
+
return WorkspaceShell({ rail, sessions, main, pane, crumb, status, narrow: isNarrow() });
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// The left workspace rail: brand, New chat action, and the primary view nav.
|
|
453
|
+
function workspaceRail() {
|
|
454
|
+
const liveCount = (Array.isArray(state.active) ? state.active.length : 0);
|
|
455
|
+
const items = [
|
|
456
|
+
{ key: 'chat', label: 'Chat', icon: 'forum', active: state.tab === 'chat', onClick: () => navTo('chat') },
|
|
457
|
+
{ key: 'history', label: 'History', icon: 'thread', active: state.tab === 'history', onClick: () => navTo('history') },
|
|
458
|
+
{ key: 'files', label: 'Files', icon: 'folder', active: state.tab === 'files', onClick: () => navTo('files') },
|
|
459
|
+
{ key: 'live', label: 'Live', icon: 'activity', active: state.tab === 'live', count: liveCount || null, onClick: () => navTo('live') },
|
|
460
|
+
{ key: 'settings', label: 'Settings', icon: 'settings', active: state.tab === 'settings', onClick: () => navTo('settings') },
|
|
461
|
+
];
|
|
462
|
+
return WorkspaceRail({
|
|
463
|
+
brand: 'agentgui',
|
|
464
|
+
action: { label: 'New chat', icon: 'pencil', onClick: () => { navTo('chat'); newChat(); } },
|
|
465
|
+
items,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Bucket a session's last-active timestamp into a Claude-Desktop-style date
|
|
470
|
+
// group label (Today / Yesterday / This week / Earlier). Pure date math against
|
|
471
|
+
// local midnight so the labels match the user's calendar, not a rolling 24h.
|
|
472
|
+
function dateGroupLabel(ts) {
|
|
473
|
+
if (!ts) return 'Earlier';
|
|
474
|
+
const now = new Date();
|
|
475
|
+
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
476
|
+
const d = ts;
|
|
477
|
+
if (d >= startOfToday) return 'Today';
|
|
478
|
+
if (d >= startOfToday - 86400000) return 'Yesterday';
|
|
479
|
+
if (d >= startOfToday - 7 * 86400000) return 'This week';
|
|
480
|
+
return 'Earlier';
|
|
481
|
+
}
|
|
482
|
+
const DATE_GROUP_ORDER = ['Running', 'Today', 'Yesterday', 'This week', 'Earlier'];
|
|
483
|
+
|
|
484
|
+
// Build the kit ConversationList `groups` from the visible sessions: running
|
|
485
|
+
// chats are pinned to a "Running" section at the top (a live workspace surfaces
|
|
486
|
+
// in-flight work first), the rest bucket by recency. Returns { items, groups }.
|
|
487
|
+
function sessionGroups(sessionsView) {
|
|
488
|
+
const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.sessionId));
|
|
489
|
+
const buckets = new Map();
|
|
490
|
+
for (const s of sessionsView) {
|
|
491
|
+
const label = runningSids.has(s.sid) ? 'Running' : dateGroupLabel(s.last);
|
|
492
|
+
if (!buckets.has(label)) buckets.set(label, []);
|
|
493
|
+
buckets.get(label).push(s.sid);
|
|
494
|
+
}
|
|
495
|
+
const groups = DATE_GROUP_ORDER
|
|
496
|
+
.filter(l => buckets.has(l))
|
|
497
|
+
.map(l => ({ label: l, sids: buckets.get(l) }));
|
|
498
|
+
return groups;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// The sessions column (conversation list) shared by chat + history. Selecting a
|
|
502
|
+
// row on the chat tab resumes it in chat; on the history tab it loads its events.
|
|
503
|
+
function sessionsColumn() {
|
|
504
|
+
// When a search is active (>=2 chars with hits), the rail shows the search
|
|
505
|
+
// results instead of the time-sorted session list - on BOTH tabs, so chat-tab
|
|
506
|
+
// search is no longer a dead control that runs a query nothing displays.
|
|
507
|
+
const searching = !!(state.searchHits && state.searchQ.trim().length >= 2);
|
|
508
|
+
if (searching) {
|
|
509
|
+
const hits = (state.searchHits.results || []).slice(0, state.sessionsLimit);
|
|
510
|
+
const items = hits.map((r, i) => ({
|
|
511
|
+
sid: r.sid,
|
|
512
|
+
title: r.snippet || r.title || r.sid,
|
|
513
|
+
project: projectLabel(r.project) || '',
|
|
514
|
+
time: r.ts ? fmtRelTime(r.ts) : '',
|
|
515
|
+
running: false,
|
|
516
|
+
unread: false,
|
|
517
|
+
rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
|
|
518
|
+
_focusEventI: r.i, _focusEventTs: r.ts,
|
|
519
|
+
}));
|
|
520
|
+
return ConversationList({
|
|
521
|
+
sessions: items,
|
|
522
|
+
selected: state.selectedSid,
|
|
523
|
+
search: {
|
|
524
|
+
value: state.searchQ,
|
|
525
|
+
placeholder: 'Search conversations',
|
|
526
|
+
onInput: (v) => { state.searchQ = v; if (v.trim().length >= 2) debouncedSearch(); else { state.searchHits = null; } render(); },
|
|
527
|
+
},
|
|
528
|
+
onNew: () => { navTo('chat'); newChat(); },
|
|
529
|
+
onSelect: (s) => {
|
|
530
|
+
if (state.tab === 'chat') resumeInChat({ sid: s.sid });
|
|
531
|
+
else loadSession(s.sid, { focusEventI: s._focusEventI, focusEventTs: s._focusEventTs });
|
|
532
|
+
},
|
|
533
|
+
loading: state.searchBusy,
|
|
534
|
+
error: state.searchHits.error || null,
|
|
535
|
+
emptyText: 'No matches for "' + state.searchQ + '"',
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
const sessionsView = visibleSessions();
|
|
539
|
+
const sliced = sessionsView.slice(0, state.sessionsLimit);
|
|
540
|
+
const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.sessionId));
|
|
541
|
+
const items = sliced.map((s) => ({
|
|
542
|
+
sid: s.sid,
|
|
543
|
+
title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
|
|
544
|
+
project: projectLabel(s.project) || '',
|
|
545
|
+
time: fmtRelTime(s.last),
|
|
546
|
+
agent: undefined,
|
|
547
|
+
running: runningSids.has(s.sid),
|
|
548
|
+
unread: false,
|
|
549
|
+
rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
|
|
550
|
+
}));
|
|
551
|
+
return ConversationList({
|
|
552
|
+
sessions: items,
|
|
553
|
+
// Today/Yesterday/This-week + a pinned Running section, the Claude-Desktop
|
|
554
|
+
// Chats shape. Groups reference sids the kit maps back to the items above.
|
|
555
|
+
groups: sessionGroups(sliced),
|
|
556
|
+
selected: state.selectedSid,
|
|
557
|
+
search: {
|
|
558
|
+
value: state.searchQ,
|
|
559
|
+
placeholder: 'Search conversations',
|
|
560
|
+
onInput: (v) => { state.searchQ = v; if (v.trim().length >= 2) debouncedSearch(); else { state.searchHits = null; } render(); },
|
|
561
|
+
},
|
|
562
|
+
onNew: () => { navTo('chat'); newChat(); },
|
|
563
|
+
onSelect: (s) => { if (state.tab === 'chat') resumeInChat({ sid: s.sid }); else loadSession(s.sid); },
|
|
564
|
+
loading: state.tab === 'history' && !state.sessions.length && !state.historyError,
|
|
565
|
+
error: state.historyError,
|
|
566
|
+
emptyText: 'No conversations yet',
|
|
567
|
+
});
|
|
421
568
|
}
|
|
422
569
|
|
|
423
570
|
function mainContent() {
|
|
424
571
|
if (state.tab === 'chat') return chatMain();
|
|
425
572
|
if (state.tab === 'history') return historyMain();
|
|
573
|
+
if (state.tab === 'files') return filesMain();
|
|
574
|
+
if (state.tab === 'live') return liveMain();
|
|
426
575
|
return settingsMain();
|
|
427
576
|
}
|
|
428
577
|
|
|
578
|
+
// --- files (folder browser) ---
|
|
579
|
+
async function loadDir(dirPath) {
|
|
580
|
+
state.files = state.files || {};
|
|
581
|
+
state.files.loading = true; state.files.error = null; render();
|
|
582
|
+
try {
|
|
583
|
+
const j = await B.listDir(state.backend, dirPath || '');
|
|
584
|
+
state.files.path = j.path;
|
|
585
|
+
state.files.segments = j.segments || [];
|
|
586
|
+
state.files.entries = j.entries || [];
|
|
587
|
+
state.files.roots = j.roots || [];
|
|
588
|
+
state.files.error = null;
|
|
589
|
+
} catch (e) {
|
|
590
|
+
state.files.error = e.message;
|
|
591
|
+
state.files.entries = [];
|
|
592
|
+
}
|
|
593
|
+
state.files.loading = false; render();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Map a file extension to a Prism language hint for the code preview.
|
|
597
|
+
const PREVIEW_LANG = {
|
|
598
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'jsx',
|
|
599
|
+
ts: 'typescript', tsx: 'tsx', py: 'python', rb: 'ruby', rs: 'rust',
|
|
600
|
+
go: 'go', java: 'java', c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp',
|
|
601
|
+
cs: 'csharp', php: 'php', sh: 'bash', css: 'css', html: 'markup',
|
|
602
|
+
xml: 'markup', svg: 'markup', json: 'json', yml: 'yaml', yaml: 'yaml',
|
|
603
|
+
toml: 'toml', sql: 'sql', md: 'markdown',
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// Load the raw content of the currently-selected text/code preview file.
|
|
607
|
+
// Images don't fetch here (the FileViewer renders them via /api/image url).
|
|
608
|
+
async function loadPreviewContent(file) {
|
|
609
|
+
state.files = state.files || {};
|
|
610
|
+
const p = state.files.previewState = { loading: true, error: null, content: '', truncated: false, path: file.path };
|
|
611
|
+
render();
|
|
612
|
+
try {
|
|
613
|
+
const { content, truncated } = await B.readFile(state.backend, file.path);
|
|
614
|
+
// Bail if the user moved to another file while this was in flight.
|
|
615
|
+
if (state.files.preview?.path !== file.path) return;
|
|
616
|
+
p.content = content; p.truncated = truncated;
|
|
617
|
+
} catch (e) {
|
|
618
|
+
if (state.files.preview?.path !== file.path) return;
|
|
619
|
+
p.error = e.message;
|
|
620
|
+
}
|
|
621
|
+
p.loading = false; render();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function closePreview() {
|
|
625
|
+
if (!state.files) return;
|
|
626
|
+
state.files.preview = null;
|
|
627
|
+
state.files.previewState = null;
|
|
628
|
+
render();
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Render the file preview modal (FileViewer) when state.files.preview is set.
|
|
632
|
+
// Returns null when nothing is selected. All children inside each h() call here
|
|
633
|
+
// are either all-unkeyed or single nodes (no keyed/null mixing).
|
|
634
|
+
function filePreview() {
|
|
635
|
+
const f = state.files || {};
|
|
636
|
+
const file = f.preview;
|
|
637
|
+
if (!file) return null;
|
|
638
|
+
const isImage = file.type === 'image';
|
|
639
|
+
let body;
|
|
640
|
+
if (isImage) {
|
|
641
|
+
body = FilePreviewMedia({ src: B.imageUrl(state.backend, file.path), type: 'image', name: file.name });
|
|
642
|
+
} else {
|
|
643
|
+
const ps = f.previewState;
|
|
644
|
+
// The load is kicked from onOpen (an effect); as a safety net for a preview
|
|
645
|
+
// set by another path, SCHEDULE (not call) a load so render stays pure.
|
|
646
|
+
if (!ps || ps.path !== file.path) { Promise.resolve().then(() => { if (state.files.preview?.path === file.path && state.files.previewState?.path !== file.path) loadPreviewContent(file); }); }
|
|
647
|
+
if (!ps || ps.loading || ps.path !== file.path) {
|
|
648
|
+
body = h('div', { class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ size: 'sm' }), 'loading…');
|
|
649
|
+
} else if (ps.error) {
|
|
650
|
+
body = Alert({ kind: 'warn', title: 'Cannot preview file', children: ps.error });
|
|
651
|
+
} else {
|
|
652
|
+
const ext = (file.name.split('.').pop() || '').toLowerCase();
|
|
653
|
+
const lang = PREVIEW_LANG[ext];
|
|
654
|
+
body = lang
|
|
655
|
+
? FilePreviewCode({ content: ps.content, lang })
|
|
656
|
+
: FilePreviewText({ content: ps.content, truncated: ps.truncated });
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return FileViewer({ file, body, onClose: closePreview });
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Go up one directory from the current path (the FileGrid Backspace affordance).
|
|
663
|
+
function fileUp() {
|
|
664
|
+
const f = state.files || {};
|
|
665
|
+
const segs = (f.segments || []);
|
|
666
|
+
if (segs.length <= 1) { loadDir(''); return; }
|
|
667
|
+
const up = segs.slice(0, segs.length - 1);
|
|
668
|
+
const joined = /^[A-Za-z]:$/.test(up[0]) ? up[0] + '\\' + up.slice(1).join('\\') : '/' + up.join('/');
|
|
669
|
+
loadDir(joined);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function filesMain() {
|
|
673
|
+
const f = state.files || {};
|
|
674
|
+
// The initial dir load is kicked from navTo('files') (an effect), not here,
|
|
675
|
+
// so this view function stays pure. A direct deep-link to files still loads:
|
|
676
|
+
// init()/navTo handles the entry. As a safety net for a render that arrives
|
|
677
|
+
// before navTo's effect (e.g. hash deep-link), schedule (not call) a load.
|
|
678
|
+
if (!f.path && !f.loading && !f.error) { Promise.resolve().then(() => { if (!state.files.path && !state.files.loading) loadDir(''); }); }
|
|
679
|
+
const crumb = BreadcrumbPath({
|
|
680
|
+
root: 'root',
|
|
681
|
+
segments: f.segments || [],
|
|
682
|
+
onNav: (i) => {
|
|
683
|
+
// i=0 is the synthetic root button -> reload the default root; otherwise
|
|
684
|
+
// rebuild the absolute path up to the clicked segment.
|
|
685
|
+
if (i === 0) { loadDir(''); return; }
|
|
686
|
+
const segs = (f.segments || []).slice(0, i);
|
|
687
|
+
// Reconstruct a drive-aware absolute path from the segments.
|
|
688
|
+
const joined = segs.length && /^[A-Za-z]:$/.test(segs[0]) ? segs[0] + '\\' + segs.slice(1).join('\\') : '/' + segs.join('/');
|
|
689
|
+
loadDir(joined);
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
// Sort + filter the entries client-side (the server returns one directory at a
|
|
693
|
+
// time, so this is bounded by dir size). Map modified to BOTH a display string
|
|
694
|
+
// and an epoch (modifiedTs) so the kit's modified-sort orders by real time.
|
|
695
|
+
const mapped = (f.entries || []).map((e) => {
|
|
696
|
+
const ts = e.modified ? Date.parse(e.modified) : 0;
|
|
697
|
+
return { name: e.name, type: e.type, size: e.size, modified: ts ? fmtRelTime(ts) : null, modifiedTs: ts, path: e.path };
|
|
698
|
+
});
|
|
699
|
+
const filtered = f.filter
|
|
700
|
+
? mapped.filter(e => e.name.toLowerCase().includes(f.filter.toLowerCase()))
|
|
701
|
+
: mapped;
|
|
702
|
+
const sorted = sortFiles(filtered, f.sort || 'name', f.sortDir || 'asc');
|
|
703
|
+
let body;
|
|
704
|
+
if (f.error) {
|
|
705
|
+
body = Alert({ key: 'ferr', kind: 'warn', title: 'Cannot list directory', children: f.error });
|
|
706
|
+
} else {
|
|
707
|
+
body = FileGrid({
|
|
708
|
+
files: sorted,
|
|
709
|
+
loading: f.loading,
|
|
710
|
+
emptyText: f.filter ? 'no files match "' + f.filter + '"' : 'empty directory',
|
|
711
|
+
sort: { key: f.sort || 'name', dir: f.sortDir || 'asc', onSort: (k) => {
|
|
712
|
+
// Click the active column to flip direction; a new column resets to asc.
|
|
713
|
+
if (state.files.sort === k) state.files.sortDir = state.files.sortDir === 'asc' ? 'desc' : 'asc';
|
|
714
|
+
else { state.files.sort = k; state.files.sortDir = 'asc'; }
|
|
715
|
+
render();
|
|
716
|
+
} },
|
|
717
|
+
filter: { value: f.filter || '', placeholder: 'Filter files in this directory', onInput: (v) => { state.files.filter = v; render(); } },
|
|
718
|
+
onUp: fileUp,
|
|
719
|
+
onOpen: (file) => {
|
|
720
|
+
if (file.type === 'dir') loadDir(file.path);
|
|
721
|
+
else {
|
|
722
|
+
state.files.preview = file;
|
|
723
|
+
// Kick the content fetch from the handler (an effect), not from inside
|
|
724
|
+
// filePreview() during render. Images don't fetch (rendered via url).
|
|
725
|
+
if (file.type !== 'image') loadPreviewContent(file);
|
|
726
|
+
render();
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
// Roots picker: when the server allows more than one root, surface them as a
|
|
732
|
+
// pill row so every allowed root is reachable in one click (not only by
|
|
733
|
+
// breadcrumb-walking down from the first). Always-present toolbar (incl. at the
|
|
734
|
+
// synthetic root) so path context + the cwd action never vanish.
|
|
735
|
+
const roots = Array.isArray(f.roots) ? f.roots : [];
|
|
736
|
+
const rootsRow = roots.length > 1
|
|
737
|
+
? h('div', { key: 'froots', class: 'pill-row', role: 'group', 'aria-label': 'Jump to an allowed root' },
|
|
738
|
+
...roots.map((r) => pillButton('root-' + r, truncate(projectLabel(r) || r, 16, 28), f.path === r, r, () => loadDir(r))))
|
|
739
|
+
: null;
|
|
740
|
+
const targetCwd = f.path || (roots.length === 1 ? roots[0] : '');
|
|
741
|
+
const toolbar = h('div', { key: 'ftb', class: 'ds-file-toolbar' },
|
|
742
|
+
h('div', { key: 'ftl', class: 'ds-file-toolbar-left' }, crumb),
|
|
743
|
+
h('div', { key: 'ftr', class: 'ds-file-toolbar-right' },
|
|
744
|
+
targetCwd
|
|
745
|
+
? Btn({ onClick: () => { state.chatCwd = targetCwd; lsSet('agentgui.cwd', targetCwd); announce('working directory set to ' + targetCwd); navTo('chat'); }, children: 'use as chat cwd' })
|
|
746
|
+
: null));
|
|
747
|
+
return [
|
|
748
|
+
offlineBanner(),
|
|
749
|
+
PageHeader({ compact: true, title: 'files', lede: 'browse the server filesystem within the allowed roots. click a folder to open it; pick one as the chat working directory.' }),
|
|
750
|
+
rootsRow,
|
|
751
|
+
toolbar,
|
|
752
|
+
h('div', { key: 'fbody' }, body),
|
|
753
|
+
// Preview overlay (FileViewer modal) when a non-dir file is selected. Wrap
|
|
754
|
+
// in a keyed node to match the keyed siblings in this array.
|
|
755
|
+
f.preview ? h('div', { key: 'fprev' }, filePreview()) : null,
|
|
756
|
+
].filter(Boolean);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Stop every in-flight chat at once (the dashboard "stop all" bulk control).
|
|
760
|
+
async function stopAllActive(sessions) {
|
|
761
|
+
const sids = (Array.isArray(sessions) ? sessions : (state.active || [])).map(s => s.sid || s.sessionId).filter(Boolean);
|
|
762
|
+
await Promise.all(sids.map(sid => B.cancelChat(state.backend, sid).catch(() => {})));
|
|
763
|
+
refreshActive();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// --- live (multi-session dashboard) ---
|
|
767
|
+
function liveMain() {
|
|
768
|
+
const offline = state.health.status !== 'ok' && state.health.status !== 'unknown';
|
|
769
|
+
const bySid = state.sessionsBySid || new Map();
|
|
770
|
+
const sessions = (Array.isArray(state.active) ? state.active : []).map((r) => {
|
|
771
|
+
// Live activity tallies come from the session row the SSE stream keeps
|
|
772
|
+
// updating (events/tools/errors/last), so a card shows motion, not just a
|
|
773
|
+
// start offset. Absent (a brand-new chat not yet in the history list) -> null.
|
|
774
|
+
const sess = bySid.get(r.sessionId);
|
|
775
|
+
const counterBits = [];
|
|
776
|
+
if (sess) {
|
|
777
|
+
if (sess.events != null) counterBits.push(sess.events + ' ev');
|
|
778
|
+
if (sess.tools) counterBits.push(sess.tools + ' tools');
|
|
779
|
+
if (sess.errors) counterBits.push(sess.errors + ' err');
|
|
780
|
+
}
|
|
781
|
+
// Current tool: while a turn is busy in the in-page chat for this sid, the
|
|
782
|
+
// trailing assistant message carries a running tool part.
|
|
783
|
+
let currentTool = '';
|
|
784
|
+
if (state.chat.resumeSid === r.sessionId && state.chat.busy) {
|
|
785
|
+
const msgs = state.chat.messages || [];
|
|
786
|
+
const last = msgs[msgs.length - 1];
|
|
787
|
+
const running = last && Array.isArray(last.parts) && last.parts.filter(p => p && p.kind === 'tool' && p.status === 'running').slice(-1)[0];
|
|
788
|
+
if (running) currentTool = running.name || '';
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
sid: r.sessionId,
|
|
792
|
+
agent: agentById(r.agentId)?.name || r.agentId || 'agent',
|
|
793
|
+
model: r.model || '',
|
|
794
|
+
cwd: r.cwd ? r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
|
|
795
|
+
elapsed: r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) + 's' : '',
|
|
796
|
+
counter: counterBits.length ? counterBits.join(' · ') : null,
|
|
797
|
+
lastActivity: sess && sess.last ? fmtRelTime(sess.last) : '',
|
|
798
|
+
currentTool,
|
|
799
|
+
status: (sess && sess.errors) ? 'error' : 'running',
|
|
800
|
+
};
|
|
801
|
+
});
|
|
802
|
+
return [
|
|
803
|
+
offlineBanner(),
|
|
804
|
+
PageHeader({ compact: true, title: 'live sessions', lede: 'every in-flight agent session, managed at once. stop, open, resume, or jump to events per session.' }),
|
|
805
|
+
SessionDashboard({
|
|
806
|
+
sessions,
|
|
807
|
+
offline,
|
|
808
|
+
emptyText: 'No live sessions - start a chat or run a local agent.',
|
|
809
|
+
onStop: (s) => stopActiveChat(s.sid),
|
|
810
|
+
onStopAll: (all) => stopAllActive(all),
|
|
811
|
+
onOpen: (s) => { resumeInChat({ sid: s.sid }); },
|
|
812
|
+
onResume: (s) => { resumeInChat({ sid: s.sid }); },
|
|
813
|
+
onView: (s) => { navTo('history'); loadSession(s.sid); },
|
|
814
|
+
}),
|
|
815
|
+
].filter(Boolean);
|
|
816
|
+
}
|
|
817
|
+
|
|
429
818
|
// --- chat ---
|
|
430
819
|
function canSend() {
|
|
431
820
|
return !!state.selectedAgent && agentAvailable(state.selectedAgent) && !state.chat.busy;
|
|
@@ -451,6 +840,16 @@ function toolLabel(inp) {
|
|
|
451
840
|
// Build a structured tool part the kit's ToolCallNode renders as a collapsible
|
|
452
841
|
// card (name + label + full args, status icon). tool_result is matched back to
|
|
453
842
|
// this part by tool_use id so the result lands inside the same card.
|
|
843
|
+
// Count tool calls still running in the current live turn. Only meaningful
|
|
844
|
+
// while the chat is busy; scans the trailing assistant message's parts.
|
|
845
|
+
function runningToolCount() {
|
|
846
|
+
if (!state.chat || !state.chat.busy) return 0;
|
|
847
|
+
const msgs = Array.isArray(state.chat.messages) ? state.chat.messages : [];
|
|
848
|
+
const last = msgs[msgs.length - 1];
|
|
849
|
+
if (!last || last.role !== 'assistant' || !Array.isArray(last.parts)) return 0;
|
|
850
|
+
return last.parts.filter(p => p && p.kind === 'tool' && p.status === 'running').length;
|
|
851
|
+
}
|
|
852
|
+
|
|
454
853
|
function toolPart(block) {
|
|
455
854
|
const name = block.name || block.kind || 'tool';
|
|
456
855
|
const input = block.input || block.rawInput || {};
|
|
@@ -578,6 +977,11 @@ function chatMain() {
|
|
|
578
977
|
'Find and explain the main entry point',
|
|
579
978
|
],
|
|
580
979
|
onSuggestionClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
|
|
980
|
+
// Per-message actions: copy any message; retry the last assistant turn;
|
|
981
|
+
// edit-and-resend a user message (drops everything after it, refills draft).
|
|
982
|
+
onCopyMessage: (m) => copyMessageText(m),
|
|
983
|
+
onRetryMessage: () => retryLastTurn(),
|
|
984
|
+
onEditMessage: (m) => editAndResend(m),
|
|
581
985
|
cwd: state.chatCwd,
|
|
582
986
|
cwdEditing: !!state.cwdEditing,
|
|
583
987
|
cwdDraft: state.cwdDraft,
|
|
@@ -634,7 +1038,7 @@ function newChat() {
|
|
|
634
1038
|
}
|
|
635
1039
|
state.confirmingNewChat = false;
|
|
636
1040
|
state.chat.abort?.abort();
|
|
637
|
-
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
|
|
1041
|
+
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null };
|
|
638
1042
|
lsRemove(CHAT_KEY);
|
|
639
1043
|
render();
|
|
640
1044
|
}
|
|
@@ -686,6 +1090,59 @@ function restoreChat() {
|
|
|
686
1090
|
} catch {}
|
|
687
1091
|
}
|
|
688
1092
|
|
|
1093
|
+
// Flatten a message's content (string content or md/text/tool parts) to plain
|
|
1094
|
+
// text for the copy action.
|
|
1095
|
+
function messageToText(m) {
|
|
1096
|
+
if (m.content) return m.content;
|
|
1097
|
+
if (!Array.isArray(m.parts)) return '';
|
|
1098
|
+
return m.parts.map((p) => {
|
|
1099
|
+
if (typeof p === 'string') return p;
|
|
1100
|
+
if (p.kind === 'md' || p.kind === 'text') return p.text || '';
|
|
1101
|
+
if (p.kind === 'tool') return '[tool: ' + (p.name || '') + (p.label ? ' ' + p.label : '') + ']';
|
|
1102
|
+
return '';
|
|
1103
|
+
}).filter(Boolean).join('\n');
|
|
1104
|
+
}
|
|
1105
|
+
function copyMessageText(m) {
|
|
1106
|
+
const text = messageToText(m);
|
|
1107
|
+
if (!text) return;
|
|
1108
|
+
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => announce('message copied')).catch(() => {});
|
|
1109
|
+
else {
|
|
1110
|
+
try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); announce('message copied'); } catch {}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// Retry the last assistant turn: drop it and re-send the preceding user message.
|
|
1114
|
+
function retryLastTurn() {
|
|
1115
|
+
if (!canSend() && !state.chat.busy) { /* allow when idle */ }
|
|
1116
|
+
if (state.chat.busy) return;
|
|
1117
|
+
const msgs = state.chat.messages;
|
|
1118
|
+
// Find the trailing assistant turn and the user message before it.
|
|
1119
|
+
let ai = msgs.length - 1;
|
|
1120
|
+
while (ai >= 0 && msgs[ai].role !== 'assistant') ai--;
|
|
1121
|
+
if (ai < 0) return;
|
|
1122
|
+
let ui = ai - 1;
|
|
1123
|
+
while (ui >= 0 && msgs[ui].role !== 'user') ui--;
|
|
1124
|
+
if (ui < 0) return;
|
|
1125
|
+
const userText = msgs[ui].content || '';
|
|
1126
|
+
// Drop the user+assistant pair (and anything after) and resend the user text.
|
|
1127
|
+
state.chat.messages = msgs.slice(0, ui);
|
|
1128
|
+
state.chat.draft = userText;
|
|
1129
|
+
persistChat();
|
|
1130
|
+
if (canSend()) sendChat();
|
|
1131
|
+
else render();
|
|
1132
|
+
}
|
|
1133
|
+
// Edit-and-resend a user message: truncate the transcript at it, refill the
|
|
1134
|
+
// composer with its text, and let the user revise before sending.
|
|
1135
|
+
function editAndResend(m) {
|
|
1136
|
+
if (state.chat.busy) return;
|
|
1137
|
+
const idx = state.chat.messages.indexOf(m);
|
|
1138
|
+
if (idx < 0) return;
|
|
1139
|
+
state.chat.messages = state.chat.messages.slice(0, idx);
|
|
1140
|
+
state.chat.draft = m.content || messageToText(m);
|
|
1141
|
+
persistChat();
|
|
1142
|
+
render();
|
|
1143
|
+
requestAnimationFrame(() => focusComposer());
|
|
1144
|
+
}
|
|
1145
|
+
|
|
689
1146
|
async function sendChat() {
|
|
690
1147
|
const text = (state.chat.draft || '').trim();
|
|
691
1148
|
if (!text || !canSend()) return;
|
|
@@ -724,7 +1181,23 @@ async function sendChat() {
|
|
|
724
1181
|
else if (ev.type === 'text') { appendText(cur.parts, ev.text); scheduleStreamRender(); }
|
|
725
1182
|
else if (ev.type === 'tool') { cur.parts.push(toolPart(ev.block)); scheduleStreamRender(); }
|
|
726
1183
|
else if (ev.type === 'tool_result') { applyToolResult(cur.parts, ev.block); scheduleStreamRender(); }
|
|
727
|
-
else if (ev.type === 'result') {
|
|
1184
|
+
else if (ev.type === 'result') {
|
|
1185
|
+
// The terminal result block carries claude's turn usage (token counts,
|
|
1186
|
+
// cost, turns, duration). The prose is already streamed via text events;
|
|
1187
|
+
// capture the usage so the ContextPane can surface it (was dropped).
|
|
1188
|
+
const b = ev.block || {};
|
|
1189
|
+
const u = b.usage || {};
|
|
1190
|
+
const inTok = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
1191
|
+
const outTok = u.output_tokens || 0;
|
|
1192
|
+
state.chat.usage = {
|
|
1193
|
+
inputTokens: inTok || null,
|
|
1194
|
+
outputTokens: outTok || null,
|
|
1195
|
+
costUsd: typeof b.total_cost_usd === 'number' ? b.total_cost_usd : null,
|
|
1196
|
+
turns: b.num_turns != null ? b.num_turns : null,
|
|
1197
|
+
durationMs: b.duration_ms != null ? b.duration_ms : null,
|
|
1198
|
+
};
|
|
1199
|
+
scheduleStreamRender();
|
|
1200
|
+
}
|
|
728
1201
|
else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
|
|
729
1202
|
}
|
|
730
1203
|
} catch (e) {
|
|
@@ -944,9 +1417,11 @@ function runningPanel() {
|
|
|
944
1417
|
children: running.map((r) => {
|
|
945
1418
|
const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
|
|
946
1419
|
const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
|
|
1420
|
+
// All three children must be keyed VElements (mixing a keyed span with an
|
|
1421
|
+
// unkeyed one crashes webjsx applyDiff "reading 'key'").
|
|
947
1422
|
return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
|
|
948
1423
|
h('span', { key: 'rd-' + r.sessionId, class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
|
|
949
|
-
h('span', { class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
|
|
1424
|
+
h('span', { key: 'rl-' + r.sessionId, class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
|
|
950
1425
|
Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
|
|
951
1426
|
}),
|
|
952
1427
|
});
|
|
@@ -1154,6 +1629,13 @@ function settingsMain() {
|
|
|
1154
1629
|
}),
|
|
1155
1630
|
]),
|
|
1156
1631
|
}),
|
|
1632
|
+
Panel({
|
|
1633
|
+
title: 'appearance',
|
|
1634
|
+
children: [
|
|
1635
|
+
h('div', { key: 'apl', class: 'lede', style: 'margin-bottom:.5em' }, 'theme - follows the OS in auto, or pick light/dark.'),
|
|
1636
|
+
ThemeToggle({ key: 'tt' }),
|
|
1637
|
+
],
|
|
1638
|
+
}),
|
|
1157
1639
|
agentsPanel(),
|
|
1158
1640
|
preferencesPanel(),
|
|
1159
1641
|
]),
|
|
@@ -1259,12 +1741,14 @@ async function refreshHistory() {
|
|
|
1259
1741
|
writeHash(null);
|
|
1260
1742
|
}
|
|
1261
1743
|
state.historyError = null;
|
|
1262
|
-
render();
|
|
1263
1744
|
} catch (e) {
|
|
1745
|
+
// Only a genuine fetch/list failure is a history error. A render exception
|
|
1746
|
+
// must not masquerade as one (it would poison the sessions panel with a
|
|
1747
|
+
// render-stack string and never clear), so render() lives outside this try.
|
|
1264
1748
|
state.historyError = e.message;
|
|
1265
1749
|
console.warn('history fetch failed:', e.message);
|
|
1266
|
-
render();
|
|
1267
1750
|
}
|
|
1751
|
+
render();
|
|
1268
1752
|
}
|
|
1269
1753
|
const debouncedRefreshHistory = debounce(refreshHistory, 500);
|
|
1270
1754
|
|
|
@@ -1379,6 +1863,10 @@ async function init() {
|
|
|
1379
1863
|
registerWsStatusOnce();
|
|
1380
1864
|
startActivePolling(); // surface running chats on any tab, not just history
|
|
1381
1865
|
startRelTimeTick();
|
|
1866
|
+
startLiveTick(); // 1s elapsed advance on the live dashboard
|
|
1867
|
+
// The conversation column shows on the default chat tab, so load the session
|
|
1868
|
+
// list at boot (unless a history deep-link already triggered a refresh).
|
|
1869
|
+
if (state.tab === 'chat' && !state.sessions.length) refreshHistory();
|
|
1382
1870
|
}
|
|
1383
1871
|
|
|
1384
1872
|
// init() runs both at boot and on every saveBackend(); registering the WS
|
|
@@ -1437,6 +1925,8 @@ window.addEventListener('keydown', (e) => {
|
|
|
1437
1925
|
gPending = false;
|
|
1438
1926
|
if (e.key === 'c') { navTo('chat'); return; }
|
|
1439
1927
|
if (e.key === 'h') { navTo('history'); return; }
|
|
1928
|
+
if (e.key === 'f') { navTo('files'); return; }
|
|
1929
|
+
if (e.key === 'l') { navTo('live'); return; }
|
|
1440
1930
|
if (e.key === 's') { navTo('settings'); return; }
|
|
1441
1931
|
return;
|
|
1442
1932
|
}
|