agentgui 1.0.1121 → 1.0.1122
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/package.json +1 -1
- package/site/app/js/app.js +19 -163
- package/site/app/js/chat-persistence.js +1 -1
- package/site/app/js/hash-routing.js +62 -0
- package/site/app/js/shortcuts.js +128 -0
package/package.json
CHANGED
package/site/app/js/app.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { h, mount, installStyles, components as C } from 'anentrypoint-design';
|
|
2
2
|
import * as B from './backend.js';
|
|
3
|
-
import { createChatPersistence } from './chat-persistence.js';
|
|
3
|
+
import { createChatPersistence, CHAT_KEY } from './chat-persistence.js';
|
|
4
|
+
import { installShortcuts } from './shortcuts.js';
|
|
5
|
+
import { readHash as readHashRouting, buildHash as buildHashRouting, writeHash as writeHashRouting } from './hash-routing.js';
|
|
4
6
|
|
|
5
7
|
installStyles().catch(() => {});
|
|
6
8
|
|
|
@@ -68,59 +70,12 @@ const ARM_RESET_MS = 4000;
|
|
|
68
70
|
|
|
69
71
|
// Full routable param set. Every view-defining piece of state round-trips
|
|
70
72
|
// through the hash so reload and Back/forward restore the exact view.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
out[k] = m ? decodeURIComponent(m[1]) : null;
|
|
78
|
-
}
|
|
79
|
-
return out;
|
|
80
|
-
}
|
|
81
|
-
function buildHash() {
|
|
82
|
-
const parts = [];
|
|
83
|
-
const tab = state.tab || 'chat';
|
|
84
|
-
// tab is omitted for the default chat tab, EXCEPT when a session id rides
|
|
85
|
-
// along - a bare #sid= historically meant history, so chat+sid must name its
|
|
86
|
-
// tab explicitly or the deep-link restores the wrong surface.
|
|
87
|
-
if (tab !== 'chat') parts.push('tab=' + encodeURIComponent(tab));
|
|
88
|
-
else if (state.selectedSid) parts.push('tab=chat');
|
|
89
|
-
// Keep sid whenever set (regardless of tab) so Back restores the selection.
|
|
90
|
-
if (state.selectedSid) parts.push('sid=' + encodeURIComponent(state.selectedSid));
|
|
91
|
-
if (tab === 'files' && state.files) {
|
|
92
|
-
if (state.files.path) parts.push('dir=' + encodeURIComponent(state.files.path));
|
|
93
|
-
if (state.files.preview && state.files.preview.path) parts.push('file=' + encodeURIComponent(state.files.preview.path));
|
|
94
|
-
if (state.files.filter) parts.push('filter=' + encodeURIComponent(state.files.filter));
|
|
95
|
-
}
|
|
96
|
-
if (tab === 'history') {
|
|
97
|
-
const q = (state.searchQ || '').trim();
|
|
98
|
-
if (q.length >= 2) parts.push('q=' + encodeURIComponent(q));
|
|
99
|
-
if (state.projectFilter) parts.push('project=' + encodeURIComponent(state.projectFilter));
|
|
100
|
-
// A session opened from a search hit carries the matched event's
|
|
101
|
-
// timestamp so reload/Back reproduces the same scrolled+flashed position
|
|
102
|
-
// a live click gives - without this, the anchor only existed in memory
|
|
103
|
-
// and a search-hit URL degraded to just the bare session on reload.
|
|
104
|
-
if (state._focusEventTs != null) parts.push('ets=' + encodeURIComponent(state._focusEventTs));
|
|
105
|
-
}
|
|
106
|
-
if (tab === 'settings' && state.settingsSection) parts.push('section=' + encodeURIComponent(state.settingsSection));
|
|
107
|
-
if (tab === 'live') {
|
|
108
|
-
const lv = state.live || {};
|
|
109
|
-
if (lv.sort && lv.sort !== 'status') parts.push('lsort=' + encodeURIComponent(lv.sort));
|
|
110
|
-
if (lv.filter) parts.push('lfilter=' + encodeURIComponent(lv.filter));
|
|
111
|
-
if (lv.errorsOnly) parts.push('lerr=1');
|
|
112
|
-
}
|
|
113
|
-
return parts.length ? '#' + parts.join('&') : '';
|
|
114
|
-
}
|
|
115
|
-
function writeHash({ push = false } = {}) {
|
|
116
|
-
const h = buildHash();
|
|
117
|
-
const url = location.pathname + location.search + h;
|
|
118
|
-
if (location.hash === h || (!location.hash && !h)) return;
|
|
119
|
-
// pushState for user navigation steps (tab visits, session selection,
|
|
120
|
-
// directory walks, preview opens) so Back retraces them; replaceState for
|
|
121
|
-
// passive state sync (search text, filter resets).
|
|
122
|
-
(push ? history.pushState : history.replaceState).call(history, null, '', url);
|
|
123
|
-
}
|
|
73
|
+
// Hash-based deep-link state - extracted to hash-routing.js. Local names kept
|
|
74
|
+
// identical to every existing call site (readHash/buildHash/writeHash), bound
|
|
75
|
+
// against the module's shared `state` object.
|
|
76
|
+
function readHash() { return readHashRouting(); }
|
|
77
|
+
function buildHash() { return buildHashRouting(state); }
|
|
78
|
+
function writeHash(opts) { return writeHashRouting(state, opts); }
|
|
124
79
|
const plural = (n, w) => n + ' ' + w + (n === 1 ? '' : 's');
|
|
125
80
|
function fmtRelTime(ts) {
|
|
126
81
|
if (!ts) return '';
|
|
@@ -4646,115 +4601,16 @@ window.__agentgui = { state, render };
|
|
|
4646
4601
|
|
|
4647
4602
|
// Keyboard shortcuts. 'g' then c/h/s switches tabs; 'n' new chat; '/' focuses
|
|
4648
4603
|
// search (history) or composer (chat). Ignored while typing in a field.
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
}
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
// filter) - both are search-type inputs inside the main region.
|
|
4660
|
-
function focusFilter() {
|
|
4661
|
-
const el = document.querySelector('#agentgui-main input[type="search"]')
|
|
4662
|
-
|| document.querySelector('#agentgui-main .ds-file-filter-input')
|
|
4663
|
-
|| document.querySelector('#agentgui-main input[type="text"]');
|
|
4664
|
-
el?.focus();
|
|
4665
|
-
return !!el;
|
|
4666
|
-
}
|
|
4667
|
-
window.addEventListener('keydown', (e) => {
|
|
4668
|
-
const t = e.target;
|
|
4669
|
-
const typing = t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable);
|
|
4670
|
-
// One explicit chord BEFORE the modifier early-return: Mod+Shift+L focuses
|
|
4671
|
-
// the composer from anywhere, even while typing in another field.
|
|
4672
|
-
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'L' || e.key === 'l')) {
|
|
4673
|
-
e.preventDefault();
|
|
4674
|
-
navTo('chat');
|
|
4675
|
-
requestAnimationFrame(() => focusComposer());
|
|
4676
|
-
announce('composer focused');
|
|
4677
|
-
return;
|
|
4678
|
-
}
|
|
4679
|
-
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
4680
|
-
if (typing) {
|
|
4681
|
-
// The cwd editor's own text input is the one exception: Escape there must
|
|
4682
|
-
// close the editor in a single press (matching every other Escape-
|
|
4683
|
-
// closeable surface in the app), not blur-then-require-a-second-press.
|
|
4684
|
-
if (e.key === 'Escape' && t.classList && t.classList.contains('agentchat-cwd-input')) {
|
|
4685
|
-
t.blur();
|
|
4686
|
-
// Fall through to the ladder below instead of returning early.
|
|
4687
|
-
} else {
|
|
4688
|
-
if (e.key === 'Escape') t.blur();
|
|
4689
|
-
return;
|
|
4690
|
-
}
|
|
4691
|
-
}
|
|
4692
|
-
if (e.key === 'Escape') {
|
|
4693
|
-
// Priority ladder for transient state (modals/drawers are kit-handled):
|
|
4694
|
-
// shortcuts overlay > armed confirms > stop a streaming generation.
|
|
4695
|
-
if (state.showShortcuts) { state.showShortcuts = false; render(); announce('shortcuts closed'); return; }
|
|
4696
|
-
// File-mutation dialog: close on Escape wherever focus sits (the kit's
|
|
4697
|
-
// backdrop listener covers in-dialog focus; this covers everything else).
|
|
4698
|
-
if (state.files.dialog) { if (!state.files.dialog.busy) closeFileDialog(); return; }
|
|
4699
|
-
if (state.chat.confirmingEdit) { state.chat.confirmingEdit = null; render(); announce('edit cancelled'); return; }
|
|
4700
|
-
// cwd editor: the browse popover is a nested layer within it - Escape
|
|
4701
|
-
// closes the popover first (one level of the nesting) before falling
|
|
4702
|
-
// through to closing the whole editor on a second press, matching the
|
|
4703
|
-
// dialog-then-page Escape convention used elsewhere in the app.
|
|
4704
|
-
if (state.cwdEditing) {
|
|
4705
|
-
if (state.cwdBrowse) { state.cwdBrowse = null; render(); announce('folder browser closed'); return; }
|
|
4706
|
-
state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false;
|
|
4707
|
-
render(); announce('cwd edit cancelled');
|
|
4708
|
-
requestAnimationFrame(() => { const btn = document.querySelector('.agentchat-cwd-btn'); if (btn) btn.focus(); });
|
|
4709
|
-
return;
|
|
4710
|
-
}
|
|
4711
|
-
if (state.confirmingClearData) { state.confirmingClearData = false; render(); announce('clear cancelled'); return; }
|
|
4712
|
-
if (state.confirmingNewChat) { clearTimeout(_newChatArmTimer); state.confirmingNewChat = false; render(); announce('new chat cancelled'); return; }
|
|
4713
|
-
if (state.live.confirmingStopAll || state.live.confirmingStopSelected) {
|
|
4714
|
-
state.live.confirmingStopAll = false; state.live.confirmingStopSelected = false;
|
|
4715
|
-
clearTimeout(_stopAllArmTimer); clearTimeout(_stopSelArmTimer);
|
|
4716
|
-
render(); announce('stop cancelled'); return;
|
|
4717
|
-
}
|
|
4718
|
-
// A live file multi-select is transient state too: Escape drops it before
|
|
4719
|
-
// falling through to stop-generation.
|
|
4720
|
-
if (state.tab === 'files' && filesMarked().size) { clearFileSelection(); return; }
|
|
4721
|
-
if (state.chat.busy && state.tab === 'chat') { cancelChat(); announce('generation stopped'); return; }
|
|
4722
|
-
return;
|
|
4723
|
-
}
|
|
4724
|
-
if (gPending) {
|
|
4725
|
-
gPending = false;
|
|
4726
|
-
if (e.key === 'c') { navTo('chat'); return; }
|
|
4727
|
-
if (e.key === 'h') { navTo('history'); return; }
|
|
4728
|
-
if (e.key === 'f') { navTo('files'); return; }
|
|
4729
|
-
if (e.key === 'l') { navTo('live'); return; }
|
|
4730
|
-
if (e.key === 's') { navTo('settings'); return; }
|
|
4731
|
-
return;
|
|
4732
|
-
}
|
|
4733
|
-
if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
|
|
4734
|
-
if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
|
|
4735
|
-
if (e.key === '/') {
|
|
4736
|
-
// / targets the active surface's find affordance: search on history,
|
|
4737
|
-
// composer on chat, the filter inputs on files/live. Settings has no
|
|
4738
|
-
// field - the only documented no-op.
|
|
4739
|
-
if (state.tab === 'history') { e.preventDefault(); focusSearch(); announce('search focused'); }
|
|
4740
|
-
else if (state.tab === 'chat') { e.preventDefault(); focusComposer(); announce('composer focused'); }
|
|
4741
|
-
else if (state.tab === 'files' || state.tab === 'live') { e.preventDefault(); if (focusFilter()) announce('filter focused'); }
|
|
4742
|
-
return;
|
|
4743
|
-
}
|
|
4744
|
-
if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
|
|
4745
|
-
// Left/Right: step through file previews (documented in SHORTCUTS).
|
|
4746
|
-
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && state.tab === 'files' && state.files.preview) {
|
|
4747
|
-
const { prev, next } = previewNeighbours();
|
|
4748
|
-
const target = e.key === 'ArrowLeft' ? prev : next;
|
|
4749
|
-
if (target) { e.preventDefault(); openPreview(target); }
|
|
4750
|
-
return;
|
|
4751
|
-
}
|
|
4752
|
-
});
|
|
4753
|
-
|
|
4754
|
-
// A file dropped anywhere outside a DropZone must never navigate the browser
|
|
4755
|
-
// away (destroying the live session view). DropZones handle their own events.
|
|
4756
|
-
window.addEventListener('dragover', (e) => {
|
|
4757
|
-
if (!(e.target instanceof Element) || !e.target.closest('.ds-dropzone')) e.preventDefault();
|
|
4604
|
+
// Keyboard shortcuts - extracted to shortcuts.js (vertical slice per
|
|
4605
|
+
// AGENTS.md SOLID/Clean-Architecture preferences). The factory takes every
|
|
4606
|
+
// app.js dependency explicitly (state, render, and the helpers object below)
|
|
4607
|
+
// rather than closing over app.js's module scope.
|
|
4608
|
+
const { focusComposer } = installShortcuts(state, render, {
|
|
4609
|
+
navTo, announce, closeFileDialog, filesMarked, clearFileSelection,
|
|
4610
|
+
cancelChat, newChat, previewNeighbours, openPreview,
|
|
4611
|
+
clearNewChatArmTimer: () => clearTimeout(_newChatArmTimer),
|
|
4612
|
+
clearStopAllArmTimer: () => clearTimeout(_stopAllArmTimer),
|
|
4613
|
+
clearStopSelArmTimer: () => clearTimeout(_stopSelArmTimer),
|
|
4758
4614
|
});
|
|
4759
4615
|
window.addEventListener('drop', (e) => {
|
|
4760
4616
|
if (!(e.target instanceof Element) || !e.target.closest('.ds-dropzone')) e.preventDefault();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const CHAT_KEY = 'agentgui.chat';
|
|
1
|
+
export const CHAT_KEY = 'agentgui.chat';
|
|
2
2
|
// Cap the persisted footprint: the last 100 messages, with any tool args/result
|
|
3
3
|
// payload over 4KB truncated in the SAVED copy only (in-memory state keeps the
|
|
4
4
|
// full payload). A quota failure is surfaced once instead of silently dropping
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Hash-based deep-link state, extracted from app.js (vertical slice per
|
|
2
|
+
// AGENTS.md SOLID/Clean-Architecture preferences). Full hash routing state is
|
|
3
|
+
// HASH_KEYS; popstate diffs the full set, and navTo(tab,{writeHash:false})
|
|
4
|
+
// navigates without pushing a new hash entry (navTo itself stays in app.js -
|
|
5
|
+
// it orchestrates tab-switch side effects like reloading history/files/live
|
|
6
|
+
// data, not just the hash string, so it is a broader concern than routing).
|
|
7
|
+
export const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section', 'filter', 'lsort', 'lfilter', 'lerr', 'ets'];
|
|
8
|
+
|
|
9
|
+
export function readHash() {
|
|
10
|
+
const hash = location.hash || '';
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const k of HASH_KEYS) {
|
|
13
|
+
const m = hash.match(new RegExp('(?:^#|&)' + k + '=([^&]*)'));
|
|
14
|
+
out[k] = m ? decodeURIComponent(m[1]) : null;
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildHash(state) {
|
|
20
|
+
const parts = [];
|
|
21
|
+
const tab = state.tab || 'chat';
|
|
22
|
+
// tab is omitted for the default chat tab, EXCEPT when a session id rides
|
|
23
|
+
// along - a bare #sid= historically meant history, so chat+sid must name its
|
|
24
|
+
// tab explicitly or the deep-link restores the wrong surface.
|
|
25
|
+
if (tab !== 'chat') parts.push('tab=' + encodeURIComponent(tab));
|
|
26
|
+
else if (state.selectedSid) parts.push('tab=chat');
|
|
27
|
+
// Keep sid whenever set (regardless of tab) so Back restores the selection.
|
|
28
|
+
if (state.selectedSid) parts.push('sid=' + encodeURIComponent(state.selectedSid));
|
|
29
|
+
if (tab === 'files' && state.files) {
|
|
30
|
+
if (state.files.path) parts.push('dir=' + encodeURIComponent(state.files.path));
|
|
31
|
+
if (state.files.preview && state.files.preview.path) parts.push('file=' + encodeURIComponent(state.files.preview.path));
|
|
32
|
+
if (state.files.filter) parts.push('filter=' + encodeURIComponent(state.files.filter));
|
|
33
|
+
}
|
|
34
|
+
if (tab === 'history') {
|
|
35
|
+
const q = (state.searchQ || '').trim();
|
|
36
|
+
if (q.length >= 2) parts.push('q=' + encodeURIComponent(q));
|
|
37
|
+
if (state.projectFilter) parts.push('project=' + encodeURIComponent(state.projectFilter));
|
|
38
|
+
// A session opened from a search hit carries the matched event's
|
|
39
|
+
// timestamp so reload/Back reproduces the same scrolled+flashed position
|
|
40
|
+
// a live click gives - without this, the anchor only existed in memory
|
|
41
|
+
// and a search-hit URL degraded to just the bare session on reload.
|
|
42
|
+
if (state._focusEventTs != null) parts.push('ets=' + encodeURIComponent(state._focusEventTs));
|
|
43
|
+
}
|
|
44
|
+
if (tab === 'settings' && state.settingsSection) parts.push('section=' + encodeURIComponent(state.settingsSection));
|
|
45
|
+
if (tab === 'live') {
|
|
46
|
+
const lv = state.live || {};
|
|
47
|
+
if (lv.sort && lv.sort !== 'status') parts.push('lsort=' + encodeURIComponent(lv.sort));
|
|
48
|
+
if (lv.filter) parts.push('lfilter=' + encodeURIComponent(lv.filter));
|
|
49
|
+
if (lv.errorsOnly) parts.push('lerr=1');
|
|
50
|
+
}
|
|
51
|
+
return parts.length ? '#' + parts.join('&') : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function writeHash(state, { push = false } = {}) {
|
|
55
|
+
const h = buildHash(state);
|
|
56
|
+
const url = location.pathname + location.search + h;
|
|
57
|
+
if (location.hash === h || (!location.hash && !h)) return;
|
|
58
|
+
// pushState for user navigation steps (tab visits, session selection,
|
|
59
|
+
// directory walks, preview opens) so Back retraces them; replaceState for
|
|
60
|
+
// passive state sync (search text, filter resets).
|
|
61
|
+
(push ? history.pushState : history.replaceState).call(history, null, '', url);
|
|
62
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Global keyboard shortcut handling, extracted from app.js (vertical slice
|
|
2
|
+
// per AGENTS.md SOLID/Clean-Architecture preferences). This is the highest
|
|
3
|
+
// cross-cutting-coupling code in app.js (AGENTS.md flags it as historically
|
|
4
|
+
// crash-prone under webjsx keying mistakes) - the factory takes every
|
|
5
|
+
// dependency explicitly rather than closing over app.js's module scope, so
|
|
6
|
+
// the coupling is visible at the call site instead of implicit.
|
|
7
|
+
export function installShortcuts(state, render, helpers) {
|
|
8
|
+
const {
|
|
9
|
+
navTo, announce, closeFileDialog, filesMarked, clearFileSelection,
|
|
10
|
+
cancelChat, newChat, previewNeighbours, openPreview,
|
|
11
|
+
clearNewChatArmTimer, clearStopAllArmTimer, clearStopSelArmTimer,
|
|
12
|
+
} = helpers;
|
|
13
|
+
|
|
14
|
+
let gPending = false;
|
|
15
|
+
|
|
16
|
+
function focusComposer() {
|
|
17
|
+
const el = document.querySelector('#agentgui-main textarea, #agentgui-main [contenteditable="true"], #agentgui-main input[type="text"]');
|
|
18
|
+
el?.focus();
|
|
19
|
+
}
|
|
20
|
+
function focusSearch() {
|
|
21
|
+
const el = document.querySelector('#app input[type="search"]');
|
|
22
|
+
el?.focus();
|
|
23
|
+
}
|
|
24
|
+
// Focus the active surface's filter input (Files grid filter / Live dashboard
|
|
25
|
+
// filter) - both are search-type inputs inside the main region.
|
|
26
|
+
function focusFilter() {
|
|
27
|
+
const el = document.querySelector('#agentgui-main input[type="search"]')
|
|
28
|
+
|| document.querySelector('#agentgui-main .ds-file-filter-input')
|
|
29
|
+
|| document.querySelector('#agentgui-main input[type="text"]');
|
|
30
|
+
el?.focus();
|
|
31
|
+
return !!el;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
window.addEventListener('keydown', (e) => {
|
|
35
|
+
const t = e.target;
|
|
36
|
+
const typing = t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable);
|
|
37
|
+
// One explicit chord BEFORE the modifier early-return: Mod+Shift+L focuses
|
|
38
|
+
// the composer from anywhere, even while typing in another field.
|
|
39
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'L' || e.key === 'l')) {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
navTo('chat');
|
|
42
|
+
requestAnimationFrame(() => focusComposer());
|
|
43
|
+
announce('composer focused');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
47
|
+
if (typing) {
|
|
48
|
+
// The cwd editor's own text input is the one exception: Escape there must
|
|
49
|
+
// close the editor in a single press (matching every other Escape-
|
|
50
|
+
// closeable surface in the app), not blur-then-require-a-second-press.
|
|
51
|
+
if (e.key === 'Escape' && t.classList && t.classList.contains('agentchat-cwd-input')) {
|
|
52
|
+
t.blur();
|
|
53
|
+
// Fall through to the ladder below instead of returning early.
|
|
54
|
+
} else {
|
|
55
|
+
if (e.key === 'Escape') t.blur();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (e.key === 'Escape') {
|
|
60
|
+
// Priority ladder for transient state (modals/drawers are kit-handled):
|
|
61
|
+
// shortcuts overlay > armed confirms > stop a streaming generation.
|
|
62
|
+
if (state.showShortcuts) { state.showShortcuts = false; render(); announce('shortcuts closed'); return; }
|
|
63
|
+
// File-mutation dialog: close on Escape wherever focus sits (the kit's
|
|
64
|
+
// backdrop listener covers in-dialog focus; this covers everything else).
|
|
65
|
+
if (state.files.dialog) { if (!state.files.dialog.busy) closeFileDialog(); return; }
|
|
66
|
+
if (state.chat.confirmingEdit) { state.chat.confirmingEdit = null; render(); announce('edit cancelled'); return; }
|
|
67
|
+
// cwd editor: the browse popover is a nested layer within it - Escape
|
|
68
|
+
// closes the popover first (one level of the nesting) before falling
|
|
69
|
+
// through to closing the whole editor on a second press, matching the
|
|
70
|
+
// dialog-then-page Escape convention used elsewhere in the app.
|
|
71
|
+
if (state.cwdEditing) {
|
|
72
|
+
if (state.cwdBrowse) { state.cwdBrowse = null; render(); announce('folder browser closed'); return; }
|
|
73
|
+
state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false;
|
|
74
|
+
render(); announce('cwd edit cancelled');
|
|
75
|
+
requestAnimationFrame(() => { const btn = document.querySelector('.agentchat-cwd-btn'); if (btn) btn.focus(); });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (state.confirmingClearData) { state.confirmingClearData = false; render(); announce('clear cancelled'); return; }
|
|
79
|
+
if (state.confirmingNewChat) { clearNewChatArmTimer(); state.confirmingNewChat = false; render(); announce('new chat cancelled'); return; }
|
|
80
|
+
if (state.live.confirmingStopAll || state.live.confirmingStopSelected) {
|
|
81
|
+
state.live.confirmingStopAll = false; state.live.confirmingStopSelected = false;
|
|
82
|
+
clearStopAllArmTimer(); clearStopSelArmTimer();
|
|
83
|
+
render(); announce('stop cancelled'); return;
|
|
84
|
+
}
|
|
85
|
+
// A live file multi-select is transient state too: Escape drops it before
|
|
86
|
+
// falling through to stop-generation.
|
|
87
|
+
if (state.tab === 'files' && filesMarked().size) { clearFileSelection(); return; }
|
|
88
|
+
if (state.chat.busy && state.tab === 'chat') { cancelChat(); announce('generation stopped'); return; }
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (gPending) {
|
|
92
|
+
gPending = false;
|
|
93
|
+
if (e.key === 'c') { navTo('chat'); return; }
|
|
94
|
+
if (e.key === 'h') { navTo('history'); return; }
|
|
95
|
+
if (e.key === 'f') { navTo('files'); return; }
|
|
96
|
+
if (e.key === 'l') { navTo('live'); return; }
|
|
97
|
+
if (e.key === 's') { navTo('settings'); return; }
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
|
|
101
|
+
if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
|
|
102
|
+
if (e.key === '/') {
|
|
103
|
+
// / targets the active surface's find affordance: search on history,
|
|
104
|
+
// composer on chat, the filter inputs on files/live. Settings has no
|
|
105
|
+
// field - the only documented no-op.
|
|
106
|
+
if (state.tab === 'history') { e.preventDefault(); focusSearch(); announce('search focused'); }
|
|
107
|
+
else if (state.tab === 'chat') { e.preventDefault(); focusComposer(); announce('composer focused'); }
|
|
108
|
+
else if (state.tab === 'files' || state.tab === 'live') { e.preventDefault(); if (focusFilter()) announce('filter focused'); }
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
|
|
112
|
+
// Left/Right: step through file previews (documented in SHORTCUTS).
|
|
113
|
+
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && state.tab === 'files' && state.files.preview) {
|
|
114
|
+
const { prev, next } = previewNeighbours();
|
|
115
|
+
const target = e.key === 'ArrowLeft' ? prev : next;
|
|
116
|
+
if (target) { e.preventDefault(); openPreview(target); }
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// A file dropped anywhere outside a DropZone must never navigate the browser
|
|
122
|
+
// away (destroying the live session view). DropZones handle their own events.
|
|
123
|
+
window.addEventListener('dragover', (e) => {
|
|
124
|
+
if (!(e.target instanceof Element) || !e.target.closest('.ds-dropzone')) e.preventDefault();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return { focusComposer, focusSearch, focusFilter };
|
|
128
|
+
}
|