agentgui 1.0.1062 → 1.0.1064
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gm/prd.yml +0 -12
- package/lib/http-handler.js +94 -6
- package/package.json +1 -1
- package/site/app/js/app.js +108 -7
- package/site/app/js/backend.js +3 -0
package/.gm/prd.yml
CHANGED
|
@@ -3106,18 +3106,6 @@
|
|
|
3106
3106
|
- id: docstudio-cue-session-group-eyebrow
|
|
3107
3107
|
subject: Verify ConversationList's session-group headers (Today/Yesterday/etc) use the --tr-label token + --fg-3 tone consistently, matching docstudio's uppercase letter-spaced low-opacity eyebrow label idiom
|
|
3108
3108
|
status: pending
|
|
3109
|
-
- id: gc-delete-undo-trash
|
|
3110
|
-
subject: No undo-after-delete (soft-delete/trash) anywhere in the confined delete surface
|
|
3111
|
-
status: pending
|
|
3112
|
-
- id: gc-search-hit-hash-anchor
|
|
3113
|
-
subject: Search-hit event anchor (focusEventI/focusEventTs) not carried in the hash - reload/Back loses highlighted line
|
|
3114
|
-
status: pending
|
|
3115
|
-
- id: gc-settings-section-scrollspy
|
|
3116
|
-
subject: Settings section anchor is deep-link-in only - scrolling never updates state.settingsSection or the URL
|
|
3117
|
-
status: pending
|
|
3118
|
-
- id: gc-context-budget-affordance
|
|
3119
|
-
subject: No context-size/token-budget affordance - only turn count and dollar cost shown, never remaining context headroom
|
|
3120
|
-
status: pending
|
|
3121
3109
|
- id: gc-expanded-body-highlight
|
|
3122
3110
|
subject: Search-result highlighting inside expanded event bodies never highlights the matched query term
|
|
3123
3111
|
status: pending
|
package/lib/http-handler.js
CHANGED
|
@@ -146,6 +146,73 @@ function isAllowRoot(realPath, allowRoots) {
|
|
|
146
146
|
return allowRoots.some(r => (IS_WINDOWS ? r.toLowerCase() : r) === p);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// --- Soft-delete (trash) -----------------------------------------------
|
|
150
|
+
// /api/delete moves entries into <root>/.agentgui-trash/<trashId>__<name>
|
|
151
|
+
// instead of unlinking, giving a short undo window. In-memory index only
|
|
152
|
+
// (server restart forfeits the undo window - acceptable since the retention
|
|
153
|
+
// window itself is short and this is a convenience net on top of, not a
|
|
154
|
+
// replacement for, the pre-delete ConfirmDialog). Purged after
|
|
155
|
+
// TRASH_RETENTION_MS or when trashIndex grows past TRASH_MAX_ENTRIES
|
|
156
|
+
// (oldest-first), so a long-running server's trash dir can't grow unbounded.
|
|
157
|
+
const TRASH_DIR_NAME = '.agentgui-trash';
|
|
158
|
+
const TRASH_RETENTION_MS = parseInt(process.env.AGENTGUI_TRASH_RETENTION_MS || '', 10) || 10 * 60 * 1000;
|
|
159
|
+
const TRASH_MAX_ENTRIES = 200;
|
|
160
|
+
const trashIndex = new Map(); // trashId -> { trashPath, originalPath, root, deletedAt }
|
|
161
|
+
|
|
162
|
+
function purgeExpiredTrash() {
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
for (const [id, info] of trashIndex) {
|
|
165
|
+
if (now - info.deletedAt > TRASH_RETENTION_MS) {
|
|
166
|
+
try { fs.rmSync(info.trashPath, { recursive: true, force: true }); } catch { /* already gone */ }
|
|
167
|
+
trashIndex.delete(id);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (trashIndex.size > TRASH_MAX_ENTRIES) {
|
|
171
|
+
const sorted = [...trashIndex.entries()].sort((a, b) => a[1].deletedAt - b[1].deletedAt);
|
|
172
|
+
for (const [id, info] of sorted.slice(0, trashIndex.size - TRASH_MAX_ENTRIES)) {
|
|
173
|
+
try { fs.rmSync(info.trashPath, { recursive: true, force: true }); } catch {}
|
|
174
|
+
trashIndex.delete(id);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Which allowed root a confined realPath lives under - the trash dir sits
|
|
180
|
+
// alongside it (still inside the SAME root, so confineToRoots covers the
|
|
181
|
+
// trash path too - no new unconfined surface).
|
|
182
|
+
function rootFor(realPath, allowRoots) {
|
|
183
|
+
const p = IS_WINDOWS ? realPath.toLowerCase() : realPath;
|
|
184
|
+
return allowRoots.find(r => { const rr = IS_WINDOWS ? r.toLowerCase() : r; return p === rr || p.startsWith(rr + path.sep); });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function moveToTrash(realPath, allowRoots) {
|
|
188
|
+
purgeExpiredTrash();
|
|
189
|
+
const root = rootFor(realPath, allowRoots);
|
|
190
|
+
if (!root) { const e = new Error('not confined to an allowed root'); e.code = 'EACCES'; throw e; }
|
|
191
|
+
const trashDir = path.join(root, TRASH_DIR_NAME);
|
|
192
|
+
fs.mkdirSync(trashDir, { recursive: true });
|
|
193
|
+
const trashId = crypto.randomBytes(8).toString('hex');
|
|
194
|
+
const base = path.basename(realPath);
|
|
195
|
+
const trashPath = path.join(trashDir, trashId + '__' + base);
|
|
196
|
+
fs.renameSync(realPath, trashPath);
|
|
197
|
+
trashIndex.set(trashId, { trashPath, originalPath: realPath, root, deletedAt: Date.now() });
|
|
198
|
+
return { trashId };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function restoreFromTrash(trashId, allowRoots) {
|
|
202
|
+
const info = trashIndex.get(trashId);
|
|
203
|
+
if (!info) { const e = new Error('nothing to restore - the undo window has expired or this was already restored'); e.code = 'NOT_FOUND'; throw e; }
|
|
204
|
+
// Re-confine the ORIGINAL path at restore time (not trust the cached one
|
|
205
|
+
// blindly) - the allowlist itself doesn't change at runtime, but this keeps
|
|
206
|
+
// restore honoring the exact same confinement contract every other route does.
|
|
207
|
+
const conf = confineToRoots(info.originalPath, allowRoots);
|
|
208
|
+
if (!conf.ok && conf.reason !== 'not found') { const e = new Error('restore target is no longer inside an accessible folder'); e.code = 'CONFLICT'; throw e; }
|
|
209
|
+
if (fs.existsSync(info.originalPath)) { const e = new Error('a file already exists at the original location'); e.code = 'CONFLICT'; throw e; }
|
|
210
|
+
fs.mkdirSync(path.dirname(info.originalPath), { recursive: true });
|
|
211
|
+
fs.renameSync(info.trashPath, info.originalPath);
|
|
212
|
+
trashIndex.delete(trashId);
|
|
213
|
+
return { path: info.originalPath };
|
|
214
|
+
}
|
|
215
|
+
|
|
149
216
|
export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
|
|
150
217
|
// Warn operators when CORS_ORIGIN=* is combined with no PASSWORD: any
|
|
151
218
|
// cross-origin page can make credentialless fetch() calls to all /api/*
|
|
@@ -690,13 +757,18 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
690
757
|
if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot delete an allowed root' }); return; }
|
|
691
758
|
try {
|
|
692
759
|
const st = fs.lstatSync(conf.realPath);
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
760
|
+
// Soft-delete: move into a confined per-root .agentgui-trash/ instead
|
|
761
|
+
// of unlinking, so the only safety net isn't confirm-before (the
|
|
762
|
+
// pre-existing ConfirmDialog) but also undo-after, matching an
|
|
763
|
+
// fsbrowse-grade file manager. A non-empty directory without
|
|
764
|
+
// recursive=true still throws ENOTEMPTY BEFORE any move happens
|
|
765
|
+
// (checked via a dry probe) to keep that existing guard's semantics.
|
|
766
|
+
if (st.isDirectory() && body.recursive !== true) {
|
|
767
|
+
const dryEntries = fs.readdirSync(conf.realPath);
|
|
768
|
+
if (dryEntries.length) { sendJSON(req, res, 409, { error: 'directory is not empty' }); return; }
|
|
698
769
|
}
|
|
699
|
-
|
|
770
|
+
const trashInfo = moveToTrash(conf.realPath, allowRoots);
|
|
771
|
+
sendJSON(req, res, 200, { ok: true, trashId: trashInfo.trashId });
|
|
700
772
|
} catch (err) {
|
|
701
773
|
const code = err.code === 'ENOTEMPTY' ? 409 : (err.code === 'EACCES' || err.code === 'EPERM' ? 403 : (err.code === 'ENOENT' ? 404 : 400));
|
|
702
774
|
sendJSON(req, res, code, { error: err.code === 'ENOTEMPTY' ? 'directory is not empty' : err.message });
|
|
@@ -704,6 +776,22 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
704
776
|
return;
|
|
705
777
|
}
|
|
706
778
|
|
|
779
|
+
// POST /api/restore {trashId} -> {ok, path}. Undoes a /api/delete within
|
|
780
|
+
// its retention window (trashRetentionMs, default 10 minutes) by moving
|
|
781
|
+
// the entry back from .agentgui-trash/ to its original confined path.
|
|
782
|
+
if (routePath.split('?')[0] === '/api/restore' && req.method === 'POST') {
|
|
783
|
+
let body;
|
|
784
|
+
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
785
|
+
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
786
|
+
try {
|
|
787
|
+
const restored = restoreFromTrash(String(body.trashId || ''), fsAllowRoots());
|
|
788
|
+
sendJSON(req, res, 200, { ok: true, path: restored.path });
|
|
789
|
+
} catch (err) {
|
|
790
|
+
sendJSON(req, res, err.code === 'NOT_FOUND' ? 404 : (err.code === 'CONFLICT' ? 409 : 400), { error: err.message });
|
|
791
|
+
}
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
707
795
|
// POST /api/mkdir {dir, name} -> {ok, path}. dir must exist inside roots.
|
|
708
796
|
if (routePath.split('?')[0] === '/api/mkdir' && req.method === 'POST') {
|
|
709
797
|
let body;
|
package/package.json
CHANGED
package/site/app/js/app.js
CHANGED
|
@@ -51,7 +51,7 @@ const ARM_RESET_MS = 4000;
|
|
|
51
51
|
|
|
52
52
|
// Full routable param set. Every view-defining piece of state round-trips
|
|
53
53
|
// through the hash so reload and Back/forward restore the exact view.
|
|
54
|
-
const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section', 'filter', 'lsort', 'lfilter', 'lerr'];
|
|
54
|
+
const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section', 'filter', 'lsort', 'lfilter', 'lerr', 'ets'];
|
|
55
55
|
function readHash() {
|
|
56
56
|
const hash = location.hash || '';
|
|
57
57
|
const out = {};
|
|
@@ -80,6 +80,11 @@ function buildHash() {
|
|
|
80
80
|
const q = (state.searchQ || '').trim();
|
|
81
81
|
if (q.length >= 2) parts.push('q=' + encodeURIComponent(q));
|
|
82
82
|
if (state.projectFilter) parts.push('project=' + encodeURIComponent(state.projectFilter));
|
|
83
|
+
// A session opened from a search hit carries the matched event's
|
|
84
|
+
// timestamp so reload/Back reproduces the same scrolled+flashed position
|
|
85
|
+
// a live click gives - without this, the anchor only existed in memory
|
|
86
|
+
// and a search-hit URL degraded to just the bare session on reload.
|
|
87
|
+
if (state._focusEventTs != null) parts.push('ets=' + encodeURIComponent(state._focusEventTs));
|
|
83
88
|
}
|
|
84
89
|
if (tab === 'settings' && state.settingsSection) parts.push('section=' + encodeURIComponent(state.settingsSection));
|
|
85
90
|
if (tab === 'live') {
|
|
@@ -822,7 +827,14 @@ function sessionsColumn() {
|
|
|
822
827
|
onNew: () => { navTo('chat'); newChat(); },
|
|
823
828
|
onSelect: (s) => {
|
|
824
829
|
if (state.tab === 'chat') resumeInChat({ sid: s.sid });
|
|
825
|
-
else
|
|
830
|
+
else {
|
|
831
|
+
// Persist the anchor so buildHash() can carry it into the URL -
|
|
832
|
+
// without this, reload/Back only ever restored the bare session,
|
|
833
|
+
// losing the matched event's scroll+flash position.
|
|
834
|
+
state._focusEventTs = s._focusEventTs ?? null;
|
|
835
|
+
loadSession(s.sid, { focusEventI: s._focusEventI, focusEventTs: s._focusEventTs });
|
|
836
|
+
writeHash({ push: true });
|
|
837
|
+
}
|
|
826
838
|
},
|
|
827
839
|
loading: state.searchBusy,
|
|
828
840
|
error: state.searchHits.error || null,
|
|
@@ -1168,10 +1180,13 @@ async function runFileMutation(fn, doneMsg, patch) {
|
|
|
1168
1180
|
if (!d || d.busy) return;
|
|
1169
1181
|
d.busy = true; d.error = null; render();
|
|
1170
1182
|
try {
|
|
1171
|
-
await fn();
|
|
1183
|
+
const result = await fn();
|
|
1172
1184
|
state.files.dialog = null;
|
|
1173
1185
|
restoreFileDialogFocus(d._trigger);
|
|
1174
1186
|
announce(doneMsg);
|
|
1187
|
+
// A soft-delete's trashId (if this mutation was a delete) rides the
|
|
1188
|
+
// return value straight to the undo-toast caller.
|
|
1189
|
+
if (result && result.trashId) offerUndoDelete(result.trashId, doneMsg);
|
|
1175
1190
|
if (patch) {
|
|
1176
1191
|
// Patch the visible list immediately, matching the bulk-delete/move
|
|
1177
1192
|
// pattern, instead of stalling the dialog on a second full round-trip.
|
|
@@ -1188,6 +1203,36 @@ async function runFileMutation(fn, doneMsg, patch) {
|
|
|
1188
1203
|
d.busy = false; d.error = fileMutationCopy(e); render();
|
|
1189
1204
|
}
|
|
1190
1205
|
}
|
|
1206
|
+
// Delete is a soft-delete server-side (moved to a confined .agentgui-trash/,
|
|
1207
|
+
// see lib/http-handler.js) - offer a real undo action within the retention
|
|
1208
|
+
// window instead of the ConfirmDialog's pre-delete confirm being the ONLY
|
|
1209
|
+
// safety net. One active undo toast at a time (the most recent delete wins;
|
|
1210
|
+
// an in-flight bulk-delete calls this per-entry, each replacing the last -
|
|
1211
|
+
// acceptable since restoring the single most recent one is still strictly
|
|
1212
|
+
// better than no undo at all, and stacking N toasts for a bulk op would be
|
|
1213
|
+
// its own UX problem).
|
|
1214
|
+
const UNDO_DELETE_WINDOW_MS = 10000;
|
|
1215
|
+
let _undoDeleteTimer = null;
|
|
1216
|
+
function offerUndoDelete(trashId, doneMsg) {
|
|
1217
|
+
clearTimeout(_undoDeleteTimer);
|
|
1218
|
+
state.files.undoDelete = { trashId, doneMsg };
|
|
1219
|
+
render();
|
|
1220
|
+
_undoDeleteTimer = setTimeout(() => { state.files.undoDelete = null; render(); }, UNDO_DELETE_WINDOW_MS);
|
|
1221
|
+
}
|
|
1222
|
+
async function undoDelete() {
|
|
1223
|
+
const u = state.files.undoDelete;
|
|
1224
|
+
if (!u) return;
|
|
1225
|
+
clearTimeout(_undoDeleteTimer);
|
|
1226
|
+
state.files.undoDelete = null;
|
|
1227
|
+
try {
|
|
1228
|
+
await B.restoreEntry(state.backend, u.trashId);
|
|
1229
|
+
announce('restored');
|
|
1230
|
+
await loadDir(state.files.path, { fromHash: true });
|
|
1231
|
+
} catch (e) {
|
|
1232
|
+
announce('could not restore: ' + fileMutationCopy(e));
|
|
1233
|
+
render();
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1191
1236
|
// Upload a FileList into the current directory; per-file rows feed the kit
|
|
1192
1237
|
// UploadProgress (done/error per file - fetch has no chunk progress).
|
|
1193
1238
|
async function uploadFiles(fileList) {
|
|
@@ -1322,9 +1367,12 @@ function fileDialog() {
|
|
|
1322
1367
|
const isDir = d.file.type === 'dir';
|
|
1323
1368
|
return ConfirmDialog({
|
|
1324
1369
|
title: 'Delete ' + d.file.name,
|
|
1370
|
+
// Delete is soft (moved to trash, undoable for a short window right
|
|
1371
|
+
// after) - the copy no longer overclaims permanence the way an actual
|
|
1372
|
+
// unlink would warrant.
|
|
1325
1373
|
message: isDir
|
|
1326
|
-
? 'Delete this folder and everything inside it?
|
|
1327
|
-
: 'Delete this file?
|
|
1374
|
+
? 'Delete this folder and everything inside it? You can undo this for a few seconds after.'
|
|
1375
|
+
: 'Delete this file? You can undo this for a few seconds after.',
|
|
1328
1376
|
error: d.error || null, busy: !!d.busy,
|
|
1329
1377
|
confirmLabel: d.busy ? 'deleting…' : 'delete', cancelLabel: 'cancel', destructive: true,
|
|
1330
1378
|
onCancel: closeFileDialog,
|
|
@@ -1647,6 +1695,10 @@ function filesMain() {
|
|
|
1647
1695
|
: body;
|
|
1648
1696
|
return [
|
|
1649
1697
|
offlineBanner(),
|
|
1698
|
+
f.undoDelete ? Alert({ key: 'undodel', kind: 'info', title: 'Deleted',
|
|
1699
|
+
children: [
|
|
1700
|
+
h('span', { key: 'udtxt' }, (f.undoDelete.doneMsg || 'Deleted') + ' - undo within a few seconds. '),
|
|
1701
|
+
Btn({ key: 'udbtn', onClick: () => undoDelete(), children: 'undo' })] }) : null,
|
|
1650
1702
|
PageHeader({ compact: true, dense: true, title: 'Files', lede: 'Browse and manage files in the allowed folders.' }),
|
|
1651
1703
|
// One vertical beat (.ds-files-stack gap) for the whole command stack -
|
|
1652
1704
|
// the bands used to butt edge-to-edge while the header gap was 24px.
|
|
@@ -3927,6 +3979,10 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
|
|
|
3927
3979
|
return;
|
|
3928
3980
|
}
|
|
3929
3981
|
state.selectedSid = sid;
|
|
3982
|
+
// A plain (non-search-hit) session open must not carry a stale event
|
|
3983
|
+
// anchor forward into the URL - only reset it when this call ISN'T itself
|
|
3984
|
+
// the one supplying a fresh focusEventTs.
|
|
3985
|
+
if (focusEventTs == null) state._focusEventTs = null;
|
|
3930
3986
|
state.events = [];
|
|
3931
3987
|
state.events._seen = new Set(); // O(1) dedupe by event index
|
|
3932
3988
|
state.eventsLoaded = false;
|
|
@@ -4092,9 +4148,11 @@ async function init() {
|
|
|
4092
4148
|
} else if (hp.sid) {
|
|
4093
4149
|
if (hp.q) state.searchQ = hp.q;
|
|
4094
4150
|
if (hp.project) state.projectFilter = hp.project;
|
|
4151
|
+
const bootFocusTs = hp.ets != null ? Number(hp.ets) : null;
|
|
4152
|
+
if (bootFocusTs != null && !Number.isNaN(bootFocusTs)) state._focusEventTs = bootFocusTs;
|
|
4095
4153
|
navTo('history', { push: false });
|
|
4096
4154
|
await refreshHistory();
|
|
4097
|
-
await loadSession(hp.sid, { fromHash: true });
|
|
4155
|
+
await loadSession(hp.sid, { fromHash: true, focusEventTs: bootFocusTs != null && !Number.isNaN(bootFocusTs) ? bootFocusTs : undefined });
|
|
4098
4156
|
if (state.searchQ.trim().length >= 2) runSearch();
|
|
4099
4157
|
} else if (bootTab !== state.tab) {
|
|
4100
4158
|
// Files deep-link: restore the directory the URL names (reload keeps
|
|
@@ -4119,6 +4177,7 @@ async function init() {
|
|
|
4119
4177
|
}
|
|
4120
4178
|
|
|
4121
4179
|
registerWsStatusOnce();
|
|
4180
|
+
registerSettingsScrollSpyOnce();
|
|
4122
4181
|
startActivePolling(); // surface running chats on any tab, not just history
|
|
4123
4182
|
startRelTimeTick();
|
|
4124
4183
|
startLiveTick(); // 1s elapsed advance on the live dashboard
|
|
@@ -4177,6 +4236,44 @@ function focusSettingsSection(id) {
|
|
|
4177
4236
|
el.addEventListener('blur', clear);
|
|
4178
4237
|
});
|
|
4179
4238
|
}
|
|
4239
|
+
const SETTINGS_SECTION_IDS = ['backend', 'server', 'agents', 'appearance', 'keyboard', 'data'];
|
|
4240
|
+
// Settings was deep-link-IN only: focusSettingsSection() (a section= URL param
|
|
4241
|
+
// or the ?-overlay jump) set state.settingsSection, but manually scrolling
|
|
4242
|
+
// never updated it back - so the URL/state silently went stale the instant a
|
|
4243
|
+
// user scrolled by hand, and Back could never step BETWEEN panels the way it
|
|
4244
|
+
// does for every other tab. A lightweight scroll-position scrollspy (not a
|
|
4245
|
+
// full IntersectionObserver - the settings scroll region is small/short-lived
|
|
4246
|
+
// enough that a debounced scroll-position check is simpler and avoids the
|
|
4247
|
+
// observer-lifecycle bookkeeping) keeps state.settingsSection (and the URL)
|
|
4248
|
+
// honest while the user scrolls, registered once like the WS/session-expired
|
|
4249
|
+
// listeners above.
|
|
4250
|
+
const debouncedSettingsScrollSpy = debounce(() => {
|
|
4251
|
+
if (state.tab !== 'settings') return;
|
|
4252
|
+
const region = document.querySelector('#agentgui-main');
|
|
4253
|
+
if (!region) return;
|
|
4254
|
+
const regionTop = region.getBoundingClientRect().top;
|
|
4255
|
+
let current = null;
|
|
4256
|
+
for (const id of SETTINGS_SECTION_IDS) {
|
|
4257
|
+
const el = document.getElementById(id);
|
|
4258
|
+
if (!el) continue;
|
|
4259
|
+
// The section whose top has scrolled past the region's own top edge
|
|
4260
|
+
// (with a little slack for the sticky header) is the "current" one -
|
|
4261
|
+
// same heuristic every scrollspy implementation uses.
|
|
4262
|
+
if (el.getBoundingClientRect().top - regionTop <= 80) current = id;
|
|
4263
|
+
}
|
|
4264
|
+
if (current && current !== state.settingsSection) {
|
|
4265
|
+
state.settingsSection = current;
|
|
4266
|
+
writeHash({ push: false }); // passive sync, not a Back-able step - matches search text/filter's replaceState treatment
|
|
4267
|
+
}
|
|
4268
|
+
}, 150);
|
|
4269
|
+
let settingsScrollSpyRegistered = false;
|
|
4270
|
+
function registerSettingsScrollSpyOnce() {
|
|
4271
|
+
if (settingsScrollSpyRegistered) return;
|
|
4272
|
+
settingsScrollSpyRegistered = true;
|
|
4273
|
+
document.addEventListener('scroll', (e) => {
|
|
4274
|
+
if (e.target && e.target.id === 'agentgui-main') debouncedSettingsScrollSpy();
|
|
4275
|
+
}, true); // capture: #agentgui-main itself is the scrolling element, not a bubling target
|
|
4276
|
+
}
|
|
4180
4277
|
|
|
4181
4278
|
// Browser Back/forward: diff the FULL hash param set against state and re-sync
|
|
4182
4279
|
// each piece. Everything here runs with writeHash:false / fromHash:true so the
|
|
@@ -4189,7 +4286,11 @@ window.addEventListener('popstate', () => {
|
|
|
4189
4286
|
// anything else - or a bare sid - opens it in history).
|
|
4190
4287
|
if (hp.sid && hp.sid !== state.selectedSid) {
|
|
4191
4288
|
if (tab === 'chat') resumeInChat({ sid: hp.sid }, { fromHash: true });
|
|
4192
|
-
else
|
|
4289
|
+
else {
|
|
4290
|
+
const popFocusTs = hp.ets != null ? Number(hp.ets) : null;
|
|
4291
|
+
state._focusEventTs = (popFocusTs != null && !Number.isNaN(popFocusTs)) ? popFocusTs : null;
|
|
4292
|
+
loadSession(hp.sid, { fromHash: true, focusEventTs: (popFocusTs != null && !Number.isNaN(popFocusTs)) ? popFocusTs : undefined });
|
|
4293
|
+
}
|
|
4193
4294
|
} else if (!hp.sid && state.selectedSid && tab === 'history') {
|
|
4194
4295
|
state.selectedSid = null;
|
|
4195
4296
|
state.events = [];
|
package/site/app/js/backend.js
CHANGED
|
@@ -167,7 +167,10 @@ export async function statPath(base, p) {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
export function renameEntry(base, filePath, newName) { return mutateJSON(base, '/api/rename', { path: filePath, newName }); }
|
|
170
|
+
// deleteEntry's server response now includes trashId - the delete is a soft
|
|
171
|
+
// move-to-trash, not a permanent unlink, so the caller can offer an undo.
|
|
170
172
|
export function deleteEntry(base, filePath, recursive) { return mutateJSON(base, '/api/delete', { path: filePath, recursive: !!recursive }); }
|
|
173
|
+
export function restoreEntry(base, trashId) { return mutateJSON(base, '/api/restore', { trashId }); }
|
|
171
174
|
export function makeDir(base, dirPath, name) { return mutateJSON(base, '/api/mkdir', { dir: dirPath, name }); }
|
|
172
175
|
export function moveEntry(base, filePath, destDir, overwrite) { return mutateJSON(base, '/api/move', { path: filePath, destDir, overwrite: !!overwrite }); }
|
|
173
176
|
|