agentgui 1.0.941 → 1.0.943

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 CHANGED
@@ -16,13 +16,23 @@ Dependencies:
16
16
  - `ccsniff` (>=1.1.0) — exports `createHistoryRouter({projectsDir})` mountable on Express; serves `/v1/history/{sessions,sessions/:sid/events,search,snapshot,reindex,stream}`. Reads `~/.claude/projects` (override via `CLAUDE_PROJECTS_DIR`).
17
17
  - `anentrypoint-design` (>=0.0.119) — kit library, single-file ESM from unpkg
18
18
 
19
- ## Browser Witness
19
+ ## Orchestration agents
20
20
 
21
- `bun server.js`. Default `PORT=3000` (server.js); the SPA is served under `BASE_URL` (default `/gm/`), so the live app is **http://localhost:3000/gm/** — `/health` and `/` answer at root, the app is under `/gm/`. First request to `/gm/` or `/v1/history/*` triggers a 30-90s ccsniff JSONL walk (curl with a short timeout returns 000 during warmup). AppShell renders nav=[chat,history,settings], SSE `hello`, 0 console errors, backend resolves to `''` (same origin).
21
+ The four flagship agents the GUI drives are **Claude Code, OpenCode, Kilo, and Antigravity (`agy`)**; the agent picker (`site/app/js/app.js`, `PRIMARY_AGENTS`) sorts these first, then other-available, then npx-installable, then not-installed.
22
+
23
+ Two runner protocols exist. **Direct** (`lib/claude-runner-direct.js`): claude-code and agy — spawn the CLI per turn, parse stdout. **ACP** (`lib/acp-sdk-manager.js`): opencode/kilo/codex — an on-demand long-lived server on ports 18100/18101/18102, health-checked via `/provider`. The on-demand start + restart-backoff is correct even when an ACP agent lacks provider auth (it reports running-not-healthy rather than crashing).
22
24
 
23
- ## Learning audit
25
+ `agy` (Antigravity) is a Gemini-backed Go CLI. Its invocation is `agy --print "<prompt>" --dangerously-skip-permissions [--continue]` — `--print` is a **value flag** (the prompt is its argument; a positional prompt exits 2). It emits **plain text** (not stream-json) and prints no session id, so its `parseOutput` wraps each line into an assistant-text event and resume is `--continue`-only. A live model response needs an authenticated Antigravity session; without it `agy` returns empty (the direct runner resolves gracefully, no hang). Detail in rs-learn (recall "agentgui agy Antigravity agent").
24
26
 
25
- - 2026-05-02 session: 5 items audited (CI bun, stream imports, windows fallback, GM blocker, ACP history), 0 removed (rs-learn retrieval not yet confirmed; safety default kept all), 1 new fact ingested (in-process ccsniff history integration)
27
+ **The direct runner spawns with `shell:false` against a resolved binary path never `shell:true`.** `shell:true` on Windows concatenates argv without escaping, so a chat prompt containing `&`/`|`/`>`/backticks executes as shell commands (arbitrary command execution). `lib/claude-runner.js` `resolveBinaryPath` resolves the command to an absolute `.exe`; `getSpawnOptions` only defaults `shell:true` when a caller passes no explicit `shell`. Detail in rs-learn (recall "agentgui SECURITY direct runner shell:false").
28
+
29
+ **Agent availability comes from `registry.isAvailable(id)`** (`lib/ws-handlers-util.js`, `agents.list`), which runs `where`/`which`. A binary installed outside the system PATH reads as "(not installed)"; the fix is an `npxPackage` on the registry entry so it falls back to bun/npx presence. Detail in rs-learn (recall "agentgui agent availability where npxPackage").
30
+
31
+ **Output and source carry no decorative glyphs.** The GUI uses ASCII words and CSS-drawn discs (`.status-dot-disc`) for status, never `●/◌/○/⌘/§/▶` etc. Convert any decorative glyph to its ASCII equivalent on sight.
32
+
33
+ ## Browser Witness
34
+
35
+ `bun server.js`. Default `PORT=3000` (server.js); the SPA is served under `BASE_URL` (default `/gm/`), so the live app is **http://localhost:3000/gm/** — `/health` and `/` answer at root, the app is under `/gm/`. First request to `/gm/` or `/v1/history/*` triggers a 30-90s ccsniff JSONL walk (curl with a short timeout returns 000 during warmup). AppShell renders nav=[chat,history,settings], SSE `hello`, 0 console errors, backend resolves to `''` (same origin).
26
36
 
27
37
  ## CI / GitHub Actions
28
38
 
@@ -144,3 +154,7 @@ The GUI runs fully offline. `site/app/vendor/cdn/` holds marked, dompurify, pris
144
154
  - `agents.list` (WS) returns `available` + `npxInstallable` per agent; `agents.models` returns model choices (claude-code → sonnet/opus/haiku). The chat picker is **agent-then-model**, not a flat model list. Unavailable agents are disabled/gated.
145
155
  - `chat.sendMessage` accepts `cwd` (defaults to STARTUP_CWD) and `model`/`agentId` separately. `chat.active` (WS) lists in-flight chats with agentId/model/cwd/startedAt/pid; the history tab polls it (3s) and shows a running panel with per-session stop.
146
156
  - Client (`app.js`): chat transcript persists to `localStorage[agentgui.chat]` and restores on load; tool_use/result events render as chat parts; keyboard shortcuts (g+c/h/s, n, /, ?); settings has an agents-status panel from `health.acp[]`.
