agentgui 1.0.958 → 1.0.959
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-completion.js +88 -0
- package/AGENTS.md +18 -0
- package/PUNCHLIST-COMPLETION.md +266 -0
- package/lib/http-handler.js +196 -16
- package/package.json +1 -1
- package/scripts/validate-mutations.mjs +40 -0
- package/site/app/js/app.js +771 -104
- package/site/app/js/backend.js +61 -3
- package/site/app/vendor/anentrypoint-design/247420.css +110 -0
- package/site/app/vendor/anentrypoint-design/247420.js +12 -12
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, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane } = C;
|
|
6
|
+
const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane, PromptDialog, ConfirmDialog, DropZone, UploadProgress, FilterPills, SessionMeta } = C;
|
|
7
7
|
|
|
8
8
|
const state = {
|
|
9
9
|
backend: B.getBackend(),
|
|
@@ -15,7 +15,13 @@ const state = {
|
|
|
15
15
|
agentModels: [],
|
|
16
16
|
selectedModel: lsGet('agentgui.model') || '',
|
|
17
17
|
chatCwd: lsGet('agentgui.cwd') || '',
|
|
18
|
-
chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null },
|
|
18
|
+
chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null, confirmingEdit: null, totalCost: 0 },
|
|
19
|
+
agentsError: null,
|
|
20
|
+
settingsSection: null,
|
|
21
|
+
eventFilter: 'all', // history event-type filter: all | text | tool | errors
|
|
22
|
+
sessionSearchQ: null, // the query the selected session was opened from (search-hit highlight)
|
|
23
|
+
historySlow: false, // first history fetch unresolved after 5s -> indexing copy
|
|
24
|
+
eventsSlow: false, // first events fetch unresolved after 5s -> indexing copy
|
|
19
25
|
sessions: [],
|
|
20
26
|
selectedSid: null,
|
|
21
27
|
events: [],
|
|
@@ -32,27 +38,47 @@ const state = {
|
|
|
32
38
|
files: { path: '', segments: [], entries: [], roots: [], loading: false, error: null, preview: null, sort: 'name', sortDir: 'asc', filter: '' },
|
|
33
39
|
};
|
|
34
40
|
|
|
41
|
+
// Full routable param set. Every view-defining piece of state round-trips
|
|
42
|
+
// through the hash so reload and Back/forward restore the exact view.
|
|
43
|
+
const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section'];
|
|
35
44
|
function readHash() {
|
|
36
45
|
const hash = location.hash || '';
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const k of HASH_KEYS) {
|
|
48
|
+
const m = hash.match(new RegExp('(?:^#|&)' + k + '=([^&]*)'));
|
|
49
|
+
out[k] = m ? decodeURIComponent(m[1]) : null;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
43
52
|
}
|
|
44
|
-
function buildHash(
|
|
53
|
+
function buildHash() {
|
|
45
54
|
const parts = [];
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
const tab = state.tab || 'chat';
|
|
56
|
+
// tab is omitted for the default chat tab, EXCEPT when a session id rides
|
|
57
|
+
// along - a bare #sid= historically meant history, so chat+sid must name its
|
|
58
|
+
// tab explicitly or the deep-link restores the wrong surface.
|
|
59
|
+
if (tab !== 'chat') parts.push('tab=' + encodeURIComponent(tab));
|
|
60
|
+
else if (state.selectedSid) parts.push('tab=chat');
|
|
61
|
+
// Keep sid whenever set (regardless of tab) so Back restores the selection.
|
|
62
|
+
if (state.selectedSid) parts.push('sid=' + encodeURIComponent(state.selectedSid));
|
|
63
|
+
if (tab === 'files' && state.files) {
|
|
64
|
+
if (state.files.path) parts.push('dir=' + encodeURIComponent(state.files.path));
|
|
65
|
+
if (state.files.preview && state.files.preview.path) parts.push('file=' + encodeURIComponent(state.files.preview.path));
|
|
66
|
+
}
|
|
67
|
+
if (tab === 'history') {
|
|
68
|
+
const q = (state.searchQ || '').trim();
|
|
69
|
+
if (q.length >= 2) parts.push('q=' + encodeURIComponent(q));
|
|
70
|
+
if (state.projectFilter) parts.push('project=' + encodeURIComponent(state.projectFilter));
|
|
71
|
+
}
|
|
72
|
+
if (tab === 'settings' && state.settingsSection) parts.push('section=' + encodeURIComponent(state.settingsSection));
|
|
48
73
|
return parts.length ? '#' + parts.join('&') : '';
|
|
49
74
|
}
|
|
50
|
-
function writeHash(
|
|
51
|
-
const h = buildHash(
|
|
75
|
+
function writeHash({ push = false } = {}) {
|
|
76
|
+
const h = buildHash();
|
|
52
77
|
const url = location.pathname + location.search + h;
|
|
53
|
-
if (location.hash === h) return;
|
|
54
|
-
// pushState for
|
|
55
|
-
// replaceState for
|
|
78
|
+
if (location.hash === h || (!location.hash && !h)) return;
|
|
79
|
+
// pushState for user navigation steps (tab visits, session selection,
|
|
80
|
+
// directory walks, preview opens) so Back retraces them; replaceState for
|
|
81
|
+
// passive state sync (search text, filter resets).
|
|
56
82
|
(push ? history.pushState : history.replaceState).call(history, null, '', url);
|
|
57
83
|
}
|
|
58
84
|
function fmtRelTime(ts) {
|
|
@@ -206,7 +232,7 @@ function sortedAgents() {
|
|
|
206
232
|
.map(({ a }) => a);
|
|
207
233
|
}
|
|
208
234
|
|
|
209
|
-
function navTo(tab) {
|
|
235
|
+
function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
|
|
210
236
|
const prev = state.tab;
|
|
211
237
|
// Leaving chat clears any pending new-chat confirmation so it doesn't linger
|
|
212
238
|
// as a stale banner when the user returns.
|
|
@@ -230,7 +256,9 @@ function navTo(tab) {
|
|
|
230
256
|
// filesMain (which runs during render) - a render-time fetch is fragile under
|
|
231
257
|
// a double-render and re-enters render() while building the tree.
|
|
232
258
|
if (tab === 'files' && !state.files.path && !state.files.loading && !state.files.error) loadDir('');
|
|
233
|
-
|
|
259
|
+
// popstate calls navTo with writeHash:false so it never replaceState-clobbers
|
|
260
|
+
// the entry it popped; user-initiated navigation pushes so Back walks tabs.
|
|
261
|
+
if (doWriteHash) writeHash({ push });
|
|
234
262
|
announce('Now on ' + tab + ' tab');
|
|
235
263
|
render();
|
|
236
264
|
// Move focus into the new region for keyboard/AT users.
|
|
@@ -390,6 +418,17 @@ function closeLiveStream() {
|
|
|
390
418
|
state.live.connected = false;
|
|
391
419
|
}
|
|
392
420
|
|
|
421
|
+
// ONE shortcuts definition consumed by BOTH the ?-overlay and the settings
|
|
422
|
+
// keyboard panel, so the two surfaces cannot drift from the keydown handler
|
|
423
|
+
// below (which implements exactly these: g+c/h/f/l/s, n, /, ?, Esc).
|
|
424
|
+
const SHORTCUTS = [
|
|
425
|
+
{ keys: 'g then c / h / f / l / s', desc: 'switch tabs (chat / history / files / live / settings)' },
|
|
426
|
+
{ keys: 'n', desc: 'new chat (on the chat tab)' },
|
|
427
|
+
{ keys: '/', desc: 'focus search or composer' },
|
|
428
|
+
{ keys: '?', desc: 'show shortcuts' },
|
|
429
|
+
{ keys: 'Esc', desc: 'blur the focused field' },
|
|
430
|
+
];
|
|
431
|
+
|
|
393
432
|
function view() {
|
|
394
433
|
const ok = state.health.status === 'ok';
|
|
395
434
|
const liveActive = state.tab === 'history' && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
|
|
@@ -444,7 +483,7 @@ function view() {
|
|
|
444
483
|
: 'min-height:0';
|
|
445
484
|
const shortcutsHint = state.showShortcuts
|
|
446
485
|
? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
|
|
447
|
-
children:
|
|
486
|
+
children: SHORTCUTS.map(s => s.keys + ' - ' + s.desc).join(' · ') })
|
|
448
487
|
: null;
|
|
449
488
|
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));
|
|
450
489
|
|
|
@@ -466,6 +505,10 @@ function view() {
|
|
|
466
505
|
cwd: state.chatCwd || '',
|
|
467
506
|
toolCount: runningToolCount(),
|
|
468
507
|
usage: state.chat.usage || null,
|
|
508
|
+
session: {
|
|
509
|
+
turns: state.chat.messages.filter(m => m.role === 'user').length,
|
|
510
|
+
cost: state.chat.totalCost || null,
|
|
511
|
+
},
|
|
469
512
|
onSetCwd: () => { navTo('files'); },
|
|
470
513
|
});
|
|
471
514
|
} else if (state.tab === 'files' && state.files && state.files.preview && !isNarrow()) {
|
|
@@ -593,6 +636,7 @@ function sessionsColumn() {
|
|
|
593
636
|
onNew: () => { navTo('chat'); newChat(); },
|
|
594
637
|
onSelect: (s) => { if (state.tab === 'chat') resumeInChat({ sid: s.sid }); else loadSession(s.sid); },
|
|
595
638
|
loading: state.tab === 'history' && !state.sessions.length && !state.historyError,
|
|
639
|
+
loadingText: state.historySlow ? 'Indexing your Claude history - the first load can take a minute…' : undefined,
|
|
596
640
|
error: state.historyError,
|
|
597
641
|
emptyText: 'No conversations yet',
|
|
598
642
|
});
|
|
@@ -607,7 +651,7 @@ function mainContent() {
|
|
|
607
651
|
}
|
|
608
652
|
|
|
609
653
|
// --- files (folder browser) ---
|
|
610
|
-
async function loadDir(dirPath) {
|
|
654
|
+
async function loadDir(dirPath, { fromHash = false } = {}) {
|
|
611
655
|
state.files = state.files || {};
|
|
612
656
|
state.files.loading = true; state.files.error = null; render();
|
|
613
657
|
try {
|
|
@@ -617,6 +661,11 @@ async function loadDir(dirPath) {
|
|
|
617
661
|
state.files.entries = j.entries || [];
|
|
618
662
|
state.files.roots = j.roots || [];
|
|
619
663
|
state.files.error = null;
|
|
664
|
+
// Deep-link the open directory: push so Back walks the tree; a load that
|
|
665
|
+
// came FROM the hash (popstate/boot) replaces instead - the entry already
|
|
666
|
+
// exists, only the URL needs to stay accurate (writeHash no-ops when the
|
|
667
|
+
// hash already matches, so popstate never loops).
|
|
668
|
+
if (state.tab === 'files') writeHash({ push: !fromHash });
|
|
620
669
|
} catch (e) {
|
|
621
670
|
// W9: translate HTTP status to plain, non-leaky copy.
|
|
622
671
|
state.files.error = e.status === 403
|
|
@@ -659,9 +708,110 @@ function closePreview() {
|
|
|
659
708
|
if (!state.files) return;
|
|
660
709
|
state.files.preview = null;
|
|
661
710
|
state.files.previewState = null;
|
|
711
|
+
// Clear file= from the URL (replace - the pushed open-entry stays poppable).
|
|
712
|
+
if (state.tab === 'files') writeHash();
|
|
662
713
|
render();
|
|
663
714
|
}
|
|
664
715
|
|
|
716
|
+
// Restore (or clear) the file= preview named by the hash once the directory
|
|
717
|
+
// listing for dir= has resolved - popstate and boot both funnel through here.
|
|
718
|
+
function restoreFileFromHash(filePath) {
|
|
719
|
+
const f = state.files || {};
|
|
720
|
+
if (!filePath) {
|
|
721
|
+
if (f.preview) { f.preview = null; f.previewState = null; render(); }
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (f.preview && f.preview.path === filePath) return;
|
|
725
|
+
const entry = (f.entries || []).find(e => e.path === filePath);
|
|
726
|
+
if (entry && entry.type !== 'dir') openPreview(entry, { fromHash: true });
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// --- file mutations (rename / delete / new folder / upload) ---
|
|
730
|
+
// One dialog at a time lives in state.files.dialog: {kind, file, error, busy}.
|
|
731
|
+
// Errors from the confined endpoints map to plain copy by HTTP status.
|
|
732
|
+
function fileMutationCopy(e) {
|
|
733
|
+
if (e.status === 403) return 'Permission denied, or the target is outside the allowed roots.';
|
|
734
|
+
if (e.status === 404) return 'That file no longer exists.';
|
|
735
|
+
if (e.status === 409) return e.message || 'A file with that name already exists.';
|
|
736
|
+
if (e.status === 413) return 'Too large (50MB upload cap).';
|
|
737
|
+
return e.message || 'The operation failed.';
|
|
738
|
+
}
|
|
739
|
+
function openFileDialog(kind, file) {
|
|
740
|
+
state.files.dialog = { kind, file: file || null, error: null, busy: false };
|
|
741
|
+
render();
|
|
742
|
+
}
|
|
743
|
+
function closeFileDialog() { state.files.dialog = null; render(); }
|
|
744
|
+
async function runFileMutation(fn, doneMsg) {
|
|
745
|
+
const d = state.files.dialog;
|
|
746
|
+
if (!d || d.busy) return;
|
|
747
|
+
d.busy = true; d.error = null; render();
|
|
748
|
+
try {
|
|
749
|
+
await fn();
|
|
750
|
+
state.files.dialog = null;
|
|
751
|
+
announce(doneMsg);
|
|
752
|
+
await loadDir(state.files.path, { fromHash: true }); // refresh in place, no history entry
|
|
753
|
+
} catch (e) {
|
|
754
|
+
d.busy = false; d.error = fileMutationCopy(e); render();
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// Upload a FileList into the current directory; per-file rows feed the kit
|
|
758
|
+
// UploadProgress (done/error per file - fetch has no chunk progress).
|
|
759
|
+
async function uploadFiles(fileList) {
|
|
760
|
+
const dir = state.files.path;
|
|
761
|
+
if (!dir || !fileList || !fileList.length) return;
|
|
762
|
+
const items = Array.from(fileList).map((f) => ({ name: f.name, pct: 0, done: false, error: null, _file: f }));
|
|
763
|
+
state.files.uploads = items; render();
|
|
764
|
+
for (const it of items) {
|
|
765
|
+
try {
|
|
766
|
+
await B.uploadFile(state.backend, dir, it._file);
|
|
767
|
+
it.pct = 100; it.done = true;
|
|
768
|
+
} catch (e) {
|
|
769
|
+
it.error = fileMutationCopy(e);
|
|
770
|
+
}
|
|
771
|
+
render();
|
|
772
|
+
}
|
|
773
|
+
announce('upload finished');
|
|
774
|
+
await loadDir(dir, { fromHash: true });
|
|
775
|
+
// Keep error rows visible; clear the list entirely when everything landed.
|
|
776
|
+
if (!items.some(i => i.error)) state.files.uploads = null;
|
|
777
|
+
render();
|
|
778
|
+
}
|
|
779
|
+
// The active file dialog (rename/delete/mkdir) as a kit modal, or null.
|
|
780
|
+
function fileDialog() {
|
|
781
|
+
const d = state.files && state.files.dialog;
|
|
782
|
+
if (!d) return null;
|
|
783
|
+
const err = d.error ? h('p', { key: 'fderr', class: 'lede', role: 'alert' }, d.error) : null;
|
|
784
|
+
if (d.kind === 'rename') {
|
|
785
|
+
return h('div', { key: 'fdlg' }, err, PromptDialog({
|
|
786
|
+
title: 'Rename ' + d.file.name, value: d.file.name, placeholder: 'new name',
|
|
787
|
+
confirmLabel: d.busy ? 'renaming...' : 'rename', cancelLabel: 'cancel',
|
|
788
|
+
onCancel: closeFileDialog,
|
|
789
|
+
onConfirm: (v) => { if (v && v !== d.file.name) runFileMutation(() => B.renameEntry(state.backend, d.file.path, v), 'renamed to ' + v); },
|
|
790
|
+
}));
|
|
791
|
+
}
|
|
792
|
+
if (d.kind === 'delete') {
|
|
793
|
+
const isDir = d.file.type === 'dir';
|
|
794
|
+
return h('div', { key: 'fdlg' }, err, ConfirmDialog({
|
|
795
|
+
title: 'Delete ' + d.file.name,
|
|
796
|
+
message: isDir
|
|
797
|
+
? 'Delete this folder and everything inside it? This cannot be undone.'
|
|
798
|
+
: 'Delete this file? This cannot be undone.',
|
|
799
|
+
confirmLabel: d.busy ? 'deleting...' : 'delete', cancelLabel: 'cancel', destructive: true,
|
|
800
|
+
onCancel: closeFileDialog,
|
|
801
|
+
onConfirm: () => runFileMutation(() => B.deleteEntry(state.backend, d.file.path, isDir), 'deleted ' + d.file.name),
|
|
802
|
+
}));
|
|
803
|
+
}
|
|
804
|
+
if (d.kind === 'mkdir') {
|
|
805
|
+
return h('div', { key: 'fdlg' }, err, PromptDialog({
|
|
806
|
+
title: 'New folder', value: '', placeholder: 'folder name',
|
|
807
|
+
confirmLabel: d.busy ? 'creating...' : 'create', cancelLabel: 'cancel',
|
|
808
|
+
onCancel: closeFileDialog,
|
|
809
|
+
onConfirm: (v) => { if (v) runFileMutation(() => B.makeDir(state.backend, state.files.path, v), 'created ' + v); },
|
|
810
|
+
}));
|
|
811
|
+
}
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
|
|
665
815
|
// Build the inner preview body (image / code / text / loading / error) shared by
|
|
666
816
|
// the modal FileViewer (<900px) and the inline FilePreviewPane (split view).
|
|
667
817
|
function filePreviewBody(file) {
|
|
@@ -697,8 +847,10 @@ function previewNeighbours() {
|
|
|
697
847
|
return { prev: i > 0 ? list[i - 1] : null, next: i < list.length - 1 ? list[i + 1] : null };
|
|
698
848
|
}
|
|
699
849
|
|
|
700
|
-
function openPreview(file) {
|
|
850
|
+
function openPreview(file, { fromHash = false } = {}) {
|
|
701
851
|
state.files.preview = file;
|
|
852
|
+
// Push file= so Back closes the preview; hash-driven opens replace instead.
|
|
853
|
+
if (state.tab === 'files') writeHash({ push: !fromHash });
|
|
702
854
|
if (file.type !== 'image') loadPreviewContent(file);
|
|
703
855
|
render();
|
|
704
856
|
}
|
|
@@ -785,6 +937,7 @@ function filesMain() {
|
|
|
785
937
|
// Click the active column to flip direction; a new column resets to asc.
|
|
786
938
|
if (state.files.sort === k) state.files.sortDir = state.files.sortDir === 'asc' ? 'desc' : 'asc';
|
|
787
939
|
else { state.files.sort = k; state.files.sortDir = 'asc'; }
|
|
940
|
+
persistFilesPrefs();
|
|
788
941
|
render();
|
|
789
942
|
} },
|
|
790
943
|
filter: { value: f.filter || '', placeholder: 'Filter files in this directory', onInput: (v) => { state.files.filter = v; state.files.shown = null; render(); } },
|
|
@@ -793,15 +946,18 @@ function filesMain() {
|
|
|
793
946
|
if (file.type === 'dir') loadDir(file.path);
|
|
794
947
|
else openPreview(file);
|
|
795
948
|
},
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
//
|
|
949
|
+
// Full manager wiring: download streams the confined /api/download;
|
|
950
|
+
// rename/delete open a kit dialog backed by the confined mutation
|
|
951
|
+
// endpoints (the former read-only scope cut is reversed).
|
|
799
952
|
onAction: (act, file) => {
|
|
953
|
+
if (file.permissions === 'EACCES') { announce('no access to ' + file.name); return; }
|
|
800
954
|
if (act === 'download' && file.type !== 'dir') {
|
|
801
955
|
const a = document.createElement('a');
|
|
802
956
|
a.href = B.downloadUrl(state.backend, file.path);
|
|
803
957
|
a.download = file.name; document.body.appendChild(a); a.click(); a.remove();
|
|
804
958
|
}
|
|
959
|
+
if (act === 'rename') openFileDialog('rename', file);
|
|
960
|
+
if (act === 'delete') openFileDialog('delete', file);
|
|
805
961
|
},
|
|
806
962
|
});
|
|
807
963
|
}
|
|
@@ -819,22 +975,84 @@ function filesMain() {
|
|
|
819
975
|
// Kit FileToolbar (replaces the hand-built .ds-file-toolbar markup).
|
|
820
976
|
const toolbar = FileToolbar({
|
|
821
977
|
left: [crumb],
|
|
822
|
-
right: [
|
|
823
|
-
? Btn({ key: '
|
|
824
|
-
:
|
|
978
|
+
right: [
|
|
979
|
+
f.path ? Btn({ key: 'newdir', onClick: () => openFileDialog('mkdir'), children: 'new folder' }) : null,
|
|
980
|
+
f.path ? Btn({ key: 'upload', onClick: () => {
|
|
981
|
+
const inp = document.createElement('input');
|
|
982
|
+
inp.type = 'file'; inp.multiple = true;
|
|
983
|
+
inp.onchange = () => uploadFiles(inp.files);
|
|
984
|
+
inp.click();
|
|
985
|
+
}, children: 'upload' }) : null,
|
|
986
|
+
targetCwd
|
|
987
|
+
? Btn({ key: 'usecwd', onClick: () => { state.chatCwd = targetCwd; lsSet('agentgui.cwd', targetCwd); announce('working directory set to ' + targetCwd); navTo('chat'); }, children: 'use as chat cwd' })
|
|
988
|
+
: null,
|
|
989
|
+
].filter(Boolean),
|
|
825
990
|
});
|
|
991
|
+
// Drag-and-drop upload wraps the grid (keyboard path = the toolbar upload
|
|
992
|
+
// button); per-file progress rows render above the grid while in flight.
|
|
993
|
+
const droppableBody = f.path && !f.error
|
|
994
|
+
? DropZone({
|
|
995
|
+
dragover: !!f.dragover,
|
|
996
|
+
label: 'drop files to upload to this folder',
|
|
997
|
+
onDragOver: () => { if (!state.files.dragover) { state.files.dragover = true; render(); } },
|
|
998
|
+
onDragLeave: () => { if (state.files.dragover) { state.files.dragover = false; render(); } },
|
|
999
|
+
onDrop: (files) => { state.files.dragover = false; uploadFiles(files); },
|
|
1000
|
+
children: body,
|
|
1001
|
+
})
|
|
1002
|
+
: body;
|
|
826
1003
|
return [
|
|
827
1004
|
offlineBanner(),
|
|
828
|
-
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.' }),
|
|
1005
|
+
PageHeader({ compact: true, title: 'Files', lede: 'Browse and manage the server filesystem within the allowed roots. Click a folder to open it; pick one as the chat working directory.' }),
|
|
829
1006
|
rootsRow ? h('div', { key: 'froots' }, rootsRow) : null,
|
|
830
1007
|
h('div', { key: 'ftb' }, toolbar),
|
|
831
|
-
h('div', { key: '
|
|
1008
|
+
(f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({ items: f.uploads })) : null,
|
|
1009
|
+
h('div', { key: 'fbody' }, droppableBody),
|
|
1010
|
+
fileDialog(),
|
|
832
1011
|
// Inline pane handles wide-screen preview; the modal is only the <900px
|
|
833
1012
|
// fallback (the pane has no room there).
|
|
834
1013
|
(f.preview && isNarrow()) ? h('div', { key: 'fprev' }, filePreview()) : null,
|
|
835
1014
|
].filter(Boolean);
|
|
836
1015
|
}
|
|
837
1016
|
|
|
1017
|
+
// --- live/files preference persistence (sort, errors-only). The live
|
|
1018
|
+
// selection Set is deliberately NOT persisted - stale sids would arm
|
|
1019
|
+
// stop-selected against sessions that no longer exist.
|
|
1020
|
+
const LIVE_PREFS_KEY = 'agentgui.live';
|
|
1021
|
+
const FILES_PREFS_KEY = 'agentgui.files';
|
|
1022
|
+
function persistLivePrefs() {
|
|
1023
|
+
lsSet(LIVE_PREFS_KEY, JSON.stringify({ sort: state.live.sort || 'status', errorsOnly: !!state.live.errorsOnly }));
|
|
1024
|
+
}
|
|
1025
|
+
function persistFilesPrefs() {
|
|
1026
|
+
lsSet(FILES_PREFS_KEY, JSON.stringify({ sort: state.files.sort || 'name', sortDir: state.files.sortDir || 'asc' }));
|
|
1027
|
+
}
|
|
1028
|
+
function hydratePrefs() {
|
|
1029
|
+
try {
|
|
1030
|
+
const lv = JSON.parse(lsGet(LIVE_PREFS_KEY) || 'null');
|
|
1031
|
+
if (lv) { if (lv.sort) state.live.sort = lv.sort; state.live.errorsOnly = !!lv.errorsOnly; }
|
|
1032
|
+
} catch {}
|
|
1033
|
+
try {
|
|
1034
|
+
const fp = JSON.parse(lsGet(FILES_PREFS_KEY) || 'null');
|
|
1035
|
+
if (fp) { if (fp.sort) state.files.sort = fp.sort; if (fp.sortDir) state.files.sortDir = fp.sortDir; }
|
|
1036
|
+
} catch {}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Two-step stop: the first click ARMS (confirming* flips the kit button to a
|
|
1040
|
+
// confirm state), a second click within 4s executes; the timer auto-resets.
|
|
1041
|
+
let _stopAllArmTimer = null;
|
|
1042
|
+
let _stopSelArmTimer = null;
|
|
1043
|
+
function armStopAll() {
|
|
1044
|
+
state.live.confirmingStopAll = true;
|
|
1045
|
+
clearTimeout(_stopAllArmTimer);
|
|
1046
|
+
_stopAllArmTimer = setTimeout(() => { state.live.confirmingStopAll = false; render(); }, 4000);
|
|
1047
|
+
render();
|
|
1048
|
+
}
|
|
1049
|
+
function armStopSelected() {
|
|
1050
|
+
state.live.confirmingStopSelected = true;
|
|
1051
|
+
clearTimeout(_stopSelArmTimer);
|
|
1052
|
+
_stopSelArmTimer = setTimeout(() => { state.live.confirmingStopSelected = false; render(); }, 4000);
|
|
1053
|
+
render();
|
|
1054
|
+
}
|
|
1055
|
+
|
|
838
1056
|
// Stop every in-flight chat at once (the dashboard "stop all" bulk control).
|
|
839
1057
|
async function stopAllActive(sessions) {
|
|
840
1058
|
const sids = (Array.isArray(sessions) ? sessions : (state.active || [])).map(s => s.sid || s.sessionId).filter(Boolean);
|
|
@@ -921,10 +1139,10 @@ function liveMain() {
|
|
|
921
1139
|
offline,
|
|
922
1140
|
streamState,
|
|
923
1141
|
activeSid: state.chat.resumeSid, // W13
|
|
924
|
-
sort: { value: sortKey, onChange: (v) => { state.live.sort = v; render(); } },
|
|
1142
|
+
sort: { value: sortKey, onChange: (v) => { state.live.sort = v; persistLivePrefs(); render(); } },
|
|
925
1143
|
filter: { value: lv.filter || '', placeholder: 'Filter sessions', onInput: (v) => { state.live.filter = v; render(); } },
|
|
926
1144
|
errorsOnly: !!lv.errorsOnly,
|
|
927
|
-
onErrorsOnly: (on) => { state.live.errorsOnly = on; render(); },
|
|
1145
|
+
onErrorsOnly: (on) => { state.live.errorsOnly = on; persistLivePrefs(); render(); },
|
|
928
1146
|
selectable: true,
|
|
929
1147
|
selected,
|
|
930
1148
|
onToggleSelect: (s) => {
|
|
@@ -932,10 +1150,24 @@ function liveMain() {
|
|
|
932
1150
|
if (set.has(s.sid)) set.delete(s.sid); else set.add(s.sid);
|
|
933
1151
|
state.live.selected = set; render();
|
|
934
1152
|
},
|
|
935
|
-
|
|
1153
|
+
// Two-step bulk stops: arm first, execute on the confirmed click.
|
|
1154
|
+
confirmingStopAll: !!lv.confirmingStopAll,
|
|
1155
|
+
confirmingStopSelected: !!lv.confirmingStopSelected,
|
|
1156
|
+
onArmStopAll: armStopAll,
|
|
1157
|
+
onArmStopSelected: armStopSelected,
|
|
1158
|
+
onStopSelected: (sids) => {
|
|
1159
|
+
state.live.confirmingStopSelected = false;
|
|
1160
|
+
clearTimeout(_stopSelArmTimer);
|
|
1161
|
+
stopAllActive(sids.map(sid => ({ sid })));
|
|
1162
|
+
state.live.selected = new Set();
|
|
1163
|
+
},
|
|
936
1164
|
emptyText: 'No live sessions - start a chat or run a local agent.',
|
|
937
1165
|
onStop: (s) => stopActiveChat(s.sid),
|
|
938
|
-
onStopAll: (all) =>
|
|
1166
|
+
onStopAll: (all) => {
|
|
1167
|
+
state.live.confirmingStopAll = false;
|
|
1168
|
+
clearTimeout(_stopAllArmTimer);
|
|
1169
|
+
stopAllActive(all);
|
|
1170
|
+
},
|
|
939
1171
|
onOpen: (s) => { resumeInChat({ sid: s.sid }); },
|
|
940
1172
|
onView: (s) => { navTo('history'); loadSession(s.sid); },
|
|
941
1173
|
}),
|
|
@@ -1016,15 +1248,29 @@ function applyToolResult(parts, block) {
|
|
|
1016
1248
|
}
|
|
1017
1249
|
}
|
|
1018
1250
|
|
|
1019
|
-
|
|
1251
|
+
// Map raw transport/server errors to plain-language copy. The raw string is
|
|
1252
|
+
// preserved via errTextRaw so renderers can carry it on a title attribute.
|
|
1253
|
+
const ERR_COPY = [
|
|
1254
|
+
[/^ws closed$/, 'Lost connection to the server.'],
|
|
1255
|
+
[/connection lost during stream/, 'Lost connection while the agent was responding - use retry.'],
|
|
1256
|
+
[/no sessionId from server/, 'The server could not start the agent - check it is installed.'],
|
|
1257
|
+
[/^sessions: \d+/, 'History is still indexing - try again in a moment.'],
|
|
1258
|
+
];
|
|
1259
|
+
function errTextRaw(e) {
|
|
1020
1260
|
if (e == null) return 'unknown error';
|
|
1021
1261
|
if (typeof e === 'string') return e;
|
|
1022
1262
|
if (e.message) return e.message;
|
|
1023
1263
|
try { return JSON.stringify(e); } catch { return String(e); }
|
|
1024
1264
|
}
|
|
1265
|
+
function errText(e) {
|
|
1266
|
+
const raw = errTextRaw(e);
|
|
1267
|
+
for (const [re, copy] of ERR_COPY) { if (re.test(raw)) return copy; }
|
|
1268
|
+
return raw;
|
|
1269
|
+
}
|
|
1025
1270
|
|
|
1026
1271
|
function chatMain() {
|
|
1027
1272
|
const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
|
|
1273
|
+
const userTurnCount = state.chat.messages.filter(m => m.role === 'user').length;
|
|
1028
1274
|
|
|
1029
1275
|
// Banners agentgui owns (resume, agent-switched, unavailable, confirm-clear,
|
|
1030
1276
|
// stream error) are pre-built here and handed to the kit, which renders them
|
|
@@ -1063,13 +1309,27 @@ function chatMain() {
|
|
|
1063
1309
|
if (state.cwdError) {
|
|
1064
1310
|
banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
|
|
1065
1311
|
}
|
|
1312
|
+
if (state.agentsError) {
|
|
1313
|
+
banners.push(Alert({ key: 'agerr', kind: 'error', title: 'Could not load agents from the server',
|
|
1314
|
+
children: [
|
|
1315
|
+
h('span', { key: 'agtxt', title: state.agentsError }, 'The agent list failed to load. '),
|
|
1316
|
+
Btn({ key: 'agretry', onClick: () => loadAgents(), children: 'retry' })] }));
|
|
1317
|
+
}
|
|
1318
|
+
if (state.chat.confirmingEdit) {
|
|
1319
|
+
banners.push(Alert({ key: 'confedit', kind: 'warn', title: 'Edit this message?',
|
|
1320
|
+
children: [
|
|
1321
|
+
h('span', { key: 'cetext' }, 'Editing will remove the later turns - continue? '),
|
|
1322
|
+
Btn({ key: 'ceyes', danger: true, onClick: confirmEditAndResend, children: 'continue' }),
|
|
1323
|
+
Btn({ key: 'ceno', onClick: cancelEditAndResend, children: 'cancel' })] }));
|
|
1324
|
+
}
|
|
1066
1325
|
const lastMsg = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1] : null;
|
|
1067
1326
|
const lastErr = lastMsg ? lastMsg.error : null;
|
|
1068
1327
|
if (lastErr && !state.chat.busy) {
|
|
1069
1328
|
banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error',
|
|
1070
1329
|
children: [
|
|
1071
|
-
h('span', { key: 'cetxt' }, lastErr + ' '),
|
|
1072
|
-
Btn({ key: '
|
|
1330
|
+
h('span', { key: 'cetxt', title: lastMsg.errorRaw || lastErr }, lastErr + ' '),
|
|
1331
|
+
Btn({ key: 'ceretry', onClick: () => { delete lastMsg.error; delete lastMsg.errorRaw; retryLastTurn(); }, children: 'retry' }),
|
|
1332
|
+
Btn({ key: 'cedismiss', onClick: () => { if (lastMsg) { delete lastMsg.error; delete lastMsg.errorRaw; persistChat(); render(); } }, children: 'dismiss' })] }));
|
|
1073
1333
|
}
|
|
1074
1334
|
|
|
1075
1335
|
const placeholder = !state.selectedAgent
|
|
@@ -1108,17 +1368,34 @@ function chatMain() {
|
|
|
1108
1368
|
// and the active target shown inline above the composer.
|
|
1109
1369
|
avatar: state.selectedAgent ? h('span', { class: 'agentchat-avatar-mark', 'aria-hidden': 'true' }, Icon('forum', { size: 16 })) : undefined,
|
|
1110
1370
|
composerContext: state.selectedAgent ? {
|
|
1111
|
-
bits: [agentName, state.selectedModel || null, state.chatCwd ? state.chatCwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : 'server default'].filter(Boolean),
|
|
1371
|
+
bits: [agentName, state.selectedModel || null, state.chatCwd ? state.chatCwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : 'server default', userTurnCount > 0 ? userTurnCount + ' turns' : null].filter(Boolean),
|
|
1112
1372
|
onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); },
|
|
1113
1373
|
} : undefined,
|
|
1114
|
-
// W15: contextual follow-up chips
|
|
1115
|
-
|
|
1374
|
+
// W15: contextual follow-up chips derived from the settled last turn
|
|
1375
|
+
// (tool error / code fence / file path), seeded statically otherwise.
|
|
1376
|
+
followups: chatFollowups(),
|
|
1116
1377
|
onFollowupClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
|
|
1378
|
+
// Transcript export: copy all / markdown / json.
|
|
1379
|
+
exportActions: state.chat.messages.length ? [
|
|
1380
|
+
{ label: 'copy all', title: 'Copy the whole transcript as markdown', onClick: () => copyText(transcriptToMarkdown(state.chat.messages), 'transcript copied') },
|
|
1381
|
+
{ label: 'export md', title: 'Download the transcript as markdown', onClick: () => downloadBlob(transcriptToMarkdown(state.chat.messages), 'agentgui-chat-' + dateStamp() + '.md', 'text/markdown') },
|
|
1382
|
+
{ label: 'export json', title: 'Download the transcript as JSON', onClick: () => downloadBlob(JSON.stringify(state.chat.messages, null, 2), 'agentgui-chat-' + dateStamp() + '.json', 'application/json') },
|
|
1383
|
+
] : [],
|
|
1384
|
+
// When the server reports agents but NONE is installed, show install
|
|
1385
|
+
// commands inline instead of a dead picker.
|
|
1386
|
+
installHint: (state.agents.length && state.agents.every(a => a.available === false)) ? {
|
|
1387
|
+
text: 'No agent CLIs found on the server.',
|
|
1388
|
+
commands: state.agents.filter(a => a.npxInstallable).map(a => ({ agent: a.name || a.id, command: 'npx ' + (a.npxPackage || a.id) })),
|
|
1389
|
+
onRecheck: () => loadAgents(),
|
|
1390
|
+
} : undefined,
|
|
1117
1391
|
// Per-message actions: copy any message; retry the last assistant turn;
|
|
1118
|
-
// edit-and-resend a user message (
|
|
1392
|
+
// edit-and-resend a user message (two-step: arm a confirm banner first,
|
|
1393
|
+
// since the truncation destroys the later turns).
|
|
1119
1394
|
onCopyMessage: (m) => copyMessageText(m),
|
|
1120
1395
|
onRetryMessage: () => retryLastTurn(),
|
|
1121
|
-
|
|
1396
|
+
confirmEdit: true,
|
|
1397
|
+
onArmEdit: (m) => armEditAndResend(m),
|
|
1398
|
+
onEditMessage: (m) => armEditAndResend(m),
|
|
1122
1399
|
cwd: state.chatCwd,
|
|
1123
1400
|
cwdEditing: !!state.cwdEditing,
|
|
1124
1401
|
cwdDraft: state.cwdDraft,
|
|
@@ -1133,11 +1410,12 @@ function chatMain() {
|
|
|
1133
1410
|
const was = !!(state.chat.draft && state.chat.draft.trim());
|
|
1134
1411
|
const now = !!(v && v.trim());
|
|
1135
1412
|
state.chat.draft = v;
|
|
1413
|
+
debouncedPersistDraft(); // a typed-but-unsent draft survives reload
|
|
1136
1414
|
if (was !== now) render();
|
|
1137
1415
|
},
|
|
1138
1416
|
onSend: (v) => { state.chat.draft = v; sendChat(); },
|
|
1139
1417
|
onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
|
|
1140
|
-
onCwdSave: () => {
|
|
1418
|
+
onCwdSave: async () => {
|
|
1141
1419
|
const path = (state.cwdDraft ?? '').trim();
|
|
1142
1420
|
// A relative cwd would resolve against the server process dir, not what
|
|
1143
1421
|
// the user means - require an absolute path (POSIX /..., UNC \\..., or
|
|
@@ -1147,6 +1425,20 @@ function chatMain() {
|
|
|
1147
1425
|
render();
|
|
1148
1426
|
return;
|
|
1149
1427
|
}
|
|
1428
|
+
// Validate against the server: the path must resolve and be a directory.
|
|
1429
|
+
if (path) {
|
|
1430
|
+
try {
|
|
1431
|
+
const st = await B.statPath(state.backend, path);
|
|
1432
|
+
if (!st || st.ok === false) { state.cwdError = 'directory not found on the server: ' + path; render(); return; }
|
|
1433
|
+
if (!st.dir) { state.cwdError = 'that path is not a directory'; render(); return; }
|
|
1434
|
+
} catch (e) {
|
|
1435
|
+
state.cwdError = e.status === 403 ? 'outside the allowed roots'
|
|
1436
|
+
: (e.status === 404 ? 'directory not found on the server: ' + path
|
|
1437
|
+
: (e.status ? 'directory not found on the server: ' + path : 'could not validate the path - server unreachable'));
|
|
1438
|
+
render();
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1150
1442
|
state.cwdError = null;
|
|
1151
1443
|
state.chatCwd = path;
|
|
1152
1444
|
if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
|
|
@@ -1175,7 +1467,7 @@ function newChat() {
|
|
|
1175
1467
|
}
|
|
1176
1468
|
state.confirmingNewChat = false;
|
|
1177
1469
|
state.chat.abort?.abort();
|
|
1178
|
-
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null };
|
|
1470
|
+
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null, confirmingEdit: null, totalCost: 0 };
|
|
1179
1471
|
lsRemove(CHAT_KEY);
|
|
1180
1472
|
render();
|
|
1181
1473
|
}
|
|
@@ -1205,15 +1497,21 @@ function persistChat() {
|
|
|
1205
1497
|
const msgs = state.chat.messages
|
|
1206
1498
|
.filter(m => !isEmptyTurn(m))
|
|
1207
1499
|
.map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, parts: m.parts }));
|
|
1208
|
-
|
|
1209
|
-
|
|
1500
|
+
const draft = (state.chat.draft || '').trim() ? state.chat.draft : '';
|
|
1501
|
+
// Persist when there is a transcript OR a non-empty draft (a typed-but-not-
|
|
1502
|
+
// sent message must survive a reload too).
|
|
1503
|
+
if (!msgs.length && !draft) { lsRemove(CHAT_KEY); return; }
|
|
1504
|
+
lsSet(CHAT_KEY, JSON.stringify({ messages: msgs, draft, resumeSid: state.chat.resumeSid, totalCost: state.chat.totalCost || 0, agent: state.selectedAgent, model: state.selectedModel }));
|
|
1210
1505
|
} catch {}
|
|
1211
1506
|
}
|
|
1507
|
+
const debouncedPersistDraft = debounce(persistChat, 500);
|
|
1212
1508
|
function restoreChat() {
|
|
1213
1509
|
try {
|
|
1214
1510
|
const raw = lsGet(CHAT_KEY);
|
|
1215
1511
|
if (!raw) return;
|
|
1216
1512
|
const saved = JSON.parse(raw);
|
|
1513
|
+
if (typeof saved.draft === 'string' && saved.draft.trim()) state.chat.draft = saved.draft;
|
|
1514
|
+
if (typeof saved.totalCost === 'number') state.chat.totalCost = saved.totalCost;
|
|
1217
1515
|
if (Array.isArray(saved.messages) && saved.messages.length) {
|
|
1218
1516
|
state.chat.messages = saved.messages
|
|
1219
1517
|
.filter(m => !isEmptyTurn(m))
|
|
@@ -1239,14 +1537,70 @@ function messageToText(m) {
|
|
|
1239
1537
|
return '';
|
|
1240
1538
|
}).filter(Boolean).join('\n');
|
|
1241
1539
|
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1540
|
+
// Shared clipboard helper with an insecure-origin fallback; announces via the
|
|
1541
|
+
// aria-live region so AT users hear the result.
|
|
1542
|
+
function copyText(text, msg) {
|
|
1244
1543
|
if (!text) return;
|
|
1245
|
-
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => announce(
|
|
1544
|
+
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => announce(msg)).catch(() => {});
|
|
1246
1545
|
else {
|
|
1247
|
-
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(
|
|
1546
|
+
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(msg); } catch {}
|
|
1248
1547
|
}
|
|
1249
1548
|
}
|
|
1549
|
+
function copyMessageText(m) {
|
|
1550
|
+
copyText(messageToText(m), 'message copied');
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// --- transcript export ---
|
|
1554
|
+
function dateStamp() { return new Date().toISOString().slice(0, 10); }
|
|
1555
|
+
function downloadBlob(content, filename, type) {
|
|
1556
|
+
const blob = new Blob([content], { type });
|
|
1557
|
+
const url = URL.createObjectURL(blob);
|
|
1558
|
+
const a = document.createElement('a');
|
|
1559
|
+
a.href = url; a.download = filename;
|
|
1560
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
1561
|
+
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
|
1562
|
+
}
|
|
1563
|
+
function transcriptToMarkdown(messages) {
|
|
1564
|
+
return (messages || []).map((m) => {
|
|
1565
|
+
const head = '## ' + (m.role === 'user' ? 'User' : 'Assistant') + (m.time ? ' (' + m.time + ')' : '');
|
|
1566
|
+
const parts = Array.isArray(m.parts) ? m.parts : [];
|
|
1567
|
+
const body = parts.length
|
|
1568
|
+
? parts.map((p) => {
|
|
1569
|
+
if (typeof p === 'string') return p;
|
|
1570
|
+
if (p.kind === 'md' || p.kind === 'text') return p.text || '';
|
|
1571
|
+
if (p.kind === 'tool' || p.kind === 'tool_result') {
|
|
1572
|
+
const bits = ['## tool: ' + (p.name || 'tool')];
|
|
1573
|
+
if (p.args && Object.keys(p.args).length) bits.push('```json\n' + JSON.stringify(p.args, null, 2) + '\n```');
|
|
1574
|
+
if (p.result != null) bits.push('```\n' + (typeof p.result === 'string' ? p.result : JSON.stringify(p.result, null, 2)) + '\n```');
|
|
1575
|
+
return bits.join('\n');
|
|
1576
|
+
}
|
|
1577
|
+
return '';
|
|
1578
|
+
}).filter(Boolean).join('\n\n')
|
|
1579
|
+
: (m.content || '');
|
|
1580
|
+
return head + '\n\n' + body;
|
|
1581
|
+
}).join('\n\n');
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Contextual follow-up chips derived from the settled last assistant turn;
|
|
1585
|
+
// falls back to the static seeds when nothing contextual applies.
|
|
1586
|
+
const FOLLOWUP_SEEDS = ['Explain that in more detail', 'Show me the diff', 'Run the tests'];
|
|
1587
|
+
function chatFollowups() {
|
|
1588
|
+
const msgs = state.chat.messages || [];
|
|
1589
|
+
if (!msgs.length || state.chat.busy) return [];
|
|
1590
|
+
const last = [...msgs].reverse().find(m => m.role === 'assistant');
|
|
1591
|
+
if (!last) return [];
|
|
1592
|
+
const out = [];
|
|
1593
|
+
const parts = Array.isArray(last.parts) ? last.parts : [];
|
|
1594
|
+
if (parts.some(p => p && p.kind === 'tool' && p.status === 'error')) out.push('Fix that error');
|
|
1595
|
+
const text = messageToText(last);
|
|
1596
|
+
if (/```/.test(text) || parts.some(p => p && p.kind === 'code')) out.push('Explain this code');
|
|
1597
|
+
const fm = text.match(/([A-Za-z]:\\\S+|\/[\w./-]+\.\w+)/);
|
|
1598
|
+
if (fm) {
|
|
1599
|
+
const base = fm[1].split(/[/\\]/).filter(Boolean).pop();
|
|
1600
|
+
if (base) out.push('Open ' + base.replace(/[^\w.\-]/g, '') + ' in files');
|
|
1601
|
+
}
|
|
1602
|
+
return out.length ? out.slice(0, 3) : FOLLOWUP_SEEDS;
|
|
1603
|
+
}
|
|
1250
1604
|
// Retry the last assistant turn: drop it and re-send the preceding user message.
|
|
1251
1605
|
function retryLastTurn() {
|
|
1252
1606
|
if (!canSend() && !state.chat.busy) { /* allow when idle */ }
|
|
@@ -1267,18 +1621,32 @@ function retryLastTurn() {
|
|
|
1267
1621
|
if (canSend()) sendChat();
|
|
1268
1622
|
else render();
|
|
1269
1623
|
}
|
|
1270
|
-
// Edit-and-resend a user message:
|
|
1271
|
-
//
|
|
1272
|
-
function
|
|
1624
|
+
// Edit-and-resend a user message: two-step. The edit click ARMS a confirmation
|
|
1625
|
+
// (truncating destroys the later turns), the banner's continue executes it.
|
|
1626
|
+
function armEditAndResend(m) {
|
|
1273
1627
|
if (state.chat.busy) return;
|
|
1274
1628
|
const idx = state.chat.messages.indexOf(m);
|
|
1275
1629
|
if (idx < 0) return;
|
|
1276
|
-
state.chat.
|
|
1277
|
-
|
|
1630
|
+
state.chat.confirmingEdit = { idx, text: m.content || messageToText(m) };
|
|
1631
|
+
render();
|
|
1632
|
+
}
|
|
1633
|
+
function confirmEditAndResend() {
|
|
1634
|
+
const ce = state.chat.confirmingEdit;
|
|
1635
|
+
if (!ce || state.chat.busy) return;
|
|
1636
|
+
state.chat.confirmingEdit = null;
|
|
1637
|
+
state.chat.messages = state.chat.messages.slice(0, ce.idx);
|
|
1638
|
+
// Never --resume a session whose tail diverged from what the server saw.
|
|
1639
|
+
state.chat.resumeSid = null;
|
|
1640
|
+
state.chat.resumeNote = null;
|
|
1641
|
+
state.chat.draft = ce.text;
|
|
1278
1642
|
persistChat();
|
|
1279
1643
|
render();
|
|
1280
1644
|
requestAnimationFrame(() => focusComposer());
|
|
1281
1645
|
}
|
|
1646
|
+
function cancelEditAndResend() {
|
|
1647
|
+
state.chat.confirmingEdit = null;
|
|
1648
|
+
render();
|
|
1649
|
+
}
|
|
1282
1650
|
|
|
1283
1651
|
async function sendChat() {
|
|
1284
1652
|
const text = (state.chat.draft || '').trim();
|
|
@@ -1333,12 +1701,16 @@ async function sendChat() {
|
|
|
1333
1701
|
turns: b.num_turns != null ? b.num_turns : null,
|
|
1334
1702
|
durationMs: b.duration_ms != null ? b.duration_ms : null,
|
|
1335
1703
|
};
|
|
1704
|
+
// Accumulate cost ACROSS turns so the ContextPane session line shows
|
|
1705
|
+
// the whole conversation's spend, not just the last turn's.
|
|
1706
|
+
const cost = typeof b.total_cost_usd === 'number' ? b.total_cost_usd : (typeof u.cost === 'number' ? u.cost : 0);
|
|
1707
|
+
if (cost) state.chat.totalCost = (state.chat.totalCost || 0) + cost;
|
|
1336
1708
|
scheduleStreamRender();
|
|
1337
1709
|
}
|
|
1338
|
-
else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
|
|
1710
|
+
else if (ev.type === 'error') { cur.error = errText(ev.error); cur.errorRaw = errTextRaw(ev.error); render(); }
|
|
1339
1711
|
}
|
|
1340
1712
|
} catch (e) {
|
|
1341
|
-
if (e.name !== 'AbortError') cur.error = errText(e.
|
|
1713
|
+
if (e.name !== 'AbortError') { cur.error = errText(e); cur.errorRaw = errTextRaw(e); }
|
|
1342
1714
|
} finally {
|
|
1343
1715
|
state.chat.busy = false;
|
|
1344
1716
|
state.chat.abort = null;
|
|
@@ -1359,6 +1731,48 @@ function reconnectAlert() {
|
|
|
1359
1731
|
});
|
|
1360
1732
|
}
|
|
1361
1733
|
|
|
1734
|
+
// Humanize a millisecond span (1m 30s scale words, no glyphs).
|
|
1735
|
+
function humanizeMs(ms) {
|
|
1736
|
+
if (ms == null || !isFinite(ms) || ms < 0) return '';
|
|
1737
|
+
const s = Math.round(ms / 1000);
|
|
1738
|
+
if (s < 60) return s + 's';
|
|
1739
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
|
|
1740
|
+
return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// First-to-last event timestamp span for the selected session, humanized.
|
|
1744
|
+
function sessionDuration() {
|
|
1745
|
+
const ts = (state.events || []).map(e => e.ts).filter(Boolean);
|
|
1746
|
+
if (ts.length < 2) return '';
|
|
1747
|
+
return humanizeMs(Math.max(...ts) - Math.min(...ts));
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// Event-type filter predicate (all | text | tool | errors).
|
|
1751
|
+
function eventMatchesFilter(e, f) {
|
|
1752
|
+
if (f === 'tool') return e.type === 'tool_use' || e.type === 'tool_result';
|
|
1753
|
+
if (f === 'errors') return !!e.isError;
|
|
1754
|
+
if (f === 'text') return !e.isError && e.type !== 'tool_use' && e.type !== 'tool_result';
|
|
1755
|
+
return true;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// Scroll to + flash the first error event, widening the render window (and
|
|
1759
|
+
// clearing the type filter) so the row is actually rendered.
|
|
1760
|
+
function jumpToFirstError() {
|
|
1761
|
+
const idx = state.events.findIndex(e => e.isError);
|
|
1762
|
+
if (idx < 0) return;
|
|
1763
|
+
state.eventFilter = 'all';
|
|
1764
|
+
const fromEnd = state.events.length - idx;
|
|
1765
|
+
if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
|
|
1766
|
+
render();
|
|
1767
|
+
const sliceStart = Math.max(0, state.events.length - state.eventsLimit);
|
|
1768
|
+
const rowPos = idx - sliceStart;
|
|
1769
|
+
requestAnimationFrame(() => {
|
|
1770
|
+
const rows = document.querySelectorAll('.ds-event-list .row');
|
|
1771
|
+
const row = rows[rowPos];
|
|
1772
|
+
if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1362
1776
|
function historyMain() {
|
|
1363
1777
|
if (!state.selectedSid) {
|
|
1364
1778
|
const count = (Array.isArray(state.sessions) ? state.sessions : []).length;
|
|
@@ -1390,26 +1804,65 @@ function historyMain() {
|
|
|
1390
1804
|
lede,
|
|
1391
1805
|
});
|
|
1392
1806
|
|
|
1393
|
-
const
|
|
1807
|
+
const hasErrors = state.events.some(e => e.isError);
|
|
1808
|
+
const actions = h('div', { key: 'acts', class: 'history-actions', role: 'status', 'aria-live': 'polite' }, [
|
|
1394
1809
|
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
|
|
1395
1810
|
Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
|
|
1396
|
-
|
|
1811
|
+
Btn({ key: 'exportsess', disabled: !state.eventsLoaded, title: 'Download this session\'s events as JSON',
|
|
1812
|
+
onClick: () => downloadBlob(JSON.stringify(state.events, null, 2), (projectLabel(sess?.project) || 'session') + '-' + state.selectedSid + '.json', 'application/json'),
|
|
1813
|
+
children: 'export' }),
|
|
1814
|
+
hasErrors ? Btn({ key: 'jumperr', onClick: jumpToFirstError, children: 'jump to first error' }) : null,
|
|
1815
|
+
].filter(Boolean));
|
|
1397
1816
|
|
|
1398
1817
|
if (state.events.length === 0) {
|
|
1399
1818
|
// Distinguish "still loading" from "genuinely empty" so a 0-event session
|
|
1400
|
-
// doesn't spin forever.
|
|
1819
|
+
// doesn't spin forever. After 5s of an unresolved first fetch, swap to the
|
|
1820
|
+
// indexing copy (ccsniff's first JSONL walk can take a minute).
|
|
1401
1821
|
const body = state.eventsLoaded
|
|
1402
1822
|
? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
|
|
1403
1823
|
h('span', { key: 'noevtxt' }, 'no events in this session - '),
|
|
1404
1824
|
Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
|
|
1405
|
-
: h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }),
|
|
1825
|
+
: h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }),
|
|
1826
|
+
state.eventsSlow ? 'Indexing your Claude history - the first load can take a minute…' : 'loading events…');
|
|
1406
1827
|
return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
|
|
1407
1828
|
}
|
|
1408
1829
|
|
|
1409
1830
|
if (!state.expandedEvents) state.expandedEvents = new Set();
|
|
1410
|
-
|
|
1831
|
+
// Event-type filter applies BEFORE the render-window slice so "errors" shows
|
|
1832
|
+
// every error in the session, not only errors among the most-recent 300.
|
|
1833
|
+
const ef = state.eventFilter || 'all';
|
|
1834
|
+
const filteredEvents = ef === 'all' ? state.events : state.events.filter(e => eventMatchesFilter(e, ef));
|
|
1835
|
+
const filterPills = FilterPills({
|
|
1836
|
+
options: [
|
|
1837
|
+
{ id: 'all', label: 'all' },
|
|
1838
|
+
{ id: 'text', label: 'text' },
|
|
1839
|
+
{ id: 'tool', label: 'tools' },
|
|
1840
|
+
{ id: 'errors', label: 'errors' },
|
|
1841
|
+
],
|
|
1842
|
+
selected: ef,
|
|
1843
|
+
onSelect: (id) => { state.eventFilter = id && id.id ? id.id : id; render(); },
|
|
1844
|
+
label: 'Filter events by type',
|
|
1845
|
+
});
|
|
1846
|
+
const meta = SessionMeta({
|
|
1847
|
+
items: [
|
|
1848
|
+
sess && sess.cwd ? { label: 'cwd', value: sess.cwd, title: sess.cwd } : null,
|
|
1849
|
+
sessionDuration() ? { label: 'duration', value: sessionDuration() } : null,
|
|
1850
|
+
{ label: 'sid', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'sid copied') },
|
|
1851
|
+
{ label: 'events', value: String(state.events.length) },
|
|
1852
|
+
].filter(Boolean),
|
|
1853
|
+
});
|
|
1854
|
+
if (filteredEvents.length === 0) {
|
|
1855
|
+
return [reconnectAlert(), head, actions, Panel({ title: 'events', children: [
|
|
1856
|
+
h('div', { key: 'evmeta' }, meta),
|
|
1857
|
+
h('div', { key: 'evfp' }, filterPills),
|
|
1858
|
+
h('div', { key: 'nofilt', class: 'lede empty-state', role: 'status' },
|
|
1859
|
+
h('span', { key: 'noftxt' }, '0 events match this filter - '),
|
|
1860
|
+
Btn({ key: 'clearf', onClick: () => { state.eventFilter = 'all'; render(); }, children: 'clear filter' })),
|
|
1861
|
+
] })].filter(Boolean);
|
|
1862
|
+
}
|
|
1863
|
+
const total = filteredEvents.length;
|
|
1411
1864
|
const limit = state.eventsLimit;
|
|
1412
|
-
const shown =
|
|
1865
|
+
const shown = filteredEvents.slice(-limit);
|
|
1413
1866
|
const hiddenCount = total - shown.length;
|
|
1414
1867
|
// Keys of the currently-shown rows, so expand-all toggles only what's rendered.
|
|
1415
1868
|
const shownKeys = shown.map((e, i) => {
|
|
@@ -1432,8 +1885,8 @@ function historyMain() {
|
|
|
1432
1885
|
head,
|
|
1433
1886
|
actions,
|
|
1434
1887
|
Panel({
|
|
1435
|
-
title: total + ' events' + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
|
|
1436
|
-
children: [eventControls, EventList({
|
|
1888
|
+
title: total + ' events' + (ef !== 'all' ? ' (' + ef + ' filter)' : '') + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
|
|
1889
|
+
children: [h('div', { key: 'evmeta' }, meta), h('div', { key: 'evfp' }, filterPills), eventControls, EventList({
|
|
1437
1890
|
items: shown.map((e, i) => {
|
|
1438
1891
|
// Stable key: prefer the server-assigned event index, else the
|
|
1439
1892
|
// event timestamp + position, never a bare array index (which
|
|
@@ -1456,13 +1909,27 @@ function historyMain() {
|
|
|
1456
1909
|
// Rail tone matches the session/agents rail semantics so an event's
|
|
1457
1910
|
// kind is visible at a glance, consistent across the GUI:
|
|
1458
1911
|
// flame = error, purple = tool_use, green = normal turn.
|
|
1459
|
-
const rail = e.isError ? 'flame' : 'green';
|
|
1912
|
+
const rail = e.isError ? 'flame' : (e.type === 'tool_use' ? 'purple' : 'green');
|
|
1913
|
+
// When the session was opened from a search hit, window the collapsed
|
|
1914
|
+
// title AROUND the first query match (a match at char 5000 would
|
|
1915
|
+
// otherwise be invisible behind the 0-220 slice).
|
|
1916
|
+
let collapsedTitle = text.slice(0, 220);
|
|
1917
|
+
const q = state.sessionSearchQ;
|
|
1918
|
+
if (q && !expanded) {
|
|
1919
|
+
const qi = text.toLowerCase().indexOf(q.toLowerCase());
|
|
1920
|
+
if (qi > 60) collapsedTitle = '…' + text.slice(qi - 60, qi - 60 + 220);
|
|
1921
|
+
}
|
|
1460
1922
|
return {
|
|
1461
1923
|
key,
|
|
1462
1924
|
code: String(absIdx + 1).padStart(4, '0'),
|
|
1463
1925
|
rail,
|
|
1464
1926
|
expanded, // disclosure state -> kit Row sets aria-expanded
|
|
1465
|
-
|
|
1927
|
+
highlight: q || undefined,
|
|
1928
|
+
actions: expanded ? [{
|
|
1929
|
+
label: 'copy', title: 'copy event',
|
|
1930
|
+
onClick: () => copyText(full || raw || ('(' + type + ')'), 'event copied'),
|
|
1931
|
+
}] : undefined,
|
|
1932
|
+
title: expanded ? (full || '(' + type + ')') : (collapsedTitle || '(' + type + ')'),
|
|
1466
1933
|
// Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
|
|
1467
1934
|
// Every row is click-to-expand, so always show the affordance word
|
|
1468
1935
|
// (not only when text overflows 220 chars).
|
|
@@ -1495,10 +1962,14 @@ function copySid() {
|
|
|
1495
1962
|
}
|
|
1496
1963
|
}
|
|
1497
1964
|
|
|
1498
|
-
function resumeInChat(sess) {
|
|
1965
|
+
function resumeInChat(sess, { fromHash = false } = {}) {
|
|
1499
1966
|
state.tab = 'chat';
|
|
1500
1967
|
closeLiveStream();
|
|
1501
1968
|
state.chat.resumeSid = sess?.sid || state.selectedSid;
|
|
1969
|
+
// Carry the sid on the chat tab too (tab=chat&sid=...) so reload/Back
|
|
1970
|
+
// restores the resumed conversation rather than a blank chat.
|
|
1971
|
+
state.selectedSid = state.chat.resumeSid || state.selectedSid;
|
|
1972
|
+
if (!fromHash) writeHash({ push: true });
|
|
1502
1973
|
state.chat.messages = [];
|
|
1503
1974
|
state.chat.draft = '';
|
|
1504
1975
|
// Only claude-code supports --resume by sid; warn if we have to switch the
|
|
@@ -1578,7 +2049,7 @@ function historySide() {
|
|
|
1578
2049
|
key: 'sr-' + (r.sid || '?') + '-' + i,
|
|
1579
2050
|
rank: String(i + 1).padStart(3, '0'),
|
|
1580
2051
|
title: r.snippet || '(no snippet)',
|
|
1581
|
-
sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : ''),
|
|
2052
|
+
sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
|
|
1582
2053
|
// Rail carries the same semantics as session rows: error > subagent > normal.
|
|
1583
2054
|
rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
|
|
1584
2055
|
// Carry the matched event's index so loadSession scrolls to + flashes
|
|
@@ -1632,13 +2103,13 @@ function historySide() {
|
|
|
1632
2103
|
? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
|
|
1633
2104
|
: null,
|
|
1634
2105
|
state.searchQ
|
|
1635
|
-
? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: 'clear search' })
|
|
2106
|
+
? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; writeHash(); render(); }, children: 'clear search' })
|
|
1636
2107
|
: null,
|
|
1637
2108
|
!searching && projects.length > 1
|
|
1638
2109
|
? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
|
|
1639
|
-
pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; render(); }),
|
|
2110
|
+
pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; writeHash(); render(); }),
|
|
1640
2111
|
...projects.slice(0, 8).map(([name, count]) =>
|
|
1641
|
-
pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
|
|
2112
|
+
pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; writeHash(); render(); })))
|
|
1642
2113
|
: null,
|
|
1643
2114
|
!searching && subagentCount
|
|
1644
2115
|
? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
|
|
@@ -1734,6 +2205,7 @@ function settingsMain() {
|
|
|
1734
2205
|
}),
|
|
1735
2206
|
h('div', { key: 'settings-grid', class: 'settings-grid' }, [
|
|
1736
2207
|
Panel({
|
|
2208
|
+
id: 'backend',
|
|
1737
2209
|
title: 'backend',
|
|
1738
2210
|
children: h('form', {
|
|
1739
2211
|
key: 'backendForm',
|
|
@@ -1767,18 +2239,53 @@ function settingsMain() {
|
|
|
1767
2239
|
]),
|
|
1768
2240
|
}),
|
|
1769
2241
|
Panel({
|
|
2242
|
+
id: 'appearance',
|
|
1770
2243
|
title: 'appearance',
|
|
1771
2244
|
children: [
|
|
1772
2245
|
h('div', { key: 'apl', class: 'lede', style: 'margin-bottom:.5em' }, 'theme - follows the OS in auto, or pick light/dark.'),
|
|
1773
2246
|
ThemeToggle({ key: 'tt' }),
|
|
1774
2247
|
],
|
|
1775
2248
|
}),
|
|
2249
|
+
serverPanel(),
|
|
1776
2250
|
agentsPanel(),
|
|
2251
|
+
keyboardPanel(),
|
|
1777
2252
|
preferencesPanel(),
|
|
1778
2253
|
]),
|
|
1779
2254
|
];
|
|
1780
2255
|
}
|
|
1781
2256
|
|
|
2257
|
+
// Server info from /health: version, uptime, ws clients, allowed roots (with
|
|
2258
|
+
// per-root copy), projects dir. Renders unknowns plainly rather than hiding.
|
|
2259
|
+
function serverPanel() {
|
|
2260
|
+
const hh = state.health || {};
|
|
2261
|
+
const roots = Array.isArray(hh.allowRoots) ? hh.allowRoots : [];
|
|
2262
|
+
const upMs = hh.uptimeMs != null ? hh.uptimeMs : (hh.uptime != null ? hh.uptime * 1000 : null);
|
|
2263
|
+
const wsClients = hh.wsClients != null ? hh.wsClients : (hh.clients != null ? hh.clients : null);
|
|
2264
|
+
return Panel({
|
|
2265
|
+
id: 'server',
|
|
2266
|
+
title: 'server',
|
|
2267
|
+
children: [
|
|
2268
|
+
h('div', { key: 'sv', class: 'lede' }, 'version: ' + (hh.version ? 'v' + hh.version : 'unknown')),
|
|
2269
|
+
h('div', { key: 'sup', class: 'lede' }, 'uptime: ' + (upMs != null ? humanizeMs(upMs) : 'unknown')),
|
|
2270
|
+
h('div', { key: 'swc', class: 'lede' }, 'ws clients: ' + (wsClients != null ? wsClients : 'unknown')),
|
|
2271
|
+
h('div', { key: 'spd', class: 'lede' }, 'projects dir: ' + (hh.projectsDir || 'unknown')),
|
|
2272
|
+
roots.length
|
|
2273
|
+
? SessionMeta({ key: 'sroots', items: roots.map((r, i) => ({ label: 'root ' + (i + 1), value: r, title: r, onCopy: () => copyText(r, 'root copied') })) })
|
|
2274
|
+
: h('div', { key: 'snoroots', class: 'lede' }, 'allowed roots: unknown'),
|
|
2275
|
+
],
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// The keyboard panel consumes the same SHORTCUTS definition as the ?-overlay.
|
|
2280
|
+
function keyboardPanel() {
|
|
2281
|
+
return Panel({
|
|
2282
|
+
id: 'keyboard',
|
|
2283
|
+
title: 'keyboard',
|
|
2284
|
+
children: SHORTCUTS.map((s, i) =>
|
|
2285
|
+
h('div', { key: 'kb' + i, class: 'lede' }, s.keys + ' - ' + s.desc)),
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
|
|
1782
2289
|
function clearLocalData() {
|
|
1783
2290
|
if (!state.confirmingClearData) { state.confirmingClearData = true; render(); return; }
|
|
1784
2291
|
state.confirmingClearData = false;
|
|
@@ -1788,19 +2295,34 @@ function clearLocalData() {
|
|
|
1788
2295
|
// re-persist them, so the "clear" wouldn't survive. A reload re-inits from
|
|
1789
2296
|
// defaults with the keys gone.
|
|
1790
2297
|
state.chat.abort?.abort(); // stop any in-flight stream before we drop the page
|
|
1791
|
-
for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend']) lsRemove(k);
|
|
1792
|
-
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
|
|
2298
|
+
for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend', 'agentgui.live', 'agentgui.files']) lsRemove(k);
|
|
2299
|
+
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, confirmingEdit: null, totalCost: 0 };
|
|
1793
2300
|
location.reload();
|
|
1794
2301
|
}
|
|
1795
2302
|
|
|
1796
2303
|
function preferencesPanel() {
|
|
1797
2304
|
const hh = state.health || {};
|
|
2305
|
+
const savedChat = lsGet(CHAT_KEY);
|
|
2306
|
+
// Footprint of agentgui's own localStorage keys.
|
|
2307
|
+
let lsKeys = 0, lsBytes = 0;
|
|
2308
|
+
try {
|
|
2309
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
2310
|
+
const k = localStorage.key(i);
|
|
2311
|
+
if (k && k.startsWith('agentgui.')) { lsKeys++; lsBytes += (localStorage.getItem(k) || '').length; }
|
|
2312
|
+
}
|
|
2313
|
+
} catch {}
|
|
1798
2314
|
return Panel({
|
|
1799
|
-
|
|
2315
|
+
id: 'data',
|
|
2316
|
+
title: 'data',
|
|
1800
2317
|
children: [
|
|
1801
2318
|
h('div', { key: 'ver', class: 'lede' }, 'server ' + (hh.version ? 'v' + hh.version : 'version unknown') + (window.__SERVER_VERSION ? ' · build ' + window.__SERVER_VERSION : '')),
|
|
1802
|
-
h('div', { key: '
|
|
1803
|
-
'
|
|
2319
|
+
h('div', { key: 'lsize', class: 'lede', style: 'margin:.5em 0' },
|
|
2320
|
+
'local data: ' + (Math.round(lsBytes / 102.4) / 10) + ' KB across ' + lsKeys + ' key' + (lsKeys === 1 ? '' : 's')),
|
|
2321
|
+
h('div', { key: 'expchatrow', style: 'margin:.5em 0' },
|
|
2322
|
+
Btn({ key: 'expchat', disabled: !savedChat,
|
|
2323
|
+
title: savedChat ? 'Download the saved chat transcript as JSON' : 'no saved chat',
|
|
2324
|
+
onClick: savedChat ? () => downloadBlob(savedChat, 'agentgui-chat-' + dateStamp() + '.json', 'application/json') : undefined,
|
|
2325
|
+
children: savedChat ? 'export chat' : 'no saved chat' })),
|
|
1804
2326
|
h('div', { key: 'cwdnote', class: 'lede', style: 'margin:.5em 0' },
|
|
1805
2327
|
'working directory: set per-chat in the chat composer’s cwd bar' + (state.chatCwd ? ' (current: ' + state.chatCwd + ')' : ' (currently: server default)')),
|
|
1806
2328
|
state.confirmingClearData
|
|
@@ -1823,6 +2345,7 @@ function agentsPanel() {
|
|
|
1823
2345
|
const installed = state.agents.filter(a => a.available !== false);
|
|
1824
2346
|
const hasAcp = (Array.isArray(state.health.acp) ? state.health.acp : []).length > 0;
|
|
1825
2347
|
return Panel({
|
|
2348
|
+
id: 'agents',
|
|
1826
2349
|
title: 'agents · ' + installed.length + '/' + state.agents.length + ' installed',
|
|
1827
2350
|
children: [
|
|
1828
2351
|
// ACP agents (opencode/kilo/codex) are managed automatically: started
|
|
@@ -1863,8 +2386,16 @@ function agentsPanel() {
|
|
|
1863
2386
|
|
|
1864
2387
|
// --- data ---
|
|
1865
2388
|
async function refreshHistory() {
|
|
2389
|
+
// Warmup copy: the FIRST sessions fetch can sit behind ccsniff's 30-90s
|
|
2390
|
+
// JSONL walk; after 5s swap the loading copy to indexing language.
|
|
2391
|
+
const firstLoad = !state._historyLoadedOnce;
|
|
2392
|
+
const slowTimer = firstLoad
|
|
2393
|
+
? setTimeout(() => { if (!state._historyLoadedOnce) { state.historySlow = true; render(); } }, 5000)
|
|
2394
|
+
: null;
|
|
1866
2395
|
try {
|
|
1867
2396
|
state.sessions = await B.listSessions(state.backend);
|
|
2397
|
+
state._historyLoadedOnce = true;
|
|
2398
|
+
state.historySlow = false;
|
|
1868
2399
|
// Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
|
|
1869
2400
|
// linear scan per event during a burst load.
|
|
1870
2401
|
state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
|
|
@@ -1875,34 +2406,38 @@ async function refreshHistory() {
|
|
|
1875
2406
|
state.selectedSid = null;
|
|
1876
2407
|
state.events = [];
|
|
1877
2408
|
state.eventsLoaded = false;
|
|
1878
|
-
writeHash(
|
|
2409
|
+
writeHash();
|
|
1879
2410
|
}
|
|
1880
2411
|
state.historyError = null;
|
|
1881
2412
|
} catch (e) {
|
|
1882
2413
|
// Only a genuine fetch/list failure is a history error. A render exception
|
|
1883
2414
|
// must not masquerade as one (it would poison the sessions panel with a
|
|
1884
2415
|
// render-stack string and never clear), so render() lives outside this try.
|
|
1885
|
-
state.historyError = e
|
|
2416
|
+
state.historyError = errText(e);
|
|
1886
2417
|
console.warn('history fetch failed:', e.message);
|
|
1887
2418
|
}
|
|
2419
|
+
if (slowTimer) clearTimeout(slowTimer);
|
|
1888
2420
|
render();
|
|
1889
2421
|
}
|
|
1890
2422
|
const debouncedRefreshHistory = debounce(refreshHistory, 500);
|
|
1891
2423
|
|
|
1892
2424
|
async function runSearch() {
|
|
1893
2425
|
const q = state.searchQ.trim();
|
|
1894
|
-
if (!q) { state.searchHits = null; state.searchBusy = false; render(); return; }
|
|
1895
|
-
if (q.length < 2) { state.searchHits = null; state.searchBusy = false; render(); return; }
|
|
2426
|
+
if (!q) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
|
|
2427
|
+
if (q.length < 2) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
|
|
1896
2428
|
// The project-filter pills are hidden while searching; clear the filter so it
|
|
1897
2429
|
// doesn't silently re-apply (and surprise the user) when they later clear the
|
|
1898
2430
|
// search and the now-visible session list is unexpectedly narrowed.
|
|
1899
2431
|
state.projectFilter = '';
|
|
2432
|
+
// The debounced search keeps the URL's q=/project= in sync via replaceState
|
|
2433
|
+
// so a reload (or share) restores the search, without flooding history.
|
|
2434
|
+
writeHash();
|
|
1900
2435
|
state.searchBusy = true;
|
|
1901
2436
|
render();
|
|
1902
2437
|
try {
|
|
1903
2438
|
state.searchHits = await B.searchHistory(state.backend, q, 60);
|
|
1904
2439
|
} catch (e) {
|
|
1905
|
-
state.searchHits = { query: q, results: [], error: e
|
|
2440
|
+
state.searchHits = { query: q, results: [], error: errText(e) };
|
|
1906
2441
|
} finally {
|
|
1907
2442
|
state.searchBusy = false;
|
|
1908
2443
|
render();
|
|
@@ -1910,18 +2445,26 @@ async function runSearch() {
|
|
|
1910
2445
|
}
|
|
1911
2446
|
const debouncedSearch = debounce(runSearch, 300);
|
|
1912
2447
|
|
|
1913
|
-
async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}) {
|
|
2448
|
+
async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromHash = false } = {}) {
|
|
1914
2449
|
// Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
|
|
1915
2450
|
if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
|
|
1916
2451
|
state.selectedSid = sid;
|
|
1917
2452
|
state.events = [];
|
|
1918
2453
|
state.eventsLoaded = false;
|
|
2454
|
+
state.eventsSlow = false;
|
|
1919
2455
|
state.eventsLimit = 300; // reset the render window per session
|
|
2456
|
+
state.eventFilter = 'all'; // don't carry the type filter across sessions
|
|
1920
2457
|
state.expandedEvents = new Set(); // don't carry expansion to the new session
|
|
2458
|
+
// Remember the query this session was opened FROM (search hit) so the event
|
|
2459
|
+
// rows can highlight + window around the match; a plain selection clears it.
|
|
2460
|
+
state.sessionSearchQ = (focusEventI != null || focusEventTs != null) && state.searchQ.trim().length >= 2
|
|
2461
|
+
? state.searchQ.trim() : null;
|
|
1921
2462
|
// The live "live · N" crumb counter reads as the selected session's activity,
|
|
1922
2463
|
// so reset it per selection rather than letting it accrue across all sessions.
|
|
1923
2464
|
state.live.eventCount = 0;
|
|
1924
|
-
writeHash(
|
|
2465
|
+
writeHash({ push: !fromHash });
|
|
2466
|
+
// Warmup copy: a first events fetch can sit behind ccsniff's JSONL walk.
|
|
2467
|
+
const slowTimer = setTimeout(() => { if (!state.eventsLoaded && state.selectedSid === sid) { state.eventsSlow = true; render(); } }, 5000);
|
|
1925
2468
|
// Close the mobile sidebar drawer on selection. The DS only auto-closes when
|
|
1926
2469
|
// the clicked element is an <a>; agentgui's session rows are onClick divs, so
|
|
1927
2470
|
// we close it explicitly here.
|
|
@@ -1934,6 +2477,8 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}
|
|
|
1934
2477
|
});
|
|
1935
2478
|
try {
|
|
1936
2479
|
state.events = await B.getSessionEvents(state.backend, sid);
|
|
2480
|
+
clearTimeout(slowTimer);
|
|
2481
|
+
state.eventsSlow = false;
|
|
1937
2482
|
state.eventsLoaded = true;
|
|
1938
2483
|
// If we arrived from a search hit, make sure the matched event is within the
|
|
1939
2484
|
// render window, then scroll to + flash it so the match isn't lost.
|
|
@@ -1961,21 +2506,20 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}
|
|
|
1961
2506
|
ts: Date.now(),
|
|
1962
2507
|
role: 'error',
|
|
1963
2508
|
type: 'fetch',
|
|
1964
|
-
text: 'Failed to load session: ' + e
|
|
2509
|
+
text: 'Failed to load session: ' + errText(e) + ' - retry via sidebar',
|
|
1965
2510
|
}];
|
|
2511
|
+
clearTimeout(slowTimer);
|
|
2512
|
+
state.eventsSlow = false;
|
|
1966
2513
|
state.eventsLoaded = true;
|
|
1967
2514
|
render();
|
|
1968
2515
|
}
|
|
1969
2516
|
}
|
|
1970
2517
|
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
state.health = { status: 'error', error: e.message };
|
|
1977
|
-
}
|
|
1978
|
-
render();
|
|
2518
|
+
// Fetch agents + pick the active one. Reusable: boot, backend save, and the
|
|
2519
|
+
// reconnect path all re-run it. Returns true on success; failure lands in
|
|
2520
|
+
// state.agentsError so the chat tab can surface it with a retry control.
|
|
2521
|
+
async function loadAgents() {
|
|
2522
|
+
state.agentsError = null;
|
|
1979
2523
|
try {
|
|
1980
2524
|
state.agents = await B.listAgents(state.backend);
|
|
1981
2525
|
// Agent selection priority: the agent a restored transcript belongs to (so
|
|
@@ -1986,15 +2530,85 @@ async function init() {
|
|
|
1986
2530
|
if (!target) target = state.agents.find(a => a.available !== false) || state.agents[0];
|
|
1987
2531
|
if (target) await selectAgent(target.id);
|
|
1988
2532
|
render();
|
|
1989
|
-
|
|
2533
|
+
return true;
|
|
2534
|
+
} catch (e) {
|
|
2535
|
+
state.agentsError = errText(e);
|
|
2536
|
+
console.warn('agents fetch failed:', e.message);
|
|
2537
|
+
render();
|
|
2538
|
+
return false;
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
1990
2541
|
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
2542
|
+
// Boot-time automatic retries with backoff when the first agents fetch fails
|
|
2543
|
+
// (the server may still be warming up).
|
|
2544
|
+
async function retryLoadAgents() {
|
|
2545
|
+
for (const ms of [2000, 5000, 10000]) {
|
|
2546
|
+
await new Promise(r => setTimeout(r, ms));
|
|
2547
|
+
if (!state.agentsError) return; // a manual retry already succeeded
|
|
2548
|
+
if (await loadAgents()) return;
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
// While the backend is unreachable, poll /health every ~10s; on recovery
|
|
2553
|
+
// re-fetch agents + models and announce, so a mid-session outage self-heals.
|
|
2554
|
+
let _healthPollTimer = null;
|
|
2555
|
+
function stopHealthPolling() { if (_healthPollTimer) { clearInterval(_healthPollTimer); _healthPollTimer = null; } }
|
|
2556
|
+
function startHealthPolling() {
|
|
2557
|
+
if (_healthPollTimer) return;
|
|
2558
|
+
_healthPollTimer = setInterval(() => {
|
|
2559
|
+
if (state.health.status === 'ok') { stopHealthPolling(); return; }
|
|
2560
|
+
recheckHealth();
|
|
2561
|
+
}, 10000);
|
|
2562
|
+
}
|
|
2563
|
+
async function recheckHealth() {
|
|
2564
|
+
const wasOk = state.health.status === 'ok';
|
|
2565
|
+
const r = await B.probeBackend(state.backend);
|
|
2566
|
+
if (r.ok) {
|
|
2567
|
+
state.health = { status: 'ok', ...r.info };
|
|
2568
|
+
stopHealthPolling();
|
|
2569
|
+
if (!wasOk) { announce('reconnected'); loadAgents(); }
|
|
2570
|
+
} else {
|
|
2571
|
+
state.health = { status: 'down', ...r };
|
|
2572
|
+
startHealthPolling();
|
|
2573
|
+
}
|
|
2574
|
+
render();
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
async function init() {
|
|
2578
|
+
try {
|
|
2579
|
+
const r = await B.probeBackend(state.backend);
|
|
2580
|
+
state.health = r.ok ? { status: 'ok', ...r.info } : { status: 'down', ...r };
|
|
2581
|
+
} catch (e) {
|
|
2582
|
+
state.health = { status: 'error', error: e.message };
|
|
2583
|
+
}
|
|
2584
|
+
render();
|
|
2585
|
+
if (!(await loadAgents())) retryLoadAgents();
|
|
2586
|
+
|
|
2587
|
+
const hp = readHash();
|
|
2588
|
+
const bootTab = hp.tab || (hp.sid ? 'history' : 'chat');
|
|
2589
|
+
if (hp.sid && bootTab === 'chat') {
|
|
2590
|
+
// tab=chat&sid deep link: resume the conversation in chat.
|
|
2591
|
+
resumeInChat({ sid: hp.sid }, { fromHash: true });
|
|
2592
|
+
refreshHistory();
|
|
2593
|
+
} else if (hp.sid) {
|
|
2594
|
+
if (hp.q) state.searchQ = hp.q;
|
|
2595
|
+
if (hp.project) state.projectFilter = hp.project;
|
|
2596
|
+
navTo('history', { push: false });
|
|
1994
2597
|
await refreshHistory();
|
|
1995
|
-
await loadSession(
|
|
1996
|
-
|
|
1997
|
-
|
|
2598
|
+
await loadSession(hp.sid, { fromHash: true });
|
|
2599
|
+
if (state.searchQ.trim().length >= 2) runSearch();
|
|
2600
|
+
} else if (bootTab !== state.tab) {
|
|
2601
|
+
// Files deep-link: restore the directory the URL names (reload keeps
|
|
2602
|
+
// context instead of resetting to the default root). Kick the load BEFORE
|
|
2603
|
+
// navTo - loadDir sets loading=true synchronously, so navTo's default
|
|
2604
|
+
// loadDir('') guard skips and the two never race. Once the listing
|
|
2605
|
+
// resolves, restore the file= preview on top of it.
|
|
2606
|
+
if (bootTab === 'files' && hp.dir) loadDir(hp.dir, { fromHash: true }).then(() => restoreFileFromHash(hp.file));
|
|
2607
|
+
if (bootTab === 'history' && hp.q) state.searchQ = hp.q;
|
|
2608
|
+
if (bootTab === 'history' && hp.project) state.projectFilter = hp.project;
|
|
2609
|
+
navTo(bootTab, { push: false });
|
|
2610
|
+
if (bootTab === 'history' && state.searchQ.trim().length >= 2) runSearch();
|
|
2611
|
+
if (bootTab === 'settings' && hp.section) focusSettingsSection(hp.section);
|
|
1998
2612
|
}
|
|
1999
2613
|
|
|
2000
2614
|
registerWsStatusOnce();
|
|
@@ -2015,12 +2629,17 @@ function registerWsStatusOnce() {
|
|
|
2015
2629
|
B.onWsStatus?.((s) => {
|
|
2016
2630
|
if (s === 'closed' || s === 'error') {
|
|
2017
2631
|
if (state.health.status === 'ok') { state.health = { ...state.health, ws: 'reconnecting' }; render(); }
|
|
2632
|
+
// The WS dropping often means the whole server went away - re-probe
|
|
2633
|
+
// /health so the offline banner appears mid-session (and the 10s
|
|
2634
|
+
// recovery poll starts) instead of silently retrying forever.
|
|
2635
|
+
recheckHealth();
|
|
2018
2636
|
} else if (s === 'open') {
|
|
2019
2637
|
if (state.health.ws) { delete state.health.ws; render(); }
|
|
2020
2638
|
}
|
|
2021
2639
|
});
|
|
2022
2640
|
}
|
|
2023
2641
|
|
|
2642
|
+
hydratePrefs();
|
|
2024
2643
|
restoreChat();
|
|
2025
2644
|
render = mount(document.getElementById('app'), view);
|
|
2026
2645
|
requestAnimationFrame(syncAriaCurrent);
|
|
@@ -2029,12 +2648,60 @@ requestAnimationFrame(syncAriaCurrent);
|
|
|
2029
2648
|
// (they read window.innerWidth only at render time).
|
|
2030
2649
|
window.addEventListener('resize', debounce(() => scheduleRender(), 150));
|
|
2031
2650
|
|
|
2032
|
-
//
|
|
2651
|
+
// Scroll to + focus a settings panel by its id (section= deep link), reusing
|
|
2652
|
+
// the data-prog-focus pattern so the programmatic focus ring is suppressed.
|
|
2653
|
+
function focusSettingsSection(id) {
|
|
2654
|
+
state.settingsSection = id;
|
|
2655
|
+
requestAnimationFrame(() => {
|
|
2656
|
+
const el = document.getElementById(id);
|
|
2657
|
+
if (!el) return;
|
|
2658
|
+
el.scrollIntoView({ block: 'start' });
|
|
2659
|
+
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '-1');
|
|
2660
|
+
el.setAttribute('data-prog-focus', '');
|
|
2661
|
+
try { el.focus({ preventScroll: true }); } catch {}
|
|
2662
|
+
const clear = () => { el.removeAttribute('data-prog-focus'); el.removeEventListener('blur', clear); };
|
|
2663
|
+
el.addEventListener('blur', clear);
|
|
2664
|
+
});
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// Browser Back/forward: diff the FULL hash param set against state and re-sync
|
|
2668
|
+
// each piece. Everything here runs with writeHash:false / fromHash:true so the
|
|
2669
|
+
// handler never clobbers or re-pushes the entry it just popped.
|
|
2033
2670
|
window.addEventListener('popstate', () => {
|
|
2034
|
-
const
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2671
|
+
const hp = readHash();
|
|
2672
|
+
const tab = hp.tab || 'chat';
|
|
2673
|
+
if (tab !== state.tab) navTo(tab, { writeHash: false });
|
|
2674
|
+
// Session selection: honor tab= when present (tab=chat&sid resumes in chat,
|
|
2675
|
+
// anything else - or a bare sid - opens it in history).
|
|
2676
|
+
if (hp.sid && hp.sid !== state.selectedSid) {
|
|
2677
|
+
if (tab === 'chat') resumeInChat({ sid: hp.sid }, { fromHash: true });
|
|
2678
|
+
else loadSession(hp.sid, { fromHash: true });
|
|
2679
|
+
} else if (!hp.sid && state.selectedSid && tab === 'history') {
|
|
2680
|
+
state.selectedSid = null;
|
|
2681
|
+
state.events = [];
|
|
2682
|
+
state.eventsLoaded = false;
|
|
2683
|
+
}
|
|
2684
|
+
if (tab === 'files') {
|
|
2685
|
+
const cur = state.files && state.files.path;
|
|
2686
|
+
if (hp.dir && hp.dir !== cur) {
|
|
2687
|
+
// Restore the file= preview only once the directory listing resolves
|
|
2688
|
+
// (the entry object lives in state.files.entries).
|
|
2689
|
+
loadDir(hp.dir, { fromHash: true }).then(() => restoreFileFromHash(hp.file));
|
|
2690
|
+
} else {
|
|
2691
|
+
restoreFileFromHash(hp.file);
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
if (tab === 'history') {
|
|
2695
|
+
const q = hp.q || '';
|
|
2696
|
+
if (q !== state.searchQ) {
|
|
2697
|
+
state.searchQ = q;
|
|
2698
|
+
if (q.trim().length >= 2) runSearch();
|
|
2699
|
+
else { state.searchHits = null; state.searchBusy = false; }
|
|
2700
|
+
}
|
|
2701
|
+
state.projectFilter = hp.project || '';
|
|
2702
|
+
}
|
|
2703
|
+
if (tab === 'settings' && hp.section) focusSettingsSection(hp.section);
|
|
2704
|
+
render();
|
|
2038
2705
|
});
|
|
2039
2706
|
|
|
2040
2707
|
window.__agentgui = { state, render };
|