@stratusagent/dashboard 0.6.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stratus Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @stratusagent/dashboard
2
+
3
+ The Stratus web dashboard: the roster, live chat with your agents, pending
4
+ approvals, and settings — served by `@stratusagent/control-api` at `/`.
5
+
6
+ ```bash
7
+ npm install -g @stratusagent/control-api @stratusagent/dashboard
8
+ stratus dashboard
9
+ ```
10
+
11
+ `stratus dashboard` finds a running daemon (or starts one), mints a
12
+ single-use sign-in link, and opens your browser at it.
13
+
14
+ ## Why it is a separate package
15
+
16
+ The control API has three consumers and only one of them is a web page. The
17
+ macOS app (roadmap step 07) is deliberately *not* a chat app, and a headless
18
+ VM deployment wants the API with no UI at all. Keeping the UI here means both
19
+ of those install the API alone, and this package can grow without weighting
20
+ them.
21
+
22
+ The API auto-loads it: if this package resolves, the dashboard is served. Pass
23
+ `ui: false` to `createControlApi` to run the API bare on a machine that has it
24
+ installed anyway.
25
+
26
+ ## No build step
27
+
28
+ The `ui/` directory is what ships: hand-written HTML, CSS, and ES modules, no
29
+ bundler and no framework. What a contributor edits is exactly what a user
30
+ loads, and there is no build output to go stale against the source.
31
+
32
+ That is a real constraint, not a preference — it is why the rendering layer is
33
+ a hundred lines of `el()` in `ui/lib/dom.js` rather than a dependency, and why
34
+ every string reaches the DOM as a text node. There is deliberately no
35
+ `innerHTML` helper in this package.
36
+
37
+ ```
38
+ ui/
39
+ index.html the shell
40
+ styles.css tokens, light and dark
41
+ app.js store, router, live stream, sidebar
42
+ lib/ dom helpers, the API client, avatars
43
+ views/ dashboard, agent, settings, plugins
44
+ ```
45
+
46
+ ## What the screens do
47
+
48
+ **Dashboard** — quick stats from `GET /health` (agents, running turns, stored
49
+ sessions, waiting approvals, uptime, and what the daemon is serving with
50
+ which credentials), the approvals panel when anything is parked, recent
51
+ conversations, and a live activity feed.
52
+
53
+ **Agent** — one agent, three tabs. *Chat* streams a conversation as it
54
+ happens: text arrives token by token, tool calls appear as status lines, and
55
+ the transcript is re-read from the daemon when the turn settles so the page
56
+ never quietly disagrees with what is stored. *Activity* is the same event
57
+ stream filtered to that agent. *Settings* edits the soul — name, persona,
58
+ provider and model pins — through a validated round-trip.
59
+
60
+ **Settings** — provider sign-ins (with a live key check before storing),
61
+ the default model listed from what those sign-ins can actually reach, and
62
+ Slack channel tokens.
63
+
64
+ **Plugins** — an honest placeholder. Tool packs are roadmap step 06.
65
+
66
+ ## Two things the page will not do
67
+
68
+ - **It never renders a secret.** `GET /credentials` reports that a sign-in
69
+ exists, its type, and its endpoint. There is nothing to show and nothing to
70
+ leak.
71
+ - **It only talks to its own origin.** The page is served with a
72
+ `default-src 'self'` content security policy — no external scripts, styles,
73
+ images, or connections. A local dashboard holding a live session against
74
+ your daemon is not a place to be relaxed about that.
75
+
76
+ ## When the daemon restarts
77
+
78
+ Sessions live in the daemon's memory, so restarting it signs the browser out
79
+ and the page says so rather than rendering an empty roster that looks like a
80
+ fleet that vanished. Run `stratus dashboard` again. If the socket drops
81
+ without the daemon dying, the page reconnects on its own with a backoff and
82
+ re-reads everything it missed.
@@ -0,0 +1,17 @@
1
+ import type { ServerResponse } from 'node:http';
2
+ export interface DashboardAssetOptions {
3
+ /** Override the asset directory. Tests use it; nothing else should. */
4
+ root?: string;
5
+ }
6
+ /**
7
+ * Static assets for the control API to serve at `/`.
8
+ *
9
+ * Deliberately not a server: the API already owns a socket, an auth model, and
10
+ * an origin check, and a second server would need its own copy of all three.
11
+ * This is the smallest thing that can answer "is there a file for this path".
12
+ */
13
+ export declare const createDashboardAssets: (options?: DashboardAssetOptions) => {
14
+ serve(pathname: string, response: ServerResponse): Promise<boolean>;
15
+ };
16
+ export type DashboardAssets = ReturnType<typeof createDashboardAssets>;
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAyBhD,MAAM,WAAW,qBAAqB;IACpC,uEAAuE;IACvE,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,GAAI,UAAS,qBAA0B;oBAI/C,MAAM,YAAY,cAAc,GAAG,OAAO,CAAC,OAAO,CAAC;CAuD5E,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,87 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { pipeline } from 'node:stream/promises';
6
+ /**
7
+ * Where the built UI lives, relative to this module.
8
+ *
9
+ * `ui/` is a sibling of `dist/`, and both ship in the package. The assets are
10
+ * hand-written ES modules with no build step, so what is published is exactly
11
+ * what a contributor edits — there is no bundle to go stale against the source.
12
+ */
13
+ const UI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui');
14
+ const CONTENT_TYPES = {
15
+ '.html': 'text/html; charset=utf-8',
16
+ '.js': 'text/javascript; charset=utf-8',
17
+ '.css': 'text/css; charset=utf-8',
18
+ '.svg': 'image/svg+xml',
19
+ '.png': 'image/png',
20
+ '.woff2': 'font/woff2',
21
+ '.json': 'application/json; charset=utf-8',
22
+ '.ico': 'image/x-icon',
23
+ };
24
+ /**
25
+ * Static assets for the control API to serve at `/`.
26
+ *
27
+ * Deliberately not a server: the API already owns a socket, an auth model, and
28
+ * an origin check, and a second server would need its own copy of all three.
29
+ * This is the smallest thing that can answer "is there a file for this path".
30
+ */
31
+ export const createDashboardAssets = (options = {}) => {
32
+ const root = options.root ?? UI_ROOT;
33
+ return {
34
+ async serve(pathname, response) {
35
+ // A single-page app: every unknown path is a route the client renders,
36
+ // so the shell answers for all of them. Real assets are matched first.
37
+ const requested = pathname === '/' ? '/index.html' : pathname;
38
+ let decoded;
39
+ try {
40
+ decoded = decodeURIComponent(requested);
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ // Resolved and then checked, not sanitised. Stripping `..` by hand is a
46
+ // game of encodings nobody wins; asking whether the resolved path is
47
+ // still inside the asset directory is a question with one answer.
48
+ const candidate = path.resolve(root, `.${path.posix.normalize(decoded)}`);
49
+ const inside = candidate === root || candidate.startsWith(`${root}${path.sep}`);
50
+ const target = inside && path.extname(candidate) !== ''
51
+ ? candidate
52
+ : path.join(root, 'index.html');
53
+ let stats;
54
+ try {
55
+ stats = await stat(target);
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ if (!stats.isFile()) {
61
+ return false;
62
+ }
63
+ response.statusCode = 200;
64
+ response.setHeader('content-type', CONTENT_TYPES[path.extname(target)] ?? 'application/octet-stream');
65
+ response.setHeader('content-length', stats.size);
66
+ // The shell and its modules are versioned with the daemon, and a
67
+ // dashboard that keeps showing last release's UI after an upgrade is a
68
+ // bug report nobody can reproduce. Loopback makes revalidation free.
69
+ response.setHeader('cache-control', 'no-cache');
70
+ // A page that only ever talks to its own origin, with no inline script
71
+ // and nothing embeddable: say so, so a mistake later has to be
72
+ // deliberate rather than accidental.
73
+ response.setHeader('content-security-policy', [
74
+ "default-src 'self'",
75
+ "img-src 'self' data:",
76
+ "connect-src 'self'",
77
+ "frame-ancestors 'none'",
78
+ "base-uri 'none'",
79
+ "form-action 'none'",
80
+ ].join('; '));
81
+ response.setHeader('x-content-type-options', 'nosniff');
82
+ await pipeline(createReadStream(target), response);
83
+ return true;
84
+ },
85
+ };
86
+ };
87
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@stratusagent/dashboard",
3
+ "version": "0.6.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "ui"
16
+ ],
17
+ "engines": {
18
+ "node": ">=22.13 <23 || >=23.4"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^24.6.0",
22
+ "typescript": "^5.9.3"
23
+ },
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/stratuslabs/agent.git",
28
+ "directory": "packages/dashboard"
29
+ },
30
+ "description": "The Stratus web dashboard: a static, build-step-free UI served by @stratusagent/control-api — roster, live chat, approvals, and settings",
31
+ "keywords": [
32
+ "stratus",
33
+ "agent",
34
+ "ai",
35
+ "llm",
36
+ "dashboard"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -b",
43
+ "typecheck": "tsc --noEmit --project tsconfig.test.json",
44
+ "test": "node --test --experimental-strip-types ./test/**/*.test.ts"
45
+ }
46
+ }
package/ui/app.js ADDED
@@ -0,0 +1,370 @@
1
+ import { api, connectEvents, ApiError } from './lib/api.js';
2
+ import { clear, el } from './lib/dom.js';
3
+ import { avatar } from './lib/avatar.js';
4
+ import { renderDashboard } from './views/dashboard.js';
5
+ import { renderAgent } from './views/agent.js';
6
+ import { renderSettings } from './views/settings.js';
7
+ import { renderPlugins } from './views/plugins.js';
8
+
9
+ /**
10
+ * Everything the page knows, in one object.
11
+ *
12
+ * A store and a full re-render rather than fine-grained bindings: the whole
13
+ * document is a few hundred nodes, and correctness-by-rerender beats a
14
+ * diffing scheme nobody will maintain. The chat transcript is the one
15
+ * exception — it appends, because re-rendering a streaming reply per token
16
+ * would fight the scroll position.
17
+ */
18
+ export const store = {
19
+ route: { name: 'dashboard' },
20
+ health: undefined,
21
+ agents: [],
22
+ sessions: [],
23
+ approvals: [],
24
+ activity: [],
25
+ stream: 'connecting',
26
+ /** Set when the daemon stops answering, so the page says so instead of going stale. */
27
+ offlineSince: undefined,
28
+ };
29
+
30
+ const listeners = new Set();
31
+ export const subscribe = (fn) => {
32
+ listeners.add(fn);
33
+ return () => listeners.delete(fn);
34
+ };
35
+
36
+ let renderQueued = false;
37
+ export const render = () => {
38
+ // Coalesced: a burst of events during a streaming turn would otherwise
39
+ // re-render the shell per token.
40
+ if (renderQueued) {
41
+ return;
42
+ }
43
+ renderQueued = true;
44
+ queueMicrotask(() => {
45
+ renderQueued = false;
46
+ paint();
47
+ });
48
+ };
49
+
50
+ export const navigate = (route) => {
51
+ // A different session on the same agent is the same view, told to switch —
52
+ // not a new one, or opening a conversation from the dashboard would
53
+ // discard whatever the composer already had in it.
54
+ if (route.name === 'agent' && store.route.name === 'agent'
55
+ && route.agentId === store.route.agentId && route.sessionId) {
56
+ current.view?.openSession?.(route.sessionId);
57
+ }
58
+ store.route = route;
59
+ const hash = route.name === 'agent'
60
+ ? `#/agents/${encodeURIComponent(route.agentId)}`
61
+ : route.name === 'settings'
62
+ ? `#/settings/${route.section}`
63
+ : `#/${route.name}`;
64
+ if (location.hash !== hash) {
65
+ history.pushState(null, '', hash);
66
+ }
67
+ render();
68
+ void refreshForRoute();
69
+ };
70
+
71
+ const routeFromHash = () => {
72
+ const parts = location.hash.replace(/^#\/?/, '').split('/').filter(Boolean);
73
+ if (parts[0] === 'agents' && parts[1]) {
74
+ return { name: 'agent', agentId: decodeURIComponent(parts[1]) };
75
+ }
76
+ if (parts[0] === 'settings') {
77
+ return { name: 'settings', section: parts[1] ?? 'providers' };
78
+ }
79
+ if (parts[0] === 'plugins') {
80
+ return { name: 'plugins' };
81
+ }
82
+ return { name: 'dashboard' };
83
+ };
84
+
85
+ // ---- data ----------------------------------------------------------------
86
+
87
+ /**
88
+ * Refresh the shared data every screen leans on.
89
+ *
90
+ * A 401 here means the daemon restarted — its cookie sessions live in memory —
91
+ * so the page says exactly that instead of rendering an empty roster that
92
+ * looks like a fleet that vanished.
93
+ */
94
+ export const refreshCore = async () => {
95
+ try {
96
+ const [health, agents, approvals] = await Promise.all([
97
+ api.health(),
98
+ api.agents(),
99
+ api.approvals(),
100
+ ]);
101
+ store.health = health;
102
+ store.agents = agents.agents ?? [];
103
+ store.approvals = approvals.approvals ?? [];
104
+ store.offlineSince = undefined;
105
+ } catch (error) {
106
+ if (error instanceof ApiError && error.status === 401) {
107
+ store.route = { name: 'signed-out' };
108
+ } else {
109
+ store.offlineSince = store.offlineSince ?? Date.now();
110
+ }
111
+ }
112
+ render();
113
+ };
114
+
115
+ const refreshForRoute = async () => {
116
+ if (store.route.name === 'dashboard') {
117
+ const listed = await api.sessions(undefined, 12).catch(() => ({ sessions: [] }));
118
+ store.sessions = listed.sessions ?? [];
119
+ render();
120
+ }
121
+ };
122
+
123
+ // ---- live stream ---------------------------------------------------------
124
+
125
+ const ACTIVITY_LIMIT = 200;
126
+
127
+ /**
128
+ * Which session belongs to which agent.
129
+ *
130
+ * Only `session.created` carries the agent, so it seeds this and everything
131
+ * later in that conversation reads from it — the same trick the daemon's own
132
+ * log writer uses, and for the same reason.
133
+ */
134
+ const agentBySession = new Map();
135
+
136
+ /**
137
+ * One line of the activity feed, or null for events that are not news.
138
+ *
139
+ * `session.updated` and `provider.response` are the loop talking to itself:
140
+ * a status of "running" the instant after "started a conversation", and a
141
+ * response that the tool lines below already describe. Rendering them fills
142
+ * the feed with restatements and pushes the things a person actually wants
143
+ * to see off the top of it.
144
+ */
145
+ const describe = (envelope) => {
146
+ const event = envelope.event;
147
+ switch (event.type) {
148
+ case 'session.created': return 'started a conversation';
149
+ case 'session.completed': return 'finished a turn';
150
+ case 'session.failed': return `turn failed — ${event.error}`;
151
+ case 'tool.called': return `ran ${event.call.toolName}`;
152
+ case 'tool.completed': return `${event.result.toolName} ${event.result.ok ? 'succeeded' : 'failed'}`;
153
+ case 'tool.denied': return `${event.call.toolName} was denied`;
154
+ case 'tool.approval-requested': return `${event.call.toolName} needs approval`;
155
+ case 'tool.approval-resolved': return `approval ${event.answer} (${event.reason})`;
156
+ default: return null;
157
+ }
158
+ };
159
+
160
+ /** Handlers a screen installs to watch its own conversation live. */
161
+ const envelopeHandlers = new Set();
162
+ export const onEnvelope = (fn) => {
163
+ envelopeHandlers.add(fn);
164
+ return () => envelopeHandlers.delete(fn);
165
+ };
166
+
167
+ /**
168
+ * How many screens are listening. Exposed so a leak is observable: a view
169
+ * that is replaced without being torn down keeps updating detached DOM and
170
+ * re-fetching on every completed turn, and the only symptom is a page that
171
+ * gets slower the more of it you have used.
172
+ */
173
+ export const envelopeHandlerCount = () => envelopeHandlers.size;
174
+
175
+ const handleEnvelope = (envelope) => {
176
+ for (const handler of envelopeHandlers) {
177
+ handler(envelope);
178
+ }
179
+
180
+ if (envelope.event.type === 'session.created') {
181
+ agentBySession.set(envelope.sessionId, envelope.event.agentId);
182
+ }
183
+
184
+ // Deltas are a per-token firehose. They drive the transcript through the
185
+ // handlers above; putting each one in an activity feed would render nothing
186
+ // but noise and pin the CPU.
187
+ const text = describe(envelope);
188
+ if (text) {
189
+ const agentId = agentBySession.get(envelope.sessionId)
190
+ ?? store.sessions.find((session) => session.id === envelope.sessionId)?.agentId;
191
+ store.activity.unshift({
192
+ at: Date.now(),
193
+ sessionId: envelope.sessionId,
194
+ agent: store.agents.find((candidate) => candidate.id === agentId)?.name ?? agentId,
195
+ text,
196
+ });
197
+ store.activity.length = Math.min(store.activity.length, ACTIVITY_LIMIT);
198
+ }
199
+
200
+ // Approvals and roster activity are shared state, so they refresh for
201
+ // everyone rather than only for whoever is looking at the right screen.
202
+ const type = envelope.event.type;
203
+ if (type === 'tool.approval-requested' || type === 'tool.approval-resolved') {
204
+ void api.approvals().then((listed) => {
205
+ store.approvals = listed.approvals ?? [];
206
+ render();
207
+ }).catch(() => {});
208
+ }
209
+ if (type === 'session.completed' || type === 'session.failed' || type === 'session.created') {
210
+ void refreshCore();
211
+ void refreshForRoute();
212
+ }
213
+ render();
214
+ };
215
+
216
+ // ---- shell ---------------------------------------------------------------
217
+
218
+ const logo = () => {
219
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
220
+ svg.setAttribute('width', '22');
221
+ svg.setAttribute('height', '22');
222
+ svg.setAttribute('viewBox', '0 0 24 24');
223
+ svg.setAttribute('fill', 'none');
224
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
225
+ path.setAttribute('d', 'M3 15a4 4 0 0 1 3.2-3.9A5.5 5.5 0 0 1 17 10.3 3.6 3.6 0 0 1 20.5 15 3.5 3.5 0 0 1 17 18H6.5A3.5 3.5 0 0 1 3 15Z');
226
+ path.setAttribute('stroke', 'currentColor');
227
+ path.setAttribute('stroke-width', '1.6');
228
+ path.setAttribute('stroke-linejoin', 'round');
229
+ svg.append(path);
230
+ return svg;
231
+ };
232
+
233
+ /**
234
+ * Whether an agent counts as active right now.
235
+ *
236
+ * The API reports a timestamp and a count, deliberately not a verdict — the
237
+ * window is a rendering decision, and it lives here so changing it does not
238
+ * mean upgrading a daemon. Both inputs matter: a turn parked on a human has
239
+ * not saved since it parked, so the timestamp alone would call it idle at
240
+ * exactly the moment it most needs attention.
241
+ */
242
+ const ACTIVE_WINDOW_MS = 10 * 60_000;
243
+ export const isActive = (agent) =>
244
+ (agent.activeSessions ?? 0) > 0
245
+ || (agent.lastActiveAt !== undefined && Date.now() - Date.parse(agent.lastActiveAt) < ACTIVE_WINDOW_MS);
246
+
247
+ const sidebar = () => {
248
+ const route = store.route;
249
+ const item = (label, target, extra = {}) => el('button', {
250
+ class: `nav-item${extra.sub ? ' sub' : ''}${extra.active ? ' active' : ''}`,
251
+ onClick: () => navigate(target),
252
+ }, extra.children ?? label);
253
+
254
+ return el('nav', { class: 'sidebar' },
255
+ el('div', { class: 'brand' }, logo(), 'Stratus Agent'),
256
+ item('Dashboard', { name: 'dashboard' }, { active: route.name === 'dashboard' }),
257
+ item('Plugins', { name: 'plugins' }, { active: route.name === 'plugins' }),
258
+ el('div', { class: 'nav-section' }, 'Settings'),
259
+ ...['providers', 'models', 'channels'].map((section) => item(
260
+ section.charAt(0).toUpperCase() + section.slice(1),
261
+ { name: 'settings', section },
262
+ { sub: true, active: route.name === 'settings' && route.section === section },
263
+ )),
264
+ el('div', { class: 'nav-section' }, 'Agents'),
265
+ ...store.agents.map((agent) => item(agent.name, { name: 'agent', agentId: agent.id }, {
266
+ active: route.name === 'agent' && route.agentId === agent.id,
267
+ children: el('span', { class: 'agent-row' },
268
+ avatar(agent),
269
+ el('span', { class: 'grow' }, agent.name),
270
+ el('span', { class: `dot${isActive(agent) ? '' : ' idle'}`, title: isActive(agent) ? 'active recently' : 'idle' }),
271
+ ),
272
+ })),
273
+ el('div', { class: 'sidebar-foot' },
274
+ el('span', {}, 'Stratus Agent'),
275
+ el('span', {}, `v${store.health?.version ?? '—'}`),
276
+ ),
277
+ );
278
+ };
279
+
280
+ const signedOut = () => el('div', { class: 'main solo' },
281
+ el('div', { class: 'card' },
282
+ el('h2', {}, 'Not signed in'),
283
+ el('p', {}, 'This browser has no session with stratusd. Sessions live in the daemon’s memory, so restarting it signs you out.'),
284
+ el('p', { class: 'field-note' }, 'Run stratus dashboard in a terminal to open a fresh one.'),
285
+ ),
286
+ );
287
+
288
+ /**
289
+ * A view is `{ node, update? }`, and the difference matters.
290
+ *
291
+ * Rebuilding the whole document on every store change is fine for the shell,
292
+ * which holds no state a person can lose. It is wrong for a screen with a
293
+ * text box and a streaming transcript: a repaint per token would take the
294
+ * caret out of the composer mid-sentence and rebuild a reply as it arrives.
295
+ * So a view is constructed once per route and told to update itself, and it
296
+ * decides what is safe to touch.
297
+ */
298
+ let current = { key: undefined, view: undefined };
299
+
300
+ const routeKey = (route) => route.name === 'agent'
301
+ ? `agent:${route.agentId}`
302
+ : route.name === 'settings'
303
+ ? `settings:${route.section}`
304
+ : route.name;
305
+
306
+ const buildView = (route) => {
307
+ switch (route.name) {
308
+ case 'agent': return renderAgent(route.agentId, route.sessionId);
309
+ case 'settings': return renderSettings(route.section);
310
+ case 'plugins': return renderPlugins();
311
+ default: return renderDashboard();
312
+ }
313
+ };
314
+
315
+ const paint = () => {
316
+ const root = document.getElementById('app');
317
+
318
+ if (store.route.name === 'signed-out') {
319
+ current.view?.destroy?.();
320
+ current = { key: undefined, view: undefined };
321
+ root.replaceChildren(signedOut());
322
+ return;
323
+ }
324
+
325
+ const key = routeKey(store.route);
326
+ if (key !== current.key || !current.view) {
327
+ // Torn down before it is dropped. The agent view subscribes to the event
328
+ // stream; without this, every agent ever visited keeps a live handler
329
+ // updating detached DOM and re-fetching transcripts on every completed
330
+ // turn — work that multiplies with each screen someone opens.
331
+ current.view?.destroy?.();
332
+ current = { key, view: buildView(store.route) };
333
+ } else {
334
+ current.view.update?.();
335
+ }
336
+ root.replaceChildren(sidebar(), current.view.node);
337
+ };
338
+
339
+ // ---- boot ----------------------------------------------------------------
340
+
341
+ window.addEventListener('popstate', () => {
342
+ store.route = routeFromHash();
343
+ render();
344
+ void refreshForRoute();
345
+ });
346
+
347
+ store.route = routeFromHash();
348
+ await refreshCore();
349
+ await refreshForRoute();
350
+
351
+ connectEvents({
352
+ onEnvelope: handleEnvelope,
353
+ onStatus: (status) => {
354
+ store.stream = status;
355
+ if (status === 'live') {
356
+ // Reconnected: whatever happened while the socket was down is only in
357
+ // the store, so re-read rather than leaving a stale page behind.
358
+ void refreshCore();
359
+ void refreshForRoute();
360
+ // Shared data is not enough for a view holding a turn of its own. A
361
+ // completion that arrived during the outage is never replayed, so a
362
+ // chat view would sit disabled on "Working…" for a turn that finished
363
+ // minutes ago.
364
+ void current.view?.reconcile?.();
365
+ }
366
+ render();
367
+ },
368
+ });
369
+
370
+ render();
package/ui/favicon.svg ADDED
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
2
+ <rect width="24" height="24" rx="5" fill="#101828"/>
3
+ <path d="M4 15.2a3.6 3.6 0 0 1 2.9-3.5 5 5 0 0 1 9.7-1.6 3.3 3.3 0 0 1 3.2 4.3A3.2 3.2 0 0 1 16.8 17H7.2A3.2 3.2 0 0 1 4 15.2Z" stroke="#6294f8" stroke-width="1.5" stroke-linejoin="round"/>
4
+ </svg>
package/ui/index.html ADDED
@@ -0,0 +1,10 @@
1
+ <!doctype html>
2
+ <meta charset="utf-8" />
3
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
4
+ <title>Stratus Agent</title>
5
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
6
+ <link rel="stylesheet" href="/styles.css" />
7
+ <div id="app" class="app">
8
+ <div class="booting">Connecting to stratusd…</div>
9
+ </div>
10
+ <script type="module" src="/app.js"></script>