157
+
158
+ ## DS CSS cascade — overriding component styles (2026-05-28)
159
+
160
+ **`installStyles()` injects DS CSS into a runtime `<style>` after the head `<style>`, so local overrides need `!important` or higher specificity than the DS's `.ds-247420`-prefixed rules.** Full detail (font vars `--ff-display`/`--ff-mono`, the `.chat-head .sub` "00 msgs" quirk, EventList `.row[role=button]`, `[data-prog-focus]` focus suppression, `projectLabel()` for cwd slugs) is in rs-learn (recall "agentgui GUI styling DS cascade installStyles").
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [Unreleased] - Antigravity (agy) orchestration + glyph-to-ASCII sweep + spawn-injection fix
2
+
3
+ - lib/agent-discovery.js + lib/claude-runner-agents.js + lib/agent-descriptors.js: added `agy` (Antigravity) as a direct-protocol CLI agent — `agy --print "<prompt>" --dangerously-skip-permissions [--continue]`, plain-text parseOutput, no model picker. Completes the four flagship orchestration targets (Claude Code, OpenCode, Kilo, Antigravity). Why: the GUI could not drive Antigravity at all.
4
+ - lib/claude-runner.js + lib/claude-runner-direct.js: SECURITY — the direct runner now spawns a resolved binary with `shell:false`. Previously `shell:true` on Windows concatenated argv unescaped, so a prompt containing `&`/`|`/`>`/backticks executed as shell commands (verified injection). Why: arbitrary command execution from chat input.
5
+ - lib/claude-runner-agents.js: claude-code registry entry gained `npxPackage` so it reports available via npx fallback when `where claude` misses a non-PATH install. Why: the flagship agent showed "(not installed)" despite being present.
6
+ - site/app/js/app.js + site/app/index.html: removed every decorative glyph (status dots, cmd/section/box/arrow/check/cross marks) in favour of ASCII words and a CSS-drawn status disc; agent picker now sorts the four flagship targets first, then available, then npx-installable, then not-installed. Why: machine-shaped glyph tells and a flat 17-item list obscured the orchestration targets.
7
+
1
8
  ## [Unreleased] - site/app live client migrated to 247420 SDK components
2
9
 
3
10
  - site/app/index.html: removed 35 lines of bespoke CSS overriding design tokens; now uses SDK panel/font tokens with only structural overrides (chat flex layout, history pane grid)
@@ -2,6 +2,7 @@ const agentDescriptorCache = new Map();
2
2
 
3
3
  const AGENT_DESCRIPTIONS = {
4
4
  'claude-code': 'Claude Code is an AI coding agent that can read, write, and execute code with streaming output support. It provides comprehensive code editing, file management, and terminal execution capabilities.',
5
+ 'agy': 'Antigravity (agy) is a Gemini-backed coding agent CLI. It runs prompts non-interactively with plain-text streaming output, workspace directory scoping, and conversation resume.',
5
6
  'gemini': 'Gemini CLI is Google AI coding agent with streaming support, code execution, and file management capabilities.',
6
7
  'opencode': 'OpenCode is a multi-provider AI coding agent with streaming support and comprehensive code manipulation capabilities.',
7
8
  };
@@ -6,6 +6,7 @@ import { execSync } from 'child_process';
6
6
 
