agentgui 1.0.944 → 1.0.946
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/AGENTS.md +7 -5
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/site/app/index.html +34 -9
- package/site/app/js/app.js +265 -166
package/AGENTS.md
CHANGED
|
@@ -6,9 +6,11 @@ One surface. `server.js` serves `site/app/` under `BASE_URL` (default `/gm`) and
|
|
|
6
6
|
|
|
7
7
|
When `PASSWORD` env var is set, every HTTP route is gated by `lib/http-handler.js` accepting **Basic auth**, **`Authorization: Bearer <pwd>`**, OR **`?token=<pwd>`** query param (added 2026-05-26 so `EventSource` and direct deep-links work — neither can set headers). WS `/sync` requires `?token=` only. The HTML head script injects `window.__BASE_URL`, `window.__SERVER_VERSION`, and `window.__WS_TOKEN`; `site/app/js/backend.js` reads `__WS_TOKEN` and threads it onto every fetch (Bearer header) / EventSource (qs) / WebSocket (qs).
|
|
8
8
|
|
|
9
|
-
- `site/app/index.html` — shell + CSS
|
|
10
|
-
- `site/app/js/backend.js` — same-origin client (`DEFAULT_BACKEND = ''`); `?backend=` query override for cross-origin debugging
|
|
11
|
-
- `site/app/js/app.js` — webjsx view + state
|
|
9
|
+
- `site/app/index.html` — shell + CSS; imports `anentrypoint-design` from `https://unpkg.com/anentrypoint-design@latest` at runtime (always-latest; requires network — the prior offline guarantee is intentionally dropped)
|
|
10
|
+
- `site/app/js/backend.js` — same-origin WS/HTTP client (`DEFAULT_BACKEND = ''`); the transport glue that wires the kit; `?backend=` query override for cross-origin debugging
|
|
11
|
+
- `site/app/js/app.js` — webjsx view + state; renders the `AgentChat` kit (from `anentrypoint-design`) for the chat surface and wires agentgui's WS/ccsniff state as kit callbacks; history/settings remain agentgui-local. Exposes `window.__agentgui`
|
|
12
|
+
|
|
13
|
+
The chat GUI lives in the design kit, not in agentgui. The reusable multi-agent chat surface is the `AgentChat` component in `anentrypoint-design` (`src/components/agent-chat.js`); agentgui keeps only the transport glue (WS `backend.js`, ccsniff history wiring, agent orchestration) and passes state + callbacks into the kit. To change chat UI, edit the kit and push it (CI publishes to npm → unpkg `@latest`), not agentgui.
|
|
12
14
|
- `server.js` — boots ACP/agents/websocket plugins, mounts `createHistoryRouter()` from `ccsniff` at `/`, serves `site/app/` as static root
|
|
13
15
|
- Plugins kept (lib/plugins/): acp, agents, database, files, stream, websocket, workflow
|
|
14
16
|
|
|
@@ -145,9 +147,9 @@ Throttle renders via `requestAnimationFrame` to avoid event storm during burst l
|
|
|
145
147
|
|
|
146
148
|
The endpoints are served by ccsniff's Express router mounted in-process from `server.js`. No external proxy.
|
|
147
149
|
|
|
148
|
-
##
|
|
150
|
+
## Runtime CDN: GUI loaded from unpkg (2026-05-29)
|
|
149
151
|
|
|
150
|
-
The GUI
|
|
152
|
+
The GUI loads `anentrypoint-design` from `https://unpkg.com/anentrypoint-design@latest` at runtime (import map + CSS link in `index.html`) so agentgui always tracks the latest published kit with no hand-maintained vendored copy. This **requires a network connection** — the earlier offline guarantee was intentionally dropped in favour of always-latest. The DS itself fetches its fonts from `unpkg.com/anentrypoint-design@latest/dist/fonts/` (set in the kit's `scripts/build.mjs` `FONT_BASE`). The `site/app/vendor/` tree is no longer imported; the old vendored DS bundle + `vendor/cdn/` assets are dead and may be removed. Witness the live GUI by confirming the import map resolves to unpkg and `.agentchat` renders with 0 console errors.
|
|
151
153
|
|
|
152
154
|
## Agent/model/session management (2026-05-28)
|
|
153
155
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [Unreleased] - GUI moved into the anentrypoint-design kit, loaded from unpkg
|
|
2
|
+
|
|
3
|
+
- the chat GUI now lives in the design kit: added a reusable `AgentChat` component to anentrypoint-design (agent-then-model picker + AICat auto-scroll/thinking + tool parts + cwd bar + caret-stable composer), pure props-in/vnode-out with all transport as host callbacks. Published via the kit's CI to npm -> unpkg.
|
|
4
|
+
- site/app/index.html: imports anentrypoint-design from https://unpkg.com/anentrypoint-design@latest at runtime (always-latest), replacing the vendored bundle. Trades the offline guarantee for always-latest, per project decision.
|
|
5
|
+
- site/app/js/app.js: chatMain renders the AgentChat kit and wires agentgui state + WS/ccsniff glue as callbacks; the agent/model picker, cwd bar, and composer moved out of agentgui into the kit. backend.js (WS client) stays as the transport glue.
|
|
6
|
+
- verified end-to-end in a real browser: GUI sourced from unpkg, AgentChat renders, chat round-trips (reply streams into the kit's transcript), 0 console errors.
|
|
7
|
+
|
|
1
8
|
## [Unreleased] - live e2e chat verified + composer caret fix + ccsniff memory cap
|
|
2
9
|
|
|
3
10
|
- verified the full GUI chat round-trip end-to-end in a real browser: compose -> WS chat.sendMessage -> claude-code -> streaming_progress -> transcript renders the streamed reply (not just the runner harness).
|
package/package.json
CHANGED
package/site/app/index.html
CHANGED
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
6
|
<title>agentgui</title>
|
|
7
7
|
<meta name="description" content="agentgui — multi-agent client with same-origin server, in-process ccsniff history, and ACP chat.">
|
|
8
|
-
|
|
8
|
+
<!-- The GUI lives in the anentrypoint-design kit and is loaded from unpkg at
|
|
9
|
+
its latest published version, so agentgui always tracks the kit without a
|
|
10
|
+
hand-maintained vendored copy. (This trades the prior offline guarantee for
|
|
11
|
+
always-latest, per project decision; a network connection is required.) -->
|
|
12
|
+
<link rel="stylesheet" href="https://unpkg.com/anentrypoint-design@latest/dist/247420.css">
|
|
9
13
|
<script type="importmap">
|
|
10
|
-
{ "imports": { "anentrypoint-design": "
|
|
14
|
+
{ "imports": { "anentrypoint-design": "https://unpkg.com/anentrypoint-design@latest/dist/247420.js" } }
|
|
11
15
|
</script>
|
|
12
16
|
<style>
|
|
13
17
|
:root {
|
|
@@ -15,6 +19,18 @@
|
|
|
15
19
|
--agentgui-fg: #e6e6ea;
|
|
16
20
|
--agentgui-accent: #50c878;
|
|
17
21
|
--agentgui-warn: #ff6b6b;
|
|
22
|
+
/* status-dot state colours, brightened for >=3:1 contrast on the dark bg */
|
|
23
|
+
--agentgui-status-connecting: #f0b84a;
|
|
24
|
+
--agentgui-status-offline: #e06b66;
|
|
25
|
+
/* unified muted text fallback (was an inconsistent mix of #888/#999) */
|
|
26
|
+
--agentgui-muted: #8a8f98;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* visually-hidden but screen-reader-available (aria-live announcer, labels) */
|
|
30
|
+
.sr-only {
|
|
31
|
+
position: absolute !important; width: 1px; height: 1px;
|
|
32
|
+
padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0);
|
|
33
|
+
white-space: nowrap; border: 0;
|
|
18
34
|
}
|
|
19
35
|
html, body {
|
|
20
36
|
margin: 0;
|
|
@@ -76,8 +92,8 @@
|
|
|
76
92
|
background: var(--accent, var(--agentgui-accent, #50c878));
|
|
77
93
|
animation: agentgui-pulse 2s infinite;
|
|
78
94
|
}
|
|
79
|
-
.status-dot.is-connecting .status-dot-disc { background:
|
|
80
|
-
.status-dot.is-offline .status-dot-disc { background:
|
|
95
|
+
.status-dot.is-connecting .status-dot-disc { background: var(--agentgui-status-connecting); }
|
|
96
|
+
.status-dot.is-offline .status-dot-disc { background: var(--agentgui-status-offline); animation: none; }
|
|
81
97
|
@keyframes agentgui-pulse {
|
|
82
98
|
0% { box-shadow: 0 0 0 0 rgba(80, 200, 120, .5); }
|
|
83
99
|
70% { box-shadow: 0 0 0 6px rgba(80, 200, 120, 0); }
|
|
@@ -155,7 +171,7 @@
|
|
|
155
171
|
|
|
156
172
|
/* empty-state: keep it from wrapping awkwardly in the narrow sidebar */
|
|
157
173
|
.empty-state { white-space: normal; }
|
|
158
|
-
p.empty-state { text-align: left; padding: .6em 0; color: var(--fg-3,
|
|
174
|
+
p.empty-state { text-align: left; padding: .6em 0; color: var(--fg-3, var(--agentgui-muted)); }
|
|
159
175
|
|
|
160
176
|
/* generic interactive focus ring */
|
|
161
177
|
button:focus-visible, [tabindex]:focus-visible, a:focus-visible {
|
|
@@ -194,7 +210,7 @@
|
|
|
194
210
|
}
|
|
195
211
|
.cwd-bar-text {
|
|
196
212
|
font-family: var(--ff-mono, ui-monospace, monospace);
|
|
197
|
-
color: var(--fg-3,
|
|
213
|
+
color: var(--fg-3, var(--agentgui-muted)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60vw;
|
|
198
214
|
}
|
|
199
215
|
.cwd-bar-btn {
|
|
200
216
|
cursor: pointer; font: inherit; line-height: 1.3;
|
|
@@ -209,9 +225,8 @@
|
|
|
209
225
|
/* History no-session empty state: fill the void with a centered prompt. */
|
|
210
226
|
.history-empty {
|
|
211
227
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
212
|
-
gap: .4em; text-align: center; min-height: 50vh; color: var(--fg-3,
|
|
228
|
+
gap: .4em; text-align: center; min-height: 50vh; color: var(--fg-3, var(--agentgui-muted));
|
|
213
229
|
}
|
|
214
|
-
.history-empty-glyph { font-size: 3rem; opacity: .25; line-height: 1; }
|
|
215
230
|
.history-empty-title { margin: 0; font-size: 1.05rem; color: var(--fg-2, var(--agentgui-fg)); }
|
|
216
231
|
.history-empty-sub { margin: 0; max-width: 42ch; }
|
|
217
232
|
|
|
@@ -248,10 +263,21 @@
|
|
|
248
263
|
.chat-composer textarea { overflow-y: auto; scrollbar-width: thin; }
|
|
249
264
|
.chat-composer textarea:not(:focus) { overflow-y: hidden; }
|
|
250
265
|
|
|
266
|
+
/* tablet: keep the chat control cluster from wrapping the status dot alone */
|
|
267
|
+
@media (max-width: 900px) {
|
|
268
|
+
.chat-controls select,
|
|
269
|
+
.chat-controls .select,
|
|
270
|
+
.chat-controls [role="combobox"] { min-width: 110px; max-width: 170px; }
|
|
271
|
+
}
|
|
272
|
+
|
|
251
273
|
/* touch targets on small screens */
|
|
252
274
|
@media (max-width: 640px) {
|
|
253
275
|
.pill { min-height: 36px; padding: .4em .8em; }
|
|
276
|
+
.history-actions { gap: .75em; }
|
|
254
277
|
.history-actions .btn, .history-actions button { min-height: 44px; }
|
|
278
|
+
/* cwd path can't eat the whole row beside its label + buttons on a phone */
|
|
279
|
+
.cwd-bar-text { max-width: 42vw; }
|
|
280
|
+
.cwd-bar { gap: .3em; }
|
|
255
281
|
}
|
|
256
282
|
|
|
257
283
|
@media print {
|
|
@@ -266,7 +292,6 @@
|
|
|
266
292
|
</style>
|
|
267
293
|
</head>
|
|
268
294
|
<body>
|
|
269
|
-
<a href="#agentgui-main" class="skip-link">Skip to main content</a>
|
|
270
295
|
<div id="app"></div>
|
|
271
296
|
<script type="module" src="./js/app.js"></script>
|
|
272
297
|
</body>
|
package/site/app/js/app.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as B from './backend.js';
|
|
|
3
3
|
|
|
4
4
|
installStyles().catch(() => {});
|
|
5
5
|
|
|
6
|
-
const { AppShell, Topbar, Crumb, Side, Status, Chat, ChatComposer, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, EventList, Spinner, Alert } = C;
|
|
6
|
+
const { AppShell, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, EventList, Spinner, Alert } = C;
|
|
7
7
|
|
|
8
8
|
const state = {
|
|
9
9
|
backend: B.getBackend(),
|
|
@@ -85,6 +85,24 @@ function debounce(fn, ms) {
|
|
|
85
85
|
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
|
|
86
86
|
function lsRemove(k) { try { localStorage.removeItem(k); } catch {} }
|
|
87
87
|
|
|
88
|
+
// A single visually-hidden aria-live region for transient announcements (tab
|
|
89
|
+
// changes, etc.) so screen-reader users hear context that's otherwise conveyed
|
|
90
|
+
// only by focus movement or color.
|
|
91
|
+
let _announcer = null;
|
|
92
|
+
function announce(msg) {
|
|
93
|
+
if (typeof document === 'undefined') return;
|
|
94
|
+
if (!_announcer) {
|
|
95
|
+
_announcer = document.createElement('div');
|
|
96
|
+
_announcer.setAttribute('aria-live', 'polite');
|
|
97
|
+
_announcer.setAttribute('role', 'status');
|
|
98
|
+
_announcer.className = 'sr-only';
|
|
99
|
+
document.body.appendChild(_announcer);
|
|
100
|
+
}
|
|
101
|
+
// Clear then set on the next frame so repeated identical messages re-announce.
|
|
102
|
+
_announcer.textContent = '';
|
|
103
|
+
requestAnimationFrame(() => { if (_announcer) _announcer.textContent = msg; });
|
|
104
|
+
}
|
|
105
|
+
|
|
88
106
|
function pillButton(key, label, active, title, onClick) {
|
|
89
107
|
return h('button', {
|
|
90
108
|
key,
|
|
@@ -113,15 +131,22 @@ function timeNow() {
|
|
|
113
131
|
}
|
|
114
132
|
|
|
115
133
|
async function selectAgent(id) {
|
|
134
|
+
// Re-selecting the same agent would needlessly refetch models and reset the
|
|
135
|
+
// current model selection — no-op early.
|
|
136
|
+
if (id === state.selectedAgent && state.agentModels.length) return;
|
|
116
137
|
state.selectedAgent = id;
|
|
117
138
|
localStorage.setItem('agentgui.agent', id);
|
|
118
139
|
state.agentModels = [];
|
|
119
140
|
state.selectedModel = '';
|
|
141
|
+
state.modelsLoading = true;
|
|
120
142
|
render();
|
|
121
143
|
const models = await B.listAgentModels(state.backend, id);
|
|
122
144
|
if (state.selectedAgent !== id) return; // changed while loading
|
|
145
|
+
state.modelsLoading = false;
|
|
123
146
|
state.agentModels = models;
|
|
124
147
|
const saved = localStorage.getItem('agentgui.model');
|
|
148
|
+
// Only restore a saved model that the NEW agent actually offers; otherwise
|
|
149
|
+
// fall back to its first model (never carry a stale model from a prior agent).
|
|
125
150
|
state.selectedModel = (saved && models.some(m => m.id === saved)) ? saved : (models[0]?.id || '');
|
|
126
151
|
render();
|
|
127
152
|
}
|
|
@@ -135,8 +160,32 @@ function selectModel(id) {
|
|
|
135
160
|
function agentById(id) { return state.agents.find(a => a.id === id); }
|
|
136
161
|
function agentAvailable(id) { const a = agentById(id); return !a || a.available !== false; }
|
|
137
162
|
|
|
163
|
+
// The four flagship orchestration targets surface first, then other available
|
|
164
|
+
// agents, then npx-installable, then not-installed — so the agents the GUI
|
|
165
|
+
// exists to drive are reachable without scanning a flat 17-item list. This
|
|
166
|
+
// ordering is agentgui's orchestration priority, so it stays in the host and is
|
|
167
|
+
// passed pre-sorted to the (app-agnostic) AgentChat kit.
|
|
168
|
+
const PRIMARY_AGENTS = ['claude-code', 'opencode', 'kilo', 'agy'];
|
|
169
|
+
function sortedAgents() {
|
|
170
|
+
const rank = (a) => {
|
|
171
|
+
const primary = PRIMARY_AGENTS.indexOf(a.id);
|
|
172
|
+
const avail = a.available !== false;
|
|
173
|
+
if (primary !== -1 && avail) return primary;
|
|
174
|
+
if (avail) return 10;
|
|
175
|
+
if (a.npxInstallable) return 20;
|
|
176
|
+
return 30;
|
|
177
|
+
};
|
|
178
|
+
return state.agents
|
|
179
|
+
.map(a => ({ a, rank: rank(a) }))
|
|
180
|
+
.sort((x, y) => x.rank - y.rank || x.a.name.localeCompare(y.a.name))
|
|
181
|
+
.map(({ a }) => a);
|
|
182
|
+
}
|
|
183
|
+
|
|
138
184
|
function navTo(tab) {
|
|
139
185
|
const prev = state.tab;
|
|
186
|
+
// Leaving chat clears any pending new-chat confirmation so it doesn't linger
|
|
187
|
+
// as a stale banner when the user returns.
|
|
188
|
+
if (prev === 'chat' && tab !== 'chat') state.confirmingNewChat = false;
|
|
140
189
|
state.tab = tab;
|
|
141
190
|
// Live history SSE is only needed on the history tab; active-chat polling
|
|
142
191
|
// runs globally (started at boot) so running chats show on every tab.
|
|
@@ -147,6 +196,7 @@ function navTo(tab) {
|
|
|
147
196
|
closeLiveStream();
|
|
148
197
|
}
|
|
149
198
|
writeHash(state.selectedSid && tab === 'history' ? state.selectedSid : null);
|
|
199
|
+
announce('Now on ' + tab + ' tab');
|
|
150
200
|
render();
|
|
151
201
|
// Move focus into the new region for keyboard/AT users.
|
|
152
202
|
requestAnimationFrame(() => {
|
|
@@ -166,7 +216,7 @@ function navTo(tab) {
|
|
|
166
216
|
});
|
|
167
217
|
}
|
|
168
218
|
|
|
169
|
-
// The DS Topbar derives aria-current from href
|
|
219
|
+
// The DS Topbar derives aria-current from href<->location.hash matching, which
|
|
170
220
|
// drifts from our hash-based active tab (e.g. aria-current lands on "settings"
|
|
171
221
|
// while we're on "chat"). Re-assert aria-current on the actually-active tab.
|
|
172
222
|
function syncAriaCurrent() {
|
|
@@ -287,61 +337,26 @@ function view() {
|
|
|
287
337
|
onNav: (label) => navTo(label),
|
|
288
338
|
});
|
|
289
339
|
|
|
290
|
-
// The
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
// the
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
value: a.id,
|
|
308
|
-
label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
|
|
309
|
-
disabled: a.available === false && !a.npxInstallable,
|
|
310
|
-
}));
|
|
311
|
-
const showModelPicker = state.tab === 'chat' && state.agentModels.length > 0;
|
|
312
|
-
|
|
313
|
-
const crumbRight = state.tab === 'chat'
|
|
314
|
-
? [h('div', { key: 'cc', class: 'chat-controls' },
|
|
315
|
-
Select({
|
|
316
|
-
key: 'agentsel',
|
|
317
|
-
value: state.selectedAgent,
|
|
318
|
-
placeholder: '— agent —',
|
|
319
|
-
title: 'Select coding agent',
|
|
320
|
-
options: agentOptions,
|
|
321
|
-
onChange: (v) => { selectAgent(v); },
|
|
322
|
-
}),
|
|
323
|
-
showModelPicker
|
|
324
|
-
? Select({
|
|
325
|
-
key: 'modelsel',
|
|
326
|
-
value: state.selectedModel,
|
|
327
|
-
placeholder: '— model —',
|
|
328
|
-
title: 'Select model for this agent',
|
|
329
|
-
options: state.agentModels.map(m => ({ value: m.id, label: m.name || m.id })),
|
|
330
|
-
onChange: (v) => { selectModel(v); },
|
|
331
|
-
})
|
|
332
|
-
: null,
|
|
333
|
-
state.chat.busy
|
|
334
|
-
? Btn({ key: 'stop', onClick: cancelChat, children: 'stop', title: 'Stop streaming' })
|
|
335
|
-
: Btn({ key: 'new', onClick: newChat, children: '+ new', title: 'Start new chat (clears history)' }),
|
|
336
|
-
dot,
|
|
337
|
-
)]
|
|
338
|
-
: [dot];
|
|
339
|
-
|
|
340
|
-
// Topbar already shows "agentgui / <tab>"; the crumb is reserved for contextual
|
|
341
|
-
// controls (model picker, new/stop, live status) so it doesn't duplicate the path.
|
|
340
|
+
// The crumb's right slot carries the live/connection status dot on every tab.
|
|
341
|
+
const crumbRight = [dot];
|
|
342
|
+
|
|
343
|
+
// Give the crumb contextual content on the left so it isn't a bare bar holding
|
|
344
|
+
// only the dot: on history it names the selected session (or "all sessions"),
|
|
345
|
+
// on chat it names the active agent, on settings it's "configuration".
|
|
346
|
+
let crumbLeaf = '';
|
|
347
|
+
if (state.tab === 'history') {
|
|
348
|
+
const sel = state.selectedSid && (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
349
|
+
crumbLeaf = state.selectedSid
|
|
350
|
+
? truncate(projectLabel(sel?.title) || projectLabel(sel?.project) || state.selectedSid, 24, 48)
|
|
351
|
+
: 'all sessions';
|
|
352
|
+
} else if (state.tab === 'chat') {
|
|
353
|
+
crumbLeaf = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : 'no agent';
|
|
354
|
+
} else if (state.tab === 'settings') {
|
|
355
|
+
crumbLeaf = 'configuration';
|
|
356
|
+
}
|
|
342
357
|
const crumb = Crumb({
|
|
343
358
|
trail: [],
|
|
344
|
-
leaf:
|
|
359
|
+
leaf: crumbLeaf,
|
|
345
360
|
right: crumbRight,
|
|
346
361
|
});
|
|
347
362
|
|
|
@@ -354,7 +369,7 @@ function view() {
|
|
|
354
369
|
: 'no agent';
|
|
355
370
|
const status = Status({
|
|
356
371
|
left: [state.backend || 'same-origin', ok ? 'live' : 'offline'],
|
|
357
|
-
right: [agentLabel],
|
|
372
|
+
right: [agentLabel, 'press ? for shortcuts'],
|
|
358
373
|
});
|
|
359
374
|
|
|
360
375
|
// The design system now owns the full-height column + inner scroll for
|
|
@@ -367,7 +382,10 @@ function view() {
|
|
|
367
382
|
children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
|
|
368
383
|
: null;
|
|
369
384
|
const main = h('div', { id: 'agentgui-main', role: 'main', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
|
|
370
|
-
|
|
385
|
+
// narrow drives the DS main-column class; the history sidebar itself collapses
|
|
386
|
+
// behind the DS .app-side-toggle hamburger (provided by AppShell when a side
|
|
387
|
+
// exists), so mobile users get a togglable session list.
|
|
388
|
+
return AppShell({ topbar, crumb, side, main, status, narrow: isNarrow() });
|
|
371
389
|
}
|
|
372
390
|
|
|
373
391
|
function mainContent() {
|
|
@@ -376,7 +394,7 @@ function mainContent() {
|
|
|
376
394
|
return settingsMain();
|
|
377
395
|
}
|
|
378
396
|
|
|
379
|
-
//
|
|
397
|
+
// --- chat ---
|
|
380
398
|
function canSend() {
|
|
381
399
|
return !!state.selectedAgent && agentAvailable(state.selectedAgent) && !state.chat.busy;
|
|
382
400
|
}
|
|
@@ -407,56 +425,21 @@ function errText(e) {
|
|
|
407
425
|
}
|
|
408
426
|
|
|
409
427
|
function chatMain() {
|
|
410
|
-
const lastIdx = state.chat.messages.length - 1;
|
|
411
428
|
const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
|
|
412
|
-
const msgs = state.chat.messages.map((m, i) => {
|
|
413
|
-
const isAssistant = m.role === 'assistant';
|
|
414
|
-
const isStreaming = state.chat.busy && i === lastIdx && isAssistant;
|
|
415
|
-
const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
|
|
416
|
-
const isEmptyStreaming = isStreaming && !m.content && !hasParts;
|
|
417
|
-
const parts = [];
|
|
418
|
-
if (m.content) parts.push({ kind: isAssistant ? 'md' : 'text', text: m.content });
|
|
419
|
-
if (hasParts) for (const p of m.parts) parts.push({ kind: 'text', text: p });
|
|
420
|
-
return {
|
|
421
|
-
key: m.id || String(i),
|
|
422
|
-
who: isAssistant ? 'them' : 'you',
|
|
423
|
-
name: isAssistant ? agentName : 'you',
|
|
424
|
-
time: m.time || '',
|
|
425
|
-
typing: isEmptyStreaming,
|
|
426
|
-
parts: isEmptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
|
|
427
|
-
};
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
const placeholder = !state.selectedAgent
|
|
431
|
-
? 'choose an agent first'
|
|
432
|
-
: (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
|
|
433
|
-
const composer = ChatComposer({
|
|
434
|
-
value: state.chat.draft,
|
|
435
|
-
disabled: !canSend(),
|
|
436
|
-
placeholder,
|
|
437
|
-
// The DS textarea is controlled and reads its live DOM value on send, so we
|
|
438
|
-
// do NOT re-render on every keystroke (that fights the cursor and is wasteful).
|
|
439
|
-
// Re-render only when the draft crosses empty<->non-empty, since that is the
|
|
440
|
-
// only transition that changes the send button's disabled state.
|
|
441
|
-
onInput: (v) => {
|
|
442
|
-
const was = !!(state.chat.draft && state.chat.draft.trim());
|
|
443
|
-
const now = !!(v && v.trim());
|
|
444
|
-
state.chat.draft = v;
|
|
445
|
-
if (was !== now) render();
|
|
446
|
-
},
|
|
447
|
-
onSend: (v) => { state.chat.draft = v; sendChat(); },
|
|
448
|
-
});
|
|
449
429
|
|
|
430
|
+
// Banners agentgui owns (resume, agent-switched, unavailable, confirm-clear,
|
|
431
|
+
// stream error) are pre-built here and handed to the kit, which renders them
|
|
432
|
+
// above the thread. The kit holds no agentgui-specific banner logic.
|
|
450
433
|
const banners = [];
|
|
451
434
|
if (state.chat.resumeSid) {
|
|
435
|
+
// The agent-switched note (if any) lives inside the resume banner rather
|
|
436
|
+
// than as a separate stacked Alert, so resume context reads as one block.
|
|
452
437
|
banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
|
|
453
|
-
h('span', { key: 'rbtxt', class: 'lede' },
|
|
438
|
+
h('span', { key: 'rbtxt', class: 'lede' },
|
|
439
|
+
'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'
|
|
440
|
+
+ (state.chat.resumeNote ? ' — ' + state.chat.resumeNote : '')),
|
|
454
441
|
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
|
|
455
|
-
if (state.chat.resumeNote) {
|
|
456
|
-
banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
|
|
457
|
-
}
|
|
458
442
|
}
|
|
459
|
-
banners.push(cwdBanner());
|
|
460
443
|
if (state.selectedAgent && !agentAvailable(state.selectedAgent)) {
|
|
461
444
|
banners.push(Alert({ key: 'unavail', kind: 'warn', title: agentName + ' is not installed',
|
|
462
445
|
children: 'This agent\'s CLI was not found on the server. Pick another agent or install it.' }));
|
|
@@ -468,26 +451,79 @@ function chatMain() {
|
|
|
468
451
|
Btn({ key: 'cnyes', danger: true, onClick: newChat, children: 'clear' }),
|
|
469
452
|
Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
|
|
470
453
|
}
|
|
471
|
-
|
|
472
|
-
|
|
454
|
+
if (state.cwdError) {
|
|
455
|
+
banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
|
|
456
|
+
}
|
|
457
|
+
const lastMsg = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1] : null;
|
|
458
|
+
const lastErr = lastMsg ? lastMsg.error : null;
|
|
473
459
|
if (lastErr && !state.chat.busy) {
|
|
474
|
-
banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error',
|
|
460
|
+
banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error',
|
|
461
|
+
children: [
|
|
462
|
+
h('span', { key: 'cetxt' }, lastErr + ' '),
|
|
463
|
+
Btn({ key: 'cedismiss', onClick: () => { if (lastMsg) { delete lastMsg.error; persistChat(); render(); } }, children: 'dismiss' })] }));
|
|
475
464
|
}
|
|
465
|
+
|
|
466
|
+
const placeholder = !state.selectedAgent
|
|
467
|
+
? 'choose an agent first'
|
|
468
|
+
: (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
|
|
469
|
+
|
|
470
|
+
// The reusable AgentChat kit owns the agent/model picker, cwd bar, transcript
|
|
471
|
+
// (with AICat-style auto-scroll + thinking), and the caret-stable composer.
|
|
472
|
+
// agentgui supplies state and wires every server interaction as a callback.
|
|
476
473
|
return [
|
|
477
474
|
offlineBanner(),
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
475
|
+
AgentChat({
|
|
476
|
+
agents: sortedAgents(),
|
|
477
|
+
selectedAgent: state.selectedAgent,
|
|
478
|
+
models: state.agentModels,
|
|
479
|
+
selectedModel: state.selectedModel,
|
|
480
|
+
modelsLoading: !!state.modelsLoading,
|
|
481
|
+
messages: state.chat.messages,
|
|
482
|
+
busy: state.chat.busy,
|
|
483
|
+
draft: state.chat.draft,
|
|
484
|
+
status: state.chat.busy
|
|
485
|
+
? (state.health.ws === 'reconnecting' ? 'reconnecting…' : 'streaming…')
|
|
486
|
+
: (state.modelsLoading ? 'loading models…' : (state.chat.resumeSid ? 'resume' : 'ready')),
|
|
487
|
+
agentName,
|
|
488
|
+
placeholder,
|
|
489
|
+
canSend: canSend(),
|
|
490
|
+
banners,
|
|
491
|
+
cwd: state.chatCwd,
|
|
492
|
+
cwdEditing: !!state.cwdEditing,
|
|
493
|
+
cwdDraft: state.cwdDraft,
|
|
494
|
+
onSelectAgent: (v) => selectAgent(v),
|
|
495
|
+
onSelectModel: (v) => selectModel(v),
|
|
496
|
+
onNewChat: newChat,
|
|
497
|
+
onStop: cancelChat,
|
|
498
|
+
onInput: (v) => {
|
|
499
|
+
// The kit's textarea is controlled and reads its live DOM value on send,
|
|
500
|
+
// so re-render only when the draft crosses empty<->non-empty (the only
|
|
501
|
+
// transition that toggles the send button's disabled state).
|
|
502
|
+
const was = !!(state.chat.draft && state.chat.draft.trim());
|
|
503
|
+
const now = !!(v && v.trim());
|
|
504
|
+
state.chat.draft = v;
|
|
505
|
+
if (was !== now) render();
|
|
506
|
+
},
|
|
507
|
+
onSend: (v) => { state.chat.draft = v; sendChat(); },
|
|
508
|
+
onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
|
|
509
|
+
onCwdSave: () => {
|
|
510
|
+
const path = (state.cwdDraft ?? '').trim();
|
|
511
|
+
// A relative cwd would resolve against the server process dir, not what
|
|
512
|
+
// the user means — require an absolute path (POSIX /…, UNC \\…, or
|
|
513
|
+
// Windows drive X:\…). Blank is valid (server default).
|
|
514
|
+
if (path && !/^([/\\]|[A-Za-z]:[/\\])/.test(path)) {
|
|
515
|
+
state.cwdError = 'enter an absolute path (e.g. /home/you/proj or C:\\proj) or leave blank';
|
|
516
|
+
render();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
state.cwdError = null;
|
|
520
|
+
state.chatCwd = path;
|
|
521
|
+
if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
|
|
522
|
+
state.cwdEditing = false; state.cwdDraft = undefined; render();
|
|
523
|
+
},
|
|
524
|
+
onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; render(); },
|
|
525
|
+
onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
|
|
526
|
+
onCwdDraft: (v) => { state.cwdDraft = v; },
|
|
491
527
|
}),
|
|
492
528
|
].filter(Boolean);
|
|
493
529
|
}
|
|
@@ -498,24 +534,8 @@ function offlineBanner() {
|
|
|
498
534
|
children: 'agentgui can\'t reach the server (' + (state.health.error || state.health.status) + '). Chat and history actions will fail until it reconnects.' });
|
|
499
535
|
}
|
|
500
536
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
return h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Set working directory' },
|
|
504
|
-
TextField({ key: 'cwdfield', label: 'working directory (blank = server default)', value: state.cwdDraft ?? state.chatCwd ?? '',
|
|
505
|
-
placeholder: 'absolute path', onInput: (v) => { state.cwdDraft = v; } }),
|
|
506
|
-
Btn({ key: 'cwdsave', primary: true, onClick: () => {
|
|
507
|
-
state.chatCwd = (state.cwdDraft ?? '').trim();
|
|
508
|
-
if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
|
|
509
|
-
state.cwdEditing = false; state.cwdDraft = undefined; render();
|
|
510
|
-
}, children: 'save' }),
|
|
511
|
-
Btn({ key: 'cwdcancel', onClick: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); }, children: 'cancel' }));
|
|
512
|
-
}
|
|
513
|
-
return h('div', { key: 'cwdb', class: 'cwd-bar', role: 'group', 'aria-label': 'Working directory' },
|
|
514
|
-
h('span', { key: 'cwdtxt', class: 'lede cwd-bar-text', title: state.chatCwd || 'server default working directory' },
|
|
515
|
-
state.chatCwd ? 'cwd: ' + truncate(state.chatCwd, 28, 60) : 'cwd: server default'),
|
|
516
|
-
h('button', { key: 'cwdset', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); } }, state.chatCwd ? 'change' : 'set'),
|
|
517
|
-
state.chatCwd ? h('button', { key: 'cwdclr', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); } }, 'use default') : null);
|
|
518
|
-
}
|
|
537
|
+
// (The working-directory bar now lives in the AgentChat kit; agentgui wires its
|
|
538
|
+
// cwd state + handlers as kit callbacks in chatMain.)
|
|
519
539
|
|
|
520
540
|
function newChat() {
|
|
521
541
|
if (state.chat.messages.length && !state.confirmingNewChat) {
|
|
@@ -529,7 +549,12 @@ function newChat() {
|
|
|
529
549
|
render();
|
|
530
550
|
}
|
|
531
551
|
|
|
532
|
-
function cancelChat() {
|
|
552
|
+
function cancelChat() {
|
|
553
|
+
// Clear busy immediately so the composer re-enables and the "stop" button
|
|
554
|
+
// flips back to "new" without waiting for the stream's finally block.
|
|
555
|
+
state.chat.abort?.abort();
|
|
556
|
+
if (state.chat.busy) { state.chat.busy = false; render(); }
|
|
557
|
+
}
|
|
533
558
|
|
|
534
559
|
const CHAT_KEY = 'agentgui.chat';
|
|
535
560
|
function persistChat() {
|
|
@@ -592,7 +617,7 @@ async function sendChat() {
|
|
|
592
617
|
}
|
|
593
618
|
}
|
|
594
619
|
|
|
595
|
-
//
|
|
620
|
+
// --- history ---
|
|
596
621
|
function reconnectAlert() {
|
|
597
622
|
if (!state.live.error) return null;
|
|
598
623
|
return Alert({
|
|
@@ -632,7 +657,7 @@ function historyMain() {
|
|
|
632
657
|
lede,
|
|
633
658
|
});
|
|
634
659
|
|
|
635
|
-
const actions = h('div', { key: 'acts', class: 'history-actions' },
|
|
660
|
+
const actions = h('div', { key: 'acts', class: 'history-actions', role: 'status', 'aria-live': 'polite' },
|
|
636
661
|
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
|
|
637
662
|
Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
|
|
638
663
|
);
|
|
@@ -641,7 +666,9 @@ function historyMain() {
|
|
|
641
666
|
// Distinguish "still loading" from "genuinely empty" so a 0-event session
|
|
642
667
|
// doesn't spin forever.
|
|
643
668
|
const body = state.eventsLoaded
|
|
644
|
-
? h('
|
|
669
|
+
? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
|
|
670
|
+
h('span', { key: 'noevtxt' }, 'no events in this session — '),
|
|
671
|
+
Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
|
|
645
672
|
: h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…');
|
|
646
673
|
return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
|
|
647
674
|
}
|
|
@@ -655,13 +682,17 @@ function historyMain() {
|
|
|
655
682
|
head,
|
|
656
683
|
actions,
|
|
657
684
|
Panel({
|
|
658
|
-
title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300)' : ''),
|
|
685
|
+
title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300; ' + hiddenCount + ' older not rendered)' : ''),
|
|
659
686
|
children: EventList({
|
|
660
687
|
items: shown.map((e, i) => {
|
|
661
688
|
// Stable key: prefer the server-assigned event index, else the
|
|
662
689
|
// event timestamp + position, never a bare array index (which
|
|
663
690
|
// collides between loaded and live-pushed events).
|
|
664
|
-
|
|
691
|
+
// Stable key: server event index when present, else ts + the event's
|
|
692
|
+
// ABSOLUTE position in state.events (not the sliced-view index, which
|
|
693
|
+
// shifts when live events append and would collide loaded vs live rows).
|
|
694
|
+
const absIdx = total - shown.length + i;
|
|
695
|
+
const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + absIdx;
|
|
665
696
|
const role = e.role || '?';
|
|
666
697
|
const type = e.type || '?';
|
|
667
698
|
const tool = e.tool ? ' · tool: ' + e.tool : '';
|
|
@@ -672,9 +703,12 @@ function historyMain() {
|
|
|
672
703
|
const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
|
|
673
704
|
return {
|
|
674
705
|
key,
|
|
675
|
-
code: String(
|
|
706
|
+
code: String(absIdx + 1).padStart(4, '0'),
|
|
676
707
|
title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
|
|
677
|
-
|
|
708
|
+
// Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
|
|
709
|
+
// Every row is click-to-expand, so always show the affordance word
|
|
710
|
+
// (not only when text overflows 220 chars).
|
|
711
|
+
sub: (e.ts ? new Date(e.ts).toLocaleString() : 'no time') + ' · ' + role + ' · ' + type + tool + errMark + ' · ' + (expanded ? 'collapse' : 'expand'),
|
|
678
712
|
onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
|
|
679
713
|
};
|
|
680
714
|
}),
|
|
@@ -684,20 +718,22 @@ function historyMain() {
|
|
|
684
718
|
}
|
|
685
719
|
|
|
686
720
|
let copyToast = null;
|
|
721
|
+
// Hold the toast long enough to read (2.5s); the copy button label is inside a
|
|
722
|
+
// role=status region (history-actions) so AT announces the change.
|
|
723
|
+
function setCopyToast(msg) { copyToast = msg; render(); setTimeout(() => { copyToast = null; render(); }, 2500); }
|
|
687
724
|
function copySid() {
|
|
688
725
|
const sid = state.selectedSid;
|
|
689
726
|
if (!sid) return;
|
|
690
|
-
const done = () => { copyToast = 'copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
|
|
691
727
|
if (navigator.clipboard?.writeText) {
|
|
692
|
-
navigator.clipboard.writeText(sid).then(
|
|
728
|
+
navigator.clipboard.writeText(sid).then(() => setCopyToast('copied')).catch(() => setCopyToast('copy failed'));
|
|
693
729
|
} else {
|
|
694
730
|
// Fallback for insecure (http) origins where navigator.clipboard is absent.
|
|
695
731
|
try {
|
|
696
732
|
const ta = document.createElement('textarea');
|
|
697
733
|
ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
698
734
|
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
|
699
|
-
|
|
700
|
-
} catch {
|
|
735
|
+
setCopyToast('copied');
|
|
736
|
+
} catch { setCopyToast('copy failed'); }
|
|
701
737
|
}
|
|
702
738
|
}
|
|
703
739
|
|
|
@@ -757,11 +793,14 @@ function historySide() {
|
|
|
757
793
|
const rows = searching
|
|
758
794
|
? visible.map((r, i) =>
|
|
759
795
|
Row({
|
|
760
|
-
key
|
|
796
|
+
// sid can repeat across hits, so key on sid+position; rank is the
|
|
797
|
+
// absolute result position (stable across the 60-row slice).
|
|
798
|
+
key: 'sr-' + (r.sid || '?') + '-' + i,
|
|
761
799
|
rank: String(i + 1).padStart(3, '0'),
|
|
762
800
|
title: r.snippet || '(no snippet)',
|
|
763
801
|
sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : ''),
|
|
764
|
-
|
|
802
|
+
// Rail carries the same semantics as session rows: error > subagent > normal.
|
|
803
|
+
rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
|
|
765
804
|
onClick: () => loadSession(r.sid),
|
|
766
805
|
})
|
|
767
806
|
)
|
|
@@ -769,8 +808,10 @@ function historySide() {
|
|
|
769
808
|
Row({
|
|
770
809
|
key: 'sess' + s.sid,
|
|
771
810
|
rank: String(i + 1).padStart(3, '0'),
|
|
772
|
-
|
|
773
|
-
|
|
811
|
+
// Subagent is conveyed by the purple rail, not a "- " text prefix.
|
|
812
|
+
title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
|
|
813
|
+
// Always show the error count so 0 reads as "no errors", not "untracked".
|
|
814
|
+
sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools · ' + (s.errors || 0) + ' err',
|
|
774
815
|
rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
|
|
775
816
|
active: s.sid === state.selectedSid,
|
|
776
817
|
onClick: () => loadSession(s.sid),
|
|
@@ -802,8 +843,8 @@ function historySide() {
|
|
|
802
843
|
children: [
|
|
803
844
|
SearchInput({
|
|
804
845
|
key: 'searchInput',
|
|
805
|
-
placeholder: 'search
|
|
806
|
-
'aria-label': 'Search
|
|
846
|
+
placeholder: 'search event text…',
|
|
847
|
+
'aria-label': 'Search event text across sessions',
|
|
807
848
|
value: state.searchQ,
|
|
808
849
|
onInput: (v) => { state.searchQ = v; debouncedSearch(); },
|
|
809
850
|
}),
|
|
@@ -831,7 +872,7 @@ function historySide() {
|
|
|
831
872
|
!searching && subagentCount
|
|
832
873
|
? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
|
|
833
874
|
h('input', { type: 'checkbox', checked: state.showSubagents, onChange: (e) => { state.showSubagents = e.target.checked; render(); } }),
|
|
834
|
-
'show subagents (' + subagentCount + ')')
|
|
875
|
+
'show subagents (' + (state.showSubagents ? subagentCount + ' shown' : subagentCount + ' hidden') + ')')
|
|
835
876
|
: null,
|
|
836
877
|
state.historyError
|
|
837
878
|
? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, state.historyError)
|
|
@@ -839,12 +880,17 @@ function historySide() {
|
|
|
839
880
|
!searching && truncatedBy > 0
|
|
840
881
|
? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
|
|
841
882
|
: null,
|
|
883
|
+
// Search is server-capped at 60 hits; there is no deeper page, so tell
|
|
884
|
+
// the user the result set is truncated rather than silently hiding it.
|
|
885
|
+
searching && truncatedBy > 0
|
|
886
|
+
? h('p', { key: 'searchmore', class: 'lede empty-state' }, 'showing first 60 matches (' + truncatedBy + ' more — refine your search)')
|
|
887
|
+
: null,
|
|
842
888
|
],
|
|
843
889
|
}),
|
|
844
890
|
].filter(Boolean);
|
|
845
891
|
}
|
|
846
892
|
|
|
847
|
-
//
|
|
893
|
+
// --- settings ---
|
|
848
894
|
function isValidUrl(s) {
|
|
849
895
|
if (!s) return true; // blank = same-origin is valid
|
|
850
896
|
try { new URL(s.startsWith('http') ? s : 'http://' + s); return true; }
|
|
@@ -853,6 +899,12 @@ function isValidUrl(s) {
|
|
|
853
899
|
|
|
854
900
|
async function saveBackend() {
|
|
855
901
|
if (!isValidUrl(state.backendDraft) || state.backendDraft === state.backend) return;
|
|
902
|
+
// Switching backend orphans the local chat transcript (it belongs to the old
|
|
903
|
+
// server's sessions). Confirm once if there's a transcript to lose.
|
|
904
|
+
if (state.chat.messages.length && !state.confirmingBackend) {
|
|
905
|
+
state.confirmingBackend = true; render(); return;
|
|
906
|
+
}
|
|
907
|
+
state.confirmingBackend = false;
|
|
856
908
|
B.setBackend(state.backendDraft);
|
|
857
909
|
state.backend = state.backendDraft;
|
|
858
910
|
state.health = { status: 'unknown' };
|
|
@@ -866,14 +918,15 @@ async function saveBackend() {
|
|
|
866
918
|
function healthSummary() {
|
|
867
919
|
const hh = state.health || {};
|
|
868
920
|
const ok = hh.status === 'ok';
|
|
921
|
+
// Each chip carries a title so its meaning isn't left to inference.
|
|
869
922
|
const bits = [];
|
|
870
|
-
bits.push(hh.status || 'unknown');
|
|
871
|
-
if (hh.version) bits.push('v' + hh.version);
|
|
872
|
-
if (typeof hh.agents === 'number') bits.push(hh.agents + ' agents');
|
|
873
|
-
if (typeof hh.activeExecutions === 'number') bits.push(hh.activeExecutions + ' active');
|
|
874
|
-
if (hh.db) bits.push('db ' + (hh.db.ok ? 'ok' : 'down'));
|
|
875
|
-
return h('div', { key: 'hp', class: 'health-summary' + (ok ? ' health-ok' : '') },
|
|
876
|
-
...bits.map((b, i) => h('span', { key: 'hb' + i, class: 'health-chip' }, b)));
|
|
923
|
+
bits.push([hh.status || 'unknown', 'Backend connection status']);
|
|
924
|
+
if (hh.version) bits.push(['v' + hh.version, 'Server version']);
|
|
925
|
+
if (typeof hh.agents === 'number') bits.push([hh.agents + ' agents', 'Agents registered on the server']);
|
|
926
|
+
if (typeof hh.activeExecutions === 'number') bits.push([hh.activeExecutions + ' active', 'Chats currently executing']);
|
|
927
|
+
if (hh.db) bits.push(['db ' + (hh.db.ok ? 'ok' : 'down'), 'History database status']);
|
|
928
|
+
return h('div', { key: 'hp', class: 'health-summary' + (ok ? ' health-ok' : ''), role: 'group', 'aria-label': 'Backend health' },
|
|
929
|
+
...bits.map(([b, t], i) => h('span', { key: 'hb' + i, class: 'health-chip', title: t }, b)));
|
|
877
930
|
}
|
|
878
931
|
|
|
879
932
|
function settingsMain() {
|
|
@@ -905,6 +958,7 @@ function settingsMain() {
|
|
|
905
958
|
state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting…') : null,
|
|
906
959
|
state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
|
|
907
960
|
state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed — check the URL') : null,
|
|
961
|
+
state.confirmingBackend ? h('p', { key: 'bcw', class: 'lede field-error', role: 'alert' }, 'changing backend discards this browser\'s chat transcript — press save again to confirm') : null,
|
|
908
962
|
healthSummary(),
|
|
909
963
|
Btn({
|
|
910
964
|
key: 'savebtn',
|
|
@@ -918,10 +972,40 @@ function settingsMain() {
|
|
|
918
972
|
]),
|
|
919
973
|
}),
|
|
920
974
|
agentsPanel(),
|
|
975
|
+
preferencesPanel(),
|
|
921
976
|
]),
|
|
922
977
|
];
|
|
923
978
|
}
|
|
924
979
|
|
|
980
|
+
function clearLocalData() {
|
|
981
|
+
if (!state.confirmingClearData) { state.confirmingClearData = true; render(); return; }
|
|
982
|
+
state.confirmingClearData = false;
|
|
983
|
+
// Wipe agentgui's own localStorage keys (chat transcript, agent/model/cwd,
|
|
984
|
+
// backend). Leaves the page reload to re-init from defaults.
|
|
985
|
+
for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend']) lsRemove(k);
|
|
986
|
+
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
|
|
987
|
+
render();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function preferencesPanel() {
|
|
991
|
+
const hh = state.health || {};
|
|
992
|
+
return Panel({
|
|
993
|
+
title: 'preferences',
|
|
994
|
+
children: [
|
|
995
|
+
h('div', { key: 'ver', class: 'lede' }, 'server ' + (hh.version ? 'v' + hh.version : 'version unknown') + (window.__SERVER_VERSION ? ' · build ' + window.__SERVER_VERSION : '')),
|
|
996
|
+
h('div', { key: 'sc', class: 'lede', style: 'margin:.5em 0' },
|
|
997
|
+
'keyboard: g then c/h/s — switch tabs · n — new chat · / — focus search/composer · ? — toggle hint · Esc — blur field'),
|
|
998
|
+
state.confirmingClearData
|
|
999
|
+
? Alert({ key: 'cld', kind: 'warn', title: 'Clear all local data?',
|
|
1000
|
+
children: [
|
|
1001
|
+
h('span', { key: 'cldtxt' }, 'Removes saved chat, agent/model/cwd, and backend from this browser. This cannot be undone. '),
|
|
1002
|
+
Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' }),
|
|
1003
|
+
Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' })] })
|
|
1004
|
+
: Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
|
|
1005
|
+
],
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
925
1009
|
function acpStatusFor(agentId) {
|
|
926
1010
|
const acp = Array.isArray(state.health.acp) ? state.health.acp : [];
|
|
927
1011
|
return acp.find(a => a.id === agentId) || null;
|
|
@@ -935,6 +1019,7 @@ function agentsPanel() {
|
|
|
935
1019
|
? state.agents.map((a, i) => {
|
|
936
1020
|
const acp = acpStatusFor(a.id);
|
|
937
1021
|
const avail = a.available !== false;
|
|
1022
|
+
const usable = avail || a.npxInstallable; // selectable from this row
|
|
938
1023
|
const bits = [a.protocol || 'agent'];
|
|
939
1024
|
if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
|
|
940
1025
|
if (acp) bits.push(acp.healthy ? 'running·healthy' : (acp.running ? 'running' : 'stopped'));
|
|
@@ -943,18 +1028,21 @@ function agentsPanel() {
|
|
|
943
1028
|
return Row({
|
|
944
1029
|
key: 'ag' + a.id,
|
|
945
1030
|
rank: String(i + 1).padStart(3, '0'),
|
|
946
|
-
title: a.name
|
|
1031
|
+
title: a.name,
|
|
947
1032
|
sub: bits.join(' · '),
|
|
948
1033
|
rail: a.id === state.selectedAgent ? 'green' : (avail ? 'purple' : 'flame'),
|
|
949
1034
|
active: a.id === state.selectedAgent,
|
|
950
|
-
|
|
1035
|
+
// Non-installable agents are genuinely inert: mark them disabled (no
|
|
1036
|
+
// click, no button role) instead of looking clickable but doing nothing.
|
|
1037
|
+
state: usable ? 'default' : 'disabled',
|
|
1038
|
+
onClick: usable ? () => { navTo('chat'); selectAgent(a.id); } : undefined,
|
|
951
1039
|
});
|
|
952
1040
|
})
|
|
953
1041
|
: h('p', { key: 'none', class: 'lede' }, 'no agents loaded'),
|
|
954
1042
|
});
|
|
955
1043
|
}
|
|
956
1044
|
|
|
957
|
-
//
|
|
1045
|
+
// --- data ---
|
|
958
1046
|
async function refreshHistory() {
|
|
959
1047
|
try {
|
|
960
1048
|
state.sessions = await B.listSessions(state.backend);
|
|
@@ -994,6 +1082,11 @@ async function loadSession(sid) {
|
|
|
994
1082
|
state.expandedEvents = new Set(); // don't carry expansion to the new session
|
|
995
1083
|
writeHash(sid, { push: true });
|
|
996
1084
|
render();
|
|
1085
|
+
// Bring the now-active sidebar row into view (deep-link / back-forward may
|
|
1086
|
+
// select a row that's scrolled out of the session list).
|
|
1087
|
+
requestAnimationFrame(() => {
|
|
1088
|
+
document.querySelector('.app-side .row.active')?.scrollIntoView({ block: 'nearest' });
|
|
1089
|
+
});
|
|
997
1090
|
try {
|
|
998
1091
|
state.events = await B.getSessionEvents(state.backend, sid);
|
|
999
1092
|
state.eventsLoaded = true;
|
|
@@ -1102,7 +1195,13 @@ window.addEventListener('keydown', (e) => {
|
|
|
1102
1195
|
}
|
|
1103
1196
|
if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
|
|
1104
1197
|
if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
|
|
1105
|
-
if (e.key === '/') {
|
|
1198
|
+
if (e.key === '/') {
|
|
1199
|
+
// / focuses search on history, composer on chat; on settings there is no
|
|
1200
|
+
// such field, so ignore it cleanly rather than focusing nothing.
|
|
1201
|
+
if (state.tab === 'history') { e.preventDefault(); focusSearch(); announce('search focused'); }
|
|
1202
|
+
else if (state.tab === 'chat') { e.preventDefault(); focusComposer(); announce('composer focused'); }
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1106
1205
|
if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
|
|
1107
1206
|
});
|
|
1108
1207
|
|