7
7
  const BINARIES = [
8
8
  { cmd: 'claude', id: 'claude-code', name: 'Claude Code', icon: 'C', protocol: 'cli' },
9
+ { cmd: 'agy', id: 'agy', name: 'Antigravity', icon: 'Y', protocol: 'cli' },
9
10
  { cmd: 'opencode', id: 'opencode', name: 'OpenCode', icon: 'O', protocol: 'acp', npxPackage: 'opencode-ai' },
10
11
  { cmd: 'gemini', id: 'gemini', name: 'Gemini CLI', icon: 'G', protocol: 'acp', npxPackage: '@google/gemini-cli' },
11
12
  { cmd: 'kilo', id: 'kilo', name: 'Kilo Code', icon: 'K', protocol: 'acp', npxPackage: '@kilocode/cli' },
@@ -97,6 +97,10 @@ function parseClaudeOutput(line) {
97
97
  registry.register({
98
98
  id: 'claude-code', name: 'Claude Code', command: 'claude', protocol: 'direct', supportsStdin: false, closeStdin: true,
99
99
  useJsonRpcStdin: false, supportedFeatures: ['streaming', 'resume', 'system-prompt', 'permissions-skip', 'steering'],
100
+ // claude has an npx fallback (matches agent-discovery), so when `where claude`
101
+ // misses a non-PATH install the agent still reports as runnable rather than
102
+ // "(not installed)".
103
+ npxPackage: '@anthropic-ai/claude-code',
100
104
  spawnEnv: { MAX_THINKING_TOKENS: '0', AGENTGUI_SUBPROCESS: '1' },
101
105
  buildArgs(prompt, config) {
102
106
  const { verbose = true, outputFormat = 'stream-json', print = true, resumeSessionId = null, systemPrompt = null, model = null } = config;
@@ -114,6 +118,29 @@ registry.register({
114
118
  parseOutput: parseClaudeOutput
115
119
  });
116
120
 
121
+ // Antigravity (`agy`): a direct-protocol CLI like claude-code, but emits PLAIN
122
+ // TEXT (no stream-json, no model flag, no printed session_id). Each stdout line
123
+ // is wrapped into the assistant-text event the non-JSONL stream handler expects.
124
+ // Resume uses --continue (most-recent conversation) since agy prints no session id.
125
+ registry.register({
126
+ id: 'agy', name: 'Antigravity', command: 'agy', protocol: 'direct',
127
+ supportsStdin: false, closeStdin: true,
128
+ supportedFeatures: ['streaming', 'resume'],
129
+ spawnEnv: { AGENTGUI_SUBPROCESS: '1' },
130
+ buildArgs(prompt, config) {
131
+ const { resumeSessionId = null } = config;
132
+ // agy's --print is a VALUE flag (the prompt is its argument), not a boolean
133
+ // followed by a positional. A positional prompt exits with code 2.
134
+ const flags = ['--print', typeof prompt === 'string' ? prompt : String(prompt), '--dangerously-skip-permissions'];
135
+ if (resumeSessionId) flags.push('--continue');
136
+ return flags;
137
+ },
138
+ parseOutput(line) {
139
+ const text = line.replace(/\r$/, '');
140
+ if (!text.trim()) return null;
141
+ return { type: 'assistant', message: { content: [{ type: 'text', text }] } };
142
+ },
143
+ });
117
144
  registry.register({ id: 'opencode', name: 'OpenCode', command: 'opencode', protocol: 'acp', supportsStdin: false, npxPackage: 'opencode-ai', supportedFeatures: ['streaming', 'resume', 'acp-protocol'], buildArgs: () => ['acp'], protocolHandler: acpProtocolHandler });
118
145
  registry.register({ id: 'gemini', name: 'Gemini CLI', command: 'gemini', protocol: 'acp', supportsStdin: false, npxPackage: '@google/gemini-cli', supportedFeatures: ['streaming', 'resume', 'acp-protocol'], buildArgs(prompt, config) { const args = ['--experimental-acp', '--yolo']; if (config?.model) args.push('--model', config.model); return args; }, protocolHandler: acpProtocolHandler });
119
146
  registry.register({ id: 'goose', name: 'Goose', command: 'goose', protocol: 'acp', supportsStdin: false, supportedFeatures: ['streaming', 'resume', 'acp-protocol'], buildArgs: () => ['acp'], protocolHandler: acpProtocolHandler });
@@ -1,14 +1,20 @@
1
1
  import { spawn } from 'child_process';
2
- import { AgentRunner, getSpawnOptions } from './claude-runner.js';
2
+ import { AgentRunner, getSpawnOptions, resolveBinaryPath } from './claude-runner.js';
3
3
 
4
4
  AgentRunner.prototype.runDirect = function(prompt, cwd, config = {}) {
5
5
  return new Promise((resolve, reject) => {
6
6
  const { timeout = 300000, onEvent = null, onError = null, onRateLimit = null } = config;
7
7
  const args = this.buildArgs(prompt, config);
8
- const spawnOpts = getSpawnOptions(cwd);
8
+ // The direct runner passes the untrusted user prompt as an argv element.
9
+ // Spawn with shell:false against a resolved binary so the prompt cannot be
10
+ // interpreted by a shell (shell:true would let "& cmd" inject). If the
11
+ // binary resolves to a .cmd/.bat shim, resolveBinaryPath returns the name
12
+ // unchanged and we fall back to the shell (npx-style fallback launchers).
13
+ const binary = resolveBinaryPath(this.command);
14
+ const spawnOpts = getSpawnOptions(cwd, { shell: false });
9
15
  if (Object.keys(this.spawnEnv).length > 0) spawnOpts.env = { ...spawnOpts.env, ...this.spawnEnv };
10
16
  if (this.closeStdin) spawnOpts.stdio = ['ignore', 'pipe', 'pipe'];
11
- const proc = spawn(this.command, args, spawnOpts);
17
+ const proc = spawn(binary, args, spawnOpts);
12
18
  console.log(`[${this.id}] Spawned PID ${proc.pid} closeStdin=${this.closeStdin}`);
13
19
 
14
20
  if (config.onPid) { try { config.onPid(proc.pid); } catch (e) { console.error(`[${this.id}] onPid callback failed:`, e.message); } }
@@ -4,12 +4,33 @@ const isWindows = process.platform === 'win32';
4
4
 
5
5
  export function getSpawnOptions(cwd, additionalOptions = {}) {
6
6
  const options = { cwd, ...additionalOptions };
7
- if (isWindows) options.shell = true;
7
+ // shell:true on Windows concatenates argv WITHOUT escaping, so a user prompt
8
+ // containing &, |, >, or backticks executes as a shell command (verified
9
+ // injection). Callers that pass untrusted args (the direct runner) must set
10
+ // shell:false and spawn a resolved binary. Default keeps the legacy shell
11
+ // behaviour only when a caller has not already chosen.
12
+ if (isWindows && options.shell === undefined) options.shell = true;
8
13
  if (!options.env) options.env = { ...process.env };
9
14
  delete options.env.CLAUDECODE;
10
15
  return options;
11
16
  }
12
17
 
18
+ // Resolve a bare command name to an absolute executable path so it can be
19
+ // spawned with shell:false (no argv concatenation, no injection surface).
20
+ // Returns the original command if resolution fails (Node will still PATH-resolve).
21
+ export function resolveBinaryPath(command) {
22
+ try {
23
+ const whichCmd = isWindows ? 'where' : 'which';
24
+ const r = spawnSync(whichCmd, [command], { encoding: 'utf-8', timeout: 3000 });
25
+ if (r.status === 0) {
26
+ const first = (r.stdout || '').trim().split(/\r?\n/)[0].trim();
27
+ // A .cmd/.bat shim cannot be exec'd without a shell; leave it to the caller.
28
+ if (first && !/\.(cmd|bat)$/i.test(first)) return first;
29
+ }
30
+ } catch (_) {}
31
+ return command;
32
+ }
33
+
13
34
  export function resolveCommand(command, npxPackage) {
14
35
  const whichCmd = isWindows ? 'where' : 'which';
15
36
  const check = spawnSync(whichCmd, [command], { encoding: 'utf-8', timeout: 3000 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.941",
3
+ "version": "1.0.943",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -21,11 +21,39 @@
21
21
  height: 100%;
22
22
  background: var(--bg, var(--agentgui-bg));
23
23
  color: var(--fg, var(--agentgui-fg));
24
- font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif);
24
+ /* The DS exposes --ff-display / --ff-mono (there is no --font-sans), so the
25
+ old reference fell through to the system font. Use the DS display face. */
26
+ font-family: var(--ff-display, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif);
25
27
  }
26
28
  #app { height: 100vh; height: 100dvh; }
27
29
  #app > * { height: 100%; }
28
30
 
31
+ /* Themed thin scrollbars — the native chrome scrollbar is chunky/light and
32
+ clashes with the dark theme on the history sidebar and settings column. */
33
+ * {
34
+ scrollbar-width: thin;
35
+ scrollbar-color: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 22%, transparent) transparent;
36
+ }
37
+ *::-webkit-scrollbar { width: 9px; height: 9px; }
38
+ *::-webkit-scrollbar-track { background: transparent; }
39
+ *::-webkit-scrollbar-thumb {
40
+ background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 20%, transparent);
41
+ border-radius: 999px;
42
+ border: 2px solid transparent;
43
+ background-clip: padding-box;
44
+ }
45
+ *::-webkit-scrollbar-thumb:hover {
46
+ background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 34%, transparent);
47
+ background-clip: padding-box;
48
+ }
49
+
50
+ /* We move focus to the page heading on tab change for AT users; suppress the
51
+ resulting green outline box on those programmatically-focused elements.
52
+ !important + .ds-247420 prefix to beat the runtime-injected DS focus rule. */
53
+ .ds-247420 [data-prog-focus]:focus,
54
+ .ds-247420 [data-prog-focus]:focus-visible,
55
+ [data-prog-focus]:focus, [data-prog-focus]:focus-visible { outline: none !important; box-shadow: none !important; }
56
+
29
57
  /* skip link for keyboard/AT users */
30
58
  .skip-link {
31
59
  position: absolute; left: -9999px; top: 0; z-index: 1000;
@@ -35,21 +63,28 @@
35
63
  }
36
64
  .skip-link:focus { left: 8px; top: 8px; outline: 2px solid #fff; outline-offset: 2px; }
37
65
 
38
- /* status connection dot */
39
- .status-dot { display: inline-flex; align-items: center; gap: .35em; white-space: nowrap; }
40
- .status-dot-live::before {
41
- content: ''; width: 8px; height: 8px; border-radius: 50%;
42
- background: var(--accent, var(--agentgui-accent));
43
- box-shadow: 0 0 0 0 rgba(80, 200, 120, .5);
66
+ /* status connection dot — a CSS-drawn disc (real UI affordance, not a text
67
+ glyph). The .status-dot-disc child is always present; the parent's state
68
+ class colours it, and only is-live pulses. */
69
+ .status-dot { display: inline-flex; align-items: center; gap: .4em; white-space: nowrap; }
70
+ .status-dot-disc {
71
+ flex: none; width: 8px; height: 8px; border-radius: 50%;
72
+ background: var(--muted, #8a8f98);
73
+ }
74
+ .status-dot.is-live .status-dot-disc,
75
+ .status-dot-live .status-dot-disc {
76
+ background: var(--accent, var(--agentgui-accent, #50c878));
44
77
  animation: agentgui-pulse 2s infinite;
45
78
  }
79
+ .status-dot.is-connecting .status-dot-disc { background: #d9a441; }
80
+ .status-dot.is-offline .status-dot-disc { background: #c0504d; animation: none; }
46
81
  @keyframes agentgui-pulse {
47
82
  0% { box-shadow: 0 0 0 0 rgba(80, 200, 120, .5); }
48
83
  70% { box-shadow: 0 0 0 6px rgba(80, 200, 120, 0); }
49
84
  100% { box-shadow: 0 0 0 0 rgba(80, 200, 120, 0); }
50
85
  }
51
86
  @media (prefers-reduced-motion: reduce) {
52
- .status-dot-live::before { animation: none; }
87
+ .status-dot-disc { animation: none !important; }
53
88
  }
54
89
 
55
90
  /* resume banner */
@@ -127,6 +162,92 @@
127
162
  outline: 2px solid var(--accent, var(--agentgui-accent)); outline-offset: 2px;
128
163
  }
129
164
 
165
+ /* Topbar nav: the DS renders the active tab as a large filled green pill that
166
+ sits taller than the inactive text links and reads as misaligned. Make all
167
+ tabs consistent — equal padding, the active one a subtle tinted underline-pill
168
+ rather than an oversized oval. */
169
+ /* Prefix with .ds-247420 (the <html> class) to match the DS selector's
170
+ specificity; source order then lets these win. */
171
+ .ds-247420 .app-topbar nav { display: flex; align-items: center; gap: .15em; }
172
+ .ds-247420 .app-topbar nav a {
173
+ padding: .35em .75em; border-radius: 8px; line-height: 1.4;
174
+ color: var(--fg-2, color-mix(in srgb, var(--fg, var(--agentgui-fg)) 75%, transparent));
175
+ text-decoration: none; background: transparent;
176
+ transition: background-color .15s ease, color .15s ease;
177
+ }
178
+ .ds-247420 .app-topbar nav a:hover { background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 8%, transparent); color: var(--fg, var(--agentgui-fg)); }
179
+ /* installStyles() injects the DS CSS into a runtime <style> appended after
180
+ this block, so equal-specificity rules lose on source order; !important is
181
+ the targeted override for the few props we reshape on the active tab. */
182
+ .ds-247420 .app-topbar nav a.active {
183
+ background: color-mix(in srgb, var(--accent, var(--agentgui-accent)) 16%, transparent) !important;
184
+ color: var(--accent, var(--agentgui-accent)) !important;
185
+ box-shadow: inset 0 -2px 0 0 var(--accent, var(--agentgui-accent)) !important;
186
+ border-radius: 8px !important;
187
+ font-weight: 600;
188
+ }
189
+
190
+ /* Compact working-directory bar (replaces the full-width tall banner box). */
191
+ .cwd-bar {
192
+ display: flex; align-items: center; gap: .5em; flex-wrap: wrap;
193
+ padding: .15em 0 .5em; font-size: .85rem;
194
+ }
195
+ .cwd-bar-text {
196
+ font-family: var(--ff-mono, ui-monospace, monospace);
197
+ color: var(--fg-3, #999); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60vw;
198
+ }
199
+ .cwd-bar-btn {
200
+ cursor: pointer; font: inherit; line-height: 1.3;
201
+ padding: .15em .55em; border-radius: 6px;
202
+ background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 8%, transparent);
203
+ border: 1px solid color-mix(in srgb, var(--fg, var(--agentgui-fg)) 14%, transparent);
204
+ color: var(--fg-2, var(--agentgui-fg));
205
+ }
206
+ .cwd-bar-btn:hover { background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 14%, transparent); }
207
+ .cwd-bar-btn:focus-visible { outline: 2px solid var(--accent, var(--agentgui-accent)); outline-offset: 2px; }
208
+
209
+ /* History no-session empty state: fill the void with a centered prompt. */
210
+ .history-empty {
211
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
212
+ gap: .4em; text-align: center; min-height: 50vh; color: var(--fg-3, #888);
213
+ }
214
+ .history-empty-glyph { font-size: 3rem; opacity: .25; line-height: 1; }
215
+ .history-empty-title { margin: 0; font-size: 1.05rem; color: var(--fg-2, var(--agentgui-fg)); }
216
+ .history-empty-sub { margin: 0; max-width: 42ch; }
217
+
218
+ /* Settings: two-column on wide screens (backend + agents) so it uses the
219
+ full width instead of a cramped centered measure with empty margins. */
220
+ .settings-grid {
221
+ display: grid; gap: var(--space-4, 16px);
222
+ grid-template-columns: minmax(0, 1fr);
223
+ align-items: start;
224
+ }
225
+ @media (min-width: 900px) {
226
+ .settings-grid { grid-template-columns: minmax(0, 420px) minmax(0, 1fr); }
227
+ }
228
+ /* The DS PageHeader carries large vertical margins that leave a big empty
229
+ band above the heading and between the lede and the panels. Neutralize
230
+ them so settings/history read as normal top-aligned scrolling pages. */
231
+ .agentgui-main [class*="page-header"],
232
+ .agentgui-main .ds-page-header { margin-top: 0 !important; margin-bottom: var(--space-4, 16px) !important; }
233
+ .agentgui-main > :first-child { margin-top: 0 !important; }
234
+ .agentgui-main-settings .settings-grid { margin-top: 0; }
235
+
236
+ /* The DS Chat head computes its own zero-padded count ("00 msgs") and ignores
237
+ our sub prop; it reads as a bug. Hide the DS sub — streaming state shows via
238
+ the title and the busy banner. Also hide the DS's empty decorative head dot. */
239
+ .chat-head .sub { display: none; }
240
+ .chat-head .dot { display: none; }
241
+
242
+ /* EventList rows are role=button (click to expand) but the DS doesn't give
243
+ them a pointer cursor, so the affordance is invisible. */
244
+ .ds-event-list .row[role="button"] { cursor: pointer; }
245
+ .ds-event-list .row[role="button"]:hover { background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 5%, transparent); }
246
+
247
+ /* Chat composer: hide the idle scrollbar on the (empty/short) textarea. */
248
+ .chat-composer textarea { overflow-y: auto; scrollbar-width: thin; }
249
+ .chat-composer textarea:not(:focus) { overflow-y: hidden; }
250
+
130
251
  /* touch targets on small screens */
131
252
  @media (max-width: 640px) {
132
253
  .pill { min-height: 36px; padding: .4em .8em; }
@@ -150,12 +150,31 @@ function navTo(tab) {
150
150
  render();
151
151
  // Move focus into the new region for keyboard/AT users.
152
152
  requestAnimationFrame(() => {
153
+ syncAriaCurrent();
153
154
  const region = document.querySelector('#agentgui-main');
154
155
  if (!region) return;
155
156
  const heading = region.querySelector('h1, h2');
156
157
  const target = heading || region;
157
158
  if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
159
+ // Mark as programmatically focused so CSS can suppress the focus ring — we
160
+ // move focus here for AT, but a visible green outline box around the heading
161
+ // reads as an accidental border to sighted users.
162
+ target.setAttribute('data-prog-focus', '');
158
163
  try { target.focus({ preventScroll: true }); } catch {}
164
+ const clear = () => { target.removeAttribute('data-prog-focus'); target.removeEventListener('blur', clear); };
165
+ target.addEventListener('blur', clear);
166
+ });
167
+ }
168
+
169
+ // The DS Topbar derives aria-current from href↔location.hash matching, which
170
+ // drifts from our hash-based active tab (e.g. aria-current lands on "settings"
171
+ // while we're on "chat"). Re-assert aria-current on the actually-active tab.
172
+ function syncAriaCurrent() {
173
+ const links = document.querySelectorAll('.app-topbar nav a');
174
+ links.forEach((a) => {
175
+ const isActive = a.classList.contains('active');
176
+ if (isActive) a.setAttribute('aria-current', 'page');
177
+ else a.removeAttribute('aria-current');
159
178
  });
160
179
  }
161
180
 
@@ -244,19 +263,20 @@ function closeLiveStream() {
244
263
  function view() {
245
264
  const ok = state.health.status === 'ok';
246
265
  const liveActive = state.tab === 'history' && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
247
- const dotText = state.tab === 'history'
266
+ const dotLabel = state.tab === 'history'
248
267
  ? (state.live.error
249
- ? '◌ ' + state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
250
- : (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting…')))
251
- : (ok ? (state.health.ws === 'reconnecting' ? 'ws reconnecting' : 'connected') : 'offline');
268
+ ? state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
269
+ : (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting…')))
270
+ : (ok ? (state.health.ws === 'reconnecting' ? 'ws reconnecting' : 'connected') : 'offline');
252
271
  const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
253
- // Split the leading status glyph (● ○) from the words: glyph is decorative
254
- // (aria-hidden), only the text is announced, so AT reads "live" not "black circle live".
255
- const glyphMatch = dotText.match(/^([●◌○])\s*(.*)$/);
256
- const dotGlyph = glyphMatch ? glyphMatch[1] : '';
257
- const dotLabel = glyphMatch ? glyphMatch[2] : dotText;
258
- const dot = h('span', { key: 'dot', class: 'status-dot' + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' },
259
- dotGlyph ? h('span', { key: 'dg', 'aria-hidden': 'true' }, dotGlyph + ' ') : null,
272
+ // The status dot is drawn entirely by CSS (.status-dot::before) a small
273
+ // colored disc, real product design, not a text glyph. State drives its colour
274
+ // via the modifier class; the label carries only words so AT reads "live", and
275
+ // there are no literal status-glyph characters in the DOM.
276
+ const dotState = state.live.error || (!dotLive) ? 'is-offline'
277
+ : (dotLive && state.tab !== 'history' && state.health.ws === 'reconnecting' ? 'is-connecting' : 'is-live');
278
+ const dot = h('span', { key: 'dot', class: 'status-dot ' + dotState + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' },
279
+ h('span', { key: 'dd', class: 'status-dot-disc', 'aria-hidden': 'true' }),
260
280
  h('span', { key: 'dl' }, dotLabel));
261
281
 
262
282
  const topbar = Topbar({
@@ -267,11 +287,27 @@ function view() {
267
287
  onNav: (label) => navTo(label),
268
288
  });
269
289
 
270
- const agentOptions = state.agents.map(a => ({
271
- value: a.id,
272
- label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
273
- disabled: a.available === false && !a.npxInstallable,
274
- }));
290
+ // The four flagship orchestration targets surface first, then the rest of the
291
+ // available agents, then unavailable ones — so the agents the GUI exists to
292
+ // drive are reachable without scanning a flat 17-item list. Ordering only;
293
+ // the DS Select renders a plain option list.
294
+ const PRIMARY_AGENTS = ['claude-code', 'opencode', 'kilo', 'agy'];
295
+ const agentRank = (a) => {
296
+ const primary = PRIMARY_AGENTS.indexOf(a.id);
297
+ const avail = a.available !== false;
298
+ if (primary !== -1 && avail) return primary; // 0..3 flagship + available
299
+ if (avail) return 10; // other available
300
+ if (a.npxInstallable) return 20; // installable via npx
301
+ return 30; // not installed
302
+ };
303
+ const agentOptions = state.agents
304
+ .map(a => ({ a, rank: agentRank(a) }))
305
+ .sort((x, y) => x.rank - y.rank || x.a.name.localeCompare(y.a.name))
306
+ .map(({ a }) => ({
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
+ }));
275
311
  const showModelPicker = state.tab === 'chat' && state.agentModels.length > 0;
276
312
 
277
313
  const crumbRight = state.tab === 'chat'
@@ -295,7 +331,7 @@ function view() {
295
331
  })
296
332
  : null,
297
333
  state.chat.busy
298
- ? Btn({ key: 'stop', onClick: cancelChat, children: 'stop', title: 'Stop streaming' })
334
+ ? Btn({ key: 'stop', onClick: cancelChat, children: 'stop', title: 'Stop streaming' })
299
335
  : Btn({ key: 'new', onClick: newChat, children: '+ new', title: 'Start new chat (clears history)' }),
300
336
  dot,
301
337
  )]
@@ -314,10 +350,10 @@ function view() {
314
350
  const side = state.tab === 'history' ? historySide() : null;
315
351
 
316
352
  const agentLabel = state.selectedAgent
317
- ? ' ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
318
- : 'no agent';
353
+ ? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
354
+ : 'no agent';
319
355
  const status = Status({
320
- left: [state.backend || 'same-origin', ok ? 'live' : 'offline'],
356
+ left: [state.backend || 'same-origin', ok ? 'live' : 'offline'],
321
357
  right: [agentLabel],
322
358
  });
323
359
 
@@ -331,8 +367,7 @@ function view() {
331
367
  children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
332
368
  : null;
333
369
  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));
334
- // settings reads better centered in a measure; chat + history use full width.
335
- return AppShell({ topbar, crumb, side, main, status, narrow: state.tab === 'settings' });
370
+ return AppShell({ topbar, crumb, side, main, status, narrow: false });
336
371
  }
337
372
 
338
373
  function mainContent() {
@@ -354,14 +389,14 @@ function toolSummary(block) {
354
389
  arg = inp.command || inp.file_path || inp.path || inp.pattern || inp.query || inp.url || '';
355
390
  if (!arg) { try { arg = JSON.stringify(inp).slice(0, 120); } catch {} }
356
391
  }
357
- return ' ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
392
+ return 'tool: ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
358
393
  }
359
394
 
360
395
  function toolResultSummary(block) {
361
396
  const c = block?.content ?? block?.output ?? block;
362
397
  let s = typeof c === 'string' ? c : (() => { try { return JSON.stringify(c); } catch { return String(c); } })();
363
398
  s = s.replace(/\s+/g, ' ').trim();
364
- return (block?.is_error ? ' ' : '') + s.slice(0, 160);
399
+ return (block?.is_error ? '[error] ' : '') + s.slice(0, 160);
365
400
  }
366
401
 
367
402
  function errText(e) {
@@ -406,8 +441,8 @@ function chatMain() {
406
441
  const banners = [];
407
442
  if (state.chat.resumeSid) {
408
443
  banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
409
- h('span', { key: 'rbtxt', class: 'lede' }, 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
410
- Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: '× clear' })));
444
+ h('span', { key: 'rbtxt', class: 'lede' }, 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
445
+ Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
411
446
  if (state.chat.resumeNote) {
412
447
  banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
413
448
  }
@@ -434,7 +469,14 @@ function chatMain() {
434
469
  ...banners,
435
470
  Chat({
436
471
  title: agentName + (state.selectedModel ? ' · ' + state.selectedModel : '') + (state.chat.resumeSid ? ' · resume' : ''),
437
- sub: state.chat.busy ? 'streaming…' : undefined,
472
+ // Explicit human-readable sub; the DS default ("NN msgs", zero-padded)
473
+ // leaks an event-list style into chat. Hide it when there are no messages
474
+ // (the empty-state already says "no messages yet").
475
+ sub: state.chat.busy
476
+ ? 'streaming…'
477
+ : (state.chat.messages.length
478
+ ? state.chat.messages.length + (state.chat.messages.length === 1 ? ' message' : ' messages')
479
+ : ''),
438
480
  messages: msgs,
439
481
  composer,
440
482
  }),
@@ -459,10 +501,11 @@ function cwdBanner() {
459
501
  }, children: 'save' }),
460
502
  Btn({ key: 'cwdcancel', onClick: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); }, children: 'cancel' }));
461
503
  }
462
- return h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Working directory' },
463
- h('span', { class: 'lede' }, state.chatCwd ? ' cwd: ' + state.chatCwd : ' cwd: server default'),
464
- Btn({ key: 'cwdset', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); }, children: state.chatCwd ? 'change' : 'set' }),
465
- state.chatCwd ? Btn({ key: 'cwdclr', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); }, children: default' }) : null);
504
+ return h('div', { key: 'cwdb', class: 'cwd-bar', role: 'group', 'aria-label': 'Working directory' },
505
+ h('span', { key: 'cwdtxt', class: 'lede cwd-bar-text', title: state.chatCwd || 'server default working directory' },
506
+ state.chatCwd ? 'cwd: ' + truncate(state.chatCwd, 28, 60) : 'cwd: server default'),
507
+ h('button', { key: 'cwdset', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); } }, state.chatCwd ? 'change' : 'set'),
508
+ state.chatCwd ? h('button', { key: 'cwdclr', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); } }, 'use default') : null);
466
509
  }
467
510
 
468
511
  function newChat() {
@@ -525,7 +568,7 @@ async function sendChat() {
525
568
  })) {
526
569
  if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
527
570
  else if (ev.type === 'tool') { cur.parts.push(toolSummary(ev.block)); render(); scrollChatToBottom(); }
528
- else if (ev.type === 'tool_result') { cur.parts.push(' ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
571
+ else if (ev.type === 'tool_result') { cur.parts.push('-> ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
529
572
  else if (ev.type === 'result') { /* terminal usage/summary block — already reflected via text */ }
530
573
  else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
531
574
  }
@@ -553,28 +596,36 @@ function reconnectAlert() {
553
596
 
554
597
  function historyMain() {
555
598
  if (!state.selectedSid) {
599
+ const count = (Array.isArray(state.sessions) ? state.sessions : []).length;
556
600
  return [
557
601
  reconnectAlert(),
558
602
  PageHeader({
559
- title: '§ history',
603
+ title: 'history',
560
604
  lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
561
605
  }),
606
+ h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
607
+ h('p', { key: 'gt', class: 'history-empty-title' },
608
+ count ? 'Select a session to view its events' : 'No sessions yet'),
609
+ h('p', { key: 'gs', class: 'lede history-empty-sub' },
610
+ count
611
+ ? count + ' session' + (count === 1 ? '' : 's') + ' available · use the search box or press / to filter'
612
+ : 'Start a chat or run a local coding agent — its session will appear here live.')),
562
613
  ].filter(Boolean);
563
614
  }
564
615
 
565
616
  const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
566
617
  const lede = sess
567
- ? (sess.project || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
618
+ ? (projectLabel(sess.project) || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
568
619
  : state.selectedSid;
569
620
 
570
621
  const head = PageHeader({
571
- title: '§ ' + truncate(sess?.title || state.selectedSid, 40, 80),
622
+ title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || state.selectedSid, 40, 80),
572
623
  lede,
573
624
  });
574
625
 
575
626
  const actions = h('div', { key: 'acts', class: 'history-actions' },
576
- Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
577
- Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
627
+ Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
628
+ Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
578
629
  );
579
630
 
580
631
  if (state.events.length === 0) {
@@ -604,8 +655,8 @@ function historyMain() {
604
655
  const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (total - shown.length + i);
605
656
  const role = e.role || '?';
606
657
  const type = e.type || '?';
607
- const tool = e.tool ? ' · ' + e.tool : '';
608
- const errMark = e.isError ? ' · ' : '';
658
+ const tool = e.tool ? ' · tool: ' + e.tool : '';
659
+ const errMark = e.isError ? ' · error' : '';
609
660
  const raw = e.text || '';
610
661
  const text = raw.replace(/\s+/g, ' ').trim();
611
662
  const expanded = state.expandedEvents.has(key);
@@ -627,9 +678,9 @@ let copyToast = null;
627
678
  function copySid() {
628
679
  const sid = state.selectedSid;
629
680
  if (!sid) return;
630
- const done = () => { copyToast = 'copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
681
+ const done = () => { copyToast = 'copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
631
682
  if (navigator.clipboard?.writeText) {
632
- navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
683
+ navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
633
684
  } else {
634
685
  // Fallback for insecure (http) origins where navigator.clipboard is absent.
635
686
  try {
@@ -637,7 +688,7 @@ function copySid() {
637
688
  ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
638
689
  document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
639
690
  done();
640
- } catch { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
691
+ } catch { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
641
692
  }
642
693
  }
643
694
 
@@ -668,6 +719,16 @@ function visibleSessions() {
668
719
  return filtered.slice().sort((a, b) => (b.last || 0) - (a.last || 0));
669
720
  }
670
721
 
722
+ // ccsniff derives `project` from the ~/.claude/projects dir name, which encodes
723
+ // the cwd as a dash-joined path (e.g. "-config-workspace-agentgui"). Show the
724
+ // last meaningful segment ("agentgui") rather than the raw slug.
725
+ function projectLabel(project) {
726
+ if (!project) return '';
727
+ if (/[/\\]/.test(project)) return project.split(/[/\\]/).filter(Boolean).pop() || project;
728
+ const segs = project.split('-').filter(Boolean);
729
+ return segs.length ? segs[segs.length - 1] : project;
730
+ }
731
+
671
732
  function uniqueProjects() {
672
733
  const arr = Array.isArray(state.sessions) ? state.sessions : [];
673
734
  const seen = new Map();
@@ -699,7 +760,7 @@ function historySide() {
699
760
  Row({
700
761
  key: 'sess' + s.sid,
701
762
  rank: String(i + 1).padStart(3, '0'),
702
- title: (s.isSubagent ? ' ' : '') + (s.title || s.project || s.sid),
763
+ title: (s.isSubagent ? '- ' : '') + (projectLabel(s.title) || projectLabel(s.project) || s.sid),
703
764
  sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
704
765
  rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
705
766
  active: s.sid === state.selectedSid,
@@ -714,13 +775,14 @@ function historySide() {
714
775
  running.length
715
776
  ? Panel({
716
777
  key: 'runningPanel',
717
- title: 'running · ' + running.length,
778
+ title: 'running · ' + running.length,
718
779
  children: running.map((r, i) => {
719
780
  const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
720
781
  const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
721
782
  return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
722
- h('span', { class: 'lede' }, '● ' + agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
723
- Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: ' stop' }));
783
+ h('span', { key: 'rd', class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
784
+ h('span', { class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
785
+ Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
724
786
  }),
725
787
  })
726
788
  : null,
@@ -749,13 +811,13 @@ function historySide() {
749
811
  ? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
750
812
  : null,
751
813
  state.searchQ
752
- ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: '× clear search' })
814
+ ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: 'clear search' })
753
815
  : null,
754
816
  !searching && projects.length > 1
755
817
  ? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
756
818
  pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; render(); }),
757
819
  ...projects.slice(0, 8).map(([name, count]) =>
758
- pillButton('p'+name, truncate(name, 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
820
+ pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
759
821
  : null,
760
822
  !searching && subagentCount
761
823
  ? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
@@ -763,10 +825,10 @@ function historySide() {
763
825
  'show subagents (' + subagentCount + ')')
764
826
  : null,
765
827
  state.historyError
766
- ? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, '⚠ ' + state.historyError)
828
+ ? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, state.historyError)
767
829
  : (rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede empty-state' }, 'no sessions yet')),
768
830
  !searching && truncatedBy > 0
769
- ? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
831
+ ? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
770
832
  : null,
771
833
  ],
772
834
  }),
@@ -795,9 +857,8 @@ async function saveBackend() {
795
857
  function healthSummary() {
796
858
  const hh = state.health || {};
797
859
  const ok = hh.status === 'ok';
798
- const dot = ok ? '●' : (hh.status === 'unknown' ? '◌' : '○');
799
860
  const bits = [];
800
- bits.push(dot + ' ' + (hh.status || 'unknown'));
861
+ bits.push(hh.status || 'unknown');
801
862
  if (hh.version) bits.push('v' + hh.version);
802
863
  if (typeof hh.agents === 'number') bits.push(hh.agents + ' agents');
803
864
  if (typeof hh.activeExecutions === 'number') bits.push(hh.activeExecutions + ' active');
@@ -811,9 +872,10 @@ function settingsMain() {
811
872
  const isValid = isValidUrl(state.backendDraft);
812
873
  return [
813
874
  PageHeader({
814
- title: 'settings',
875
+ title: 'settings',
815
876
  lede: 'point agentgui at any backend. blank = same-origin (ccsniff in-process). ?backend=… or the field below persists via localStorage.',
816
877
  }),
878
+ h('div', { key: 'settings-grid', class: 'settings-grid' }, [
817
879
  Panel({
818
880
  title: 'backend',
819
881
  children: h('form', {
@@ -830,10 +892,10 @@ function settingsMain() {
830
892
  title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
831
893
  onInput: (v) => { state.backendDraft = v; render(); },
832
894
  }),
833
- !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
834
- state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting…') : null,
835
- state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
836
- state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed — check the URL') : null,
895
+ !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
896
+ state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting…') : null,
897
+ state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
898
+ state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed — check the URL') : null,
837
899
  healthSummary(),
838
900
  Btn({
839
901
  key: 'savebtn',
@@ -847,6 +909,7 @@ function settingsMain() {
847
909
  ]),
848
910
  }),
849
911
  agentsPanel(),
912
+ ]),
850
913
  ];
851
914
  }
852
915
 
@@ -914,6 +977,8 @@ async function runSearch() {
914
977
  const debouncedSearch = debounce(runSearch, 300);
915
978
 
916
979
  async function loadSession(sid) {
980
+ // Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
981
+ if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
917
982
  state.selectedSid = sid;
918
983
  state.events = [];
919
984
  state.eventsLoaded = false;
@@ -984,6 +1049,7 @@ function registerWsStatusOnce() {
984
1049
 
985
1050
  restoreChat();
986
1051
  render = mount(document.getElementById('app'), view);
1052
+ requestAnimationFrame(syncAriaCurrent);
987
1053
 
988
1054
  // Re-render on resize so isNarrow()/truncate() reflect the current width
989
1055
  // (they read window.innerWidth only at render time).