@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 +21 -0
- package/README.md +82 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +87 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
- package/ui/app.js +370 -0
- package/ui/favicon.svg +4 -0
- package/ui/index.html +10 -0
- package/ui/lib/api.js +120 -0
- package/ui/lib/avatar.js +28 -0
- package/ui/lib/dom.js +83 -0
- package/ui/styles.css +208 -0
- package/ui/views/agent.js +704 -0
- package/ui/views/dashboard.js +148 -0
- package/ui/views/plugins.js +86 -0
- package/ui/views/settings.js +365 -0
package/ui/lib/api.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Every call the page makes. Authentication is the session cookie the
|
|
2
|
+
// one-time-token exchange set, so nothing here attaches a credential — the
|
|
3
|
+
// browser does, and a 401 means the daemon restarted (sessions live in its
|
|
4
|
+
// memory) rather than that something went wrong here.
|
|
5
|
+
|
|
6
|
+
const BASE = '/api/v1';
|
|
7
|
+
|
|
8
|
+
export class ApiError extends Error {
|
|
9
|
+
constructor(status, code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const request = async (method, path, body) => {
|
|
17
|
+
const response = await fetch(`${BASE}${path}`, {
|
|
18
|
+
method,
|
|
19
|
+
headers: body === undefined ? {} : { 'content-type': 'application/json' },
|
|
20
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (response.status === 204) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
const payload = await response.json().catch(() => ({}));
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new ApiError(
|
|
29
|
+
response.status,
|
|
30
|
+
payload?.error?.code ?? 'unknown',
|
|
31
|
+
payload?.error?.message ?? `HTTP ${response.status}`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return payload;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const api = {
|
|
38
|
+
health: () => request('GET', '/health'),
|
|
39
|
+
agents: () => request('GET', '/agents'),
|
|
40
|
+
agent: (id) => request('GET', `/agents/${encodeURIComponent(id)}`),
|
|
41
|
+
createAgent: (body) => request('POST', '/agents', body),
|
|
42
|
+
updateAgent: (id, body) => request('PUT', `/agents/${encodeURIComponent(id)}`, body),
|
|
43
|
+
reloadRoster: () => request('POST', '/roster/reload'),
|
|
44
|
+
sessions: (agent, limit) => {
|
|
45
|
+
const query = new URLSearchParams();
|
|
46
|
+
if (agent) query.set('agent', agent);
|
|
47
|
+
if (limit) query.set('limit', String(limit));
|
|
48
|
+
const suffix = query.toString();
|
|
49
|
+
return request('GET', `/sessions${suffix ? `?${suffix}` : ''}`);
|
|
50
|
+
},
|
|
51
|
+
session: (id) => request('GET', `/sessions/${encodeURIComponent(id)}`),
|
|
52
|
+
send: (id, body) => request('POST', `/sessions/${encodeURIComponent(id)}/messages`, body),
|
|
53
|
+
approvals: () => request('GET', '/approvals'),
|
|
54
|
+
resolveApproval: (body) => request('POST', '/approvals', body),
|
|
55
|
+
models: () => request('GET', '/catalog/models'),
|
|
56
|
+
tools: () => request('GET', '/catalog/tools'),
|
|
57
|
+
credentials: () => request('GET', '/credentials'),
|
|
58
|
+
verifyKey: (body) => request('POST', '/credentials/verify', body),
|
|
59
|
+
storeKey: (provider, body) => request('PUT', `/credentials/${encodeURIComponent(provider)}`, body),
|
|
60
|
+
storeChannel: (channel, body) => request('PUT', `/credentials/channels/${encodeURIComponent(channel)}`, body),
|
|
61
|
+
config: () => request('GET', '/config'),
|
|
62
|
+
saveConfig: (config) => request('PUT', '/config', { config }),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The live event stream, reconnecting on its own.
|
|
67
|
+
*
|
|
68
|
+
* Frames are `{ sessionId, turnId?, event }` envelopes. The turn id is what
|
|
69
|
+
* lets a chat view attribute deltas to the message it just sent rather than
|
|
70
|
+
* to whatever else that session is doing.
|
|
71
|
+
*/
|
|
72
|
+
export const connectEvents = ({ onEnvelope, onStatus }) => {
|
|
73
|
+
let socket;
|
|
74
|
+
let closed = false;
|
|
75
|
+
let backoffMs = 500;
|
|
76
|
+
|
|
77
|
+
const open = () => {
|
|
78
|
+
if (closed) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
82
|
+
socket = new WebSocket(`${scheme}://${location.host}${BASE}/events`);
|
|
83
|
+
|
|
84
|
+
socket.addEventListener('open', () => {
|
|
85
|
+
backoffMs = 500;
|
|
86
|
+
onStatus?.('live');
|
|
87
|
+
});
|
|
88
|
+
socket.addEventListener('message', (message) => {
|
|
89
|
+
let frame;
|
|
90
|
+
try {
|
|
91
|
+
frame = JSON.parse(message.data);
|
|
92
|
+
} catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (frame.event) {
|
|
96
|
+
onEnvelope(frame);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
socket.addEventListener('close', () => {
|
|
100
|
+
onStatus?.('offline');
|
|
101
|
+
if (closed) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Backing off rather than hammering: the usual reason this closes is
|
|
105
|
+
// that stratusd stopped, and a reconnect loop at full speed against a
|
|
106
|
+
// dead port is just noise in someone's console.
|
|
107
|
+
setTimeout(open, backoffMs);
|
|
108
|
+
backoffMs = Math.min(backoffMs * 2, 15_000);
|
|
109
|
+
});
|
|
110
|
+
socket.addEventListener('error', () => socket.close());
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
open();
|
|
114
|
+
return {
|
|
115
|
+
close() {
|
|
116
|
+
closed = true;
|
|
117
|
+
socket?.close();
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
};
|
package/ui/lib/avatar.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { el } from './dom.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The agent's avatar, drawn from the palette the kernel computed.
|
|
5
|
+
*
|
|
6
|
+
* Deterministic and local: the same hue and palette that `stratus agents`
|
|
7
|
+
* prints as prose, rendered as a mark. No image is fetched — the page's CSP
|
|
8
|
+
* allows only its own origin, and an agent's identity should not depend on a
|
|
9
|
+
* network round trip.
|
|
10
|
+
*/
|
|
11
|
+
export const avatar = (agent, size = 28) => {
|
|
12
|
+
const theme = agent.avatar;
|
|
13
|
+
const initial = (agent.name ?? '?').trim().charAt(0).toUpperCase() || '?';
|
|
14
|
+
const node = el('span', { class: 'avatar', 'aria-hidden': 'true' }, initial);
|
|
15
|
+
node.style.width = `${size}px`;
|
|
16
|
+
node.style.height = `${size}px`;
|
|
17
|
+
node.style.fontSize = `${Math.round(size * 0.44)}px`;
|
|
18
|
+
|
|
19
|
+
if (theme) {
|
|
20
|
+
const [first, second] = theme.palette ?? [];
|
|
21
|
+
node.style.background = second
|
|
22
|
+
? `linear-gradient(135deg, ${first}, ${second})`
|
|
23
|
+
: (first ?? `hsl(${theme.hue} 60% 55%)`);
|
|
24
|
+
} else {
|
|
25
|
+
node.style.background = 'var(--muted-strong)';
|
|
26
|
+
}
|
|
27
|
+
return node;
|
|
28
|
+
};
|
package/ui/lib/dom.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The whole rendering layer. There is no framework here on purpose: the page
|
|
2
|
+
// has four screens and a websocket, and a build step would put a bundle
|
|
3
|
+
// between what ships and what a contributor edits.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build an element.
|
|
7
|
+
*
|
|
8
|
+
* Children are appended as text nodes unless they are already nodes — so
|
|
9
|
+
* every string that reaches the DOM does so as text, and no data path can
|
|
10
|
+
* become markup. There is deliberately no innerHTML helper in this file.
|
|
11
|
+
*/
|
|
12
|
+
export const el = (tag, props = {}, ...children) => {
|
|
13
|
+
const node = document.createElement(tag);
|
|
14
|
+
for (const [key, value] of Object.entries(props)) {
|
|
15
|
+
if (value === undefined || value === null || value === false) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (key === 'class') {
|
|
19
|
+
node.className = value;
|
|
20
|
+
} else if (key === 'dataset') {
|
|
21
|
+
Object.assign(node.dataset, value);
|
|
22
|
+
} else if (key.startsWith('on') && typeof value === 'function') {
|
|
23
|
+
node.addEventListener(key.slice(2).toLowerCase(), value);
|
|
24
|
+
} else if (key === 'value' || key === 'checked' || key === 'disabled' || key === 'selected') {
|
|
25
|
+
node[key] = value;
|
|
26
|
+
} else {
|
|
27
|
+
node.setAttribute(key, String(value));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const child of children.flat()) {
|
|
31
|
+
if (child === undefined || child === null || child === false) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
|
35
|
+
}
|
|
36
|
+
return node;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const clear = (node) => {
|
|
40
|
+
node.replaceChildren();
|
|
41
|
+
return node;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** A relative time that stays honest without a timer per row. */
|
|
45
|
+
export const ago = (iso) => {
|
|
46
|
+
if (!iso) {
|
|
47
|
+
return 'never';
|
|
48
|
+
}
|
|
49
|
+
const seconds = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
|
|
50
|
+
if (seconds < 45) return 'just now';
|
|
51
|
+
if (seconds < 90) return 'a minute ago';
|
|
52
|
+
const minutes = Math.round(seconds / 60);
|
|
53
|
+
if (minutes < 60) return `${minutes} min ago`;
|
|
54
|
+
const hours = Math.round(minutes / 60);
|
|
55
|
+
if (hours < 24) return `${hours}h ago`;
|
|
56
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const duration = (ms) => {
|
|
60
|
+
const seconds = Math.floor(ms / 1000);
|
|
61
|
+
if (seconds < 60) return `${seconds}s`;
|
|
62
|
+
const minutes = Math.floor(seconds / 60);
|
|
63
|
+
if (minutes < 60) return `${minutes}m`;
|
|
64
|
+
const hours = Math.floor(minutes / 60);
|
|
65
|
+
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
|
66
|
+
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How long is left before a deadline.
|
|
71
|
+
*
|
|
72
|
+
* `ago` is the wrong formatter for one and reads as nonsense: it clamps a
|
|
73
|
+
* future timestamp's negative elapsed time to zero, so an approval half an
|
|
74
|
+
* hour from lapsing renders as "just now". A deadline is the thing an
|
|
75
|
+
* operator is deciding against, so it gets a countdown of its own.
|
|
76
|
+
*/
|
|
77
|
+
export const until = (iso) => {
|
|
78
|
+
if (!iso) {
|
|
79
|
+
return 'no expiry';
|
|
80
|
+
}
|
|
81
|
+
const ms = Date.parse(iso) - Date.now();
|
|
82
|
+
return ms > 0 ? `in ${duration(ms)}` : 'now';
|
|
83
|
+
};
|
package/ui/styles.css
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/* Light by default, matching the design; dark follows the OS so a terminal
|
|
2
|
+
person at night is not flashbanged by their own daemon. Every colour is a
|
|
3
|
+
token so the two stay in step. */
|
|
4
|
+
:root {
|
|
5
|
+
--bg: #f6f7f9;
|
|
6
|
+
--surface: #ffffff;
|
|
7
|
+
--surface-sunken: #fafbfc;
|
|
8
|
+
--border: #e4e7ec;
|
|
9
|
+
--text: #101828;
|
|
10
|
+
--muted: #667085;
|
|
11
|
+
--muted-strong: #98a2b3;
|
|
12
|
+
--accent: #2563eb;
|
|
13
|
+
--accent-soft: #eff4ff;
|
|
14
|
+
--ok: #12b76a;
|
|
15
|
+
--warn: #f79009;
|
|
16
|
+
--danger: #d92d20;
|
|
17
|
+
--radius: 12px;
|
|
18
|
+
--shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@media (prefers-color-scheme: dark) {
|
|
22
|
+
:root {
|
|
23
|
+
--bg: #0b0e14;
|
|
24
|
+
--surface: #131720;
|
|
25
|
+
--surface-sunken: #0f131b;
|
|
26
|
+
--border: #232a36;
|
|
27
|
+
--text: #e8ecf3;
|
|
28
|
+
--muted: #94a1b5;
|
|
29
|
+
--muted-strong: #64748b;
|
|
30
|
+
--accent: #6294f8;
|
|
31
|
+
--accent-soft: #16213a;
|
|
32
|
+
--shadow: none;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
* { box-sizing: border-box; }
|
|
37
|
+
|
|
38
|
+
body {
|
|
39
|
+
margin: 0;
|
|
40
|
+
background: var(--bg);
|
|
41
|
+
color: var(--text);
|
|
42
|
+
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
43
|
+
-webkit-font-smoothing: antialiased;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.booting { padding: 48px; color: var(--muted); }
|
|
47
|
+
|
|
48
|
+
.app { display: grid; grid-template-columns: 248px minmax(0, 1fr); min-height: 100vh; }
|
|
49
|
+
|
|
50
|
+
/* ---- sidebar ---------------------------------------------------------- */
|
|
51
|
+
|
|
52
|
+
.sidebar {
|
|
53
|
+
display: flex; flex-direction: column; gap: 4px;
|
|
54
|
+
border-right: 1px solid var(--border); background: var(--surface);
|
|
55
|
+
padding: 18px 12px; position: sticky; top: 0; height: 100vh; overflow-y: auto;
|
|
56
|
+
}
|
|
57
|
+
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px 18px; font-weight: 650; font-size: 15px; }
|
|
58
|
+
.brand svg { flex: none; }
|
|
59
|
+
|
|
60
|
+
.nav-item {
|
|
61
|
+
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
62
|
+
padding: 7px 10px; border-radius: 8px; color: var(--text);
|
|
63
|
+
text-decoration: none; cursor: pointer; border: 0; background: none;
|
|
64
|
+
font: inherit; width: 100%; text-align: left;
|
|
65
|
+
}
|
|
66
|
+
.nav-item:hover { background: var(--surface-sunken); }
|
|
67
|
+
.nav-item.active { color: var(--accent); font-weight: 600; }
|
|
68
|
+
.nav-item.sub { padding-left: 24px; color: var(--muted); font-size: 13px; }
|
|
69
|
+
.nav-item.sub.active { color: var(--accent); }
|
|
70
|
+
|
|
71
|
+
.nav-section {
|
|
72
|
+
margin-top: 18px; padding: 6px 10px; font-size: 11px; letter-spacing: 0.08em;
|
|
73
|
+
text-transform: uppercase; color: var(--muted-strong); font-weight: 600;
|
|
74
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.agent-row { display: flex; align-items: center; gap: 9px; }
|
|
78
|
+
.avatar {
|
|
79
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
80
|
+
border-radius: 7px; color: #fff; font-weight: 650; flex: none;
|
|
81
|
+
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.25);
|
|
82
|
+
}
|
|
83
|
+
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); flex: none; }
|
|
84
|
+
.dot.idle { background: transparent; }
|
|
85
|
+
|
|
86
|
+
.sidebar-foot {
|
|
87
|
+
margin-top: auto; padding: 14px 10px 0; border-top: 1px solid var(--border);
|
|
88
|
+
display: flex; justify-content: space-between; color: var(--muted); font-size: 12px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ---- layout ----------------------------------------------------------- */
|
|
92
|
+
|
|
93
|
+
.main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; padding: 18px; align-items: start; }
|
|
94
|
+
.main.solo { grid-template-columns: minmax(0, 1fr); }
|
|
95
|
+
.column { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
|
|
96
|
+
|
|
97
|
+
.card {
|
|
98
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
99
|
+
border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; min-width: 0;
|
|
100
|
+
}
|
|
101
|
+
.card h2 { margin: 0 0 14px; font-size: 17px; font-weight: 650; }
|
|
102
|
+
.card h3 { margin: 18px 0 8px; font-size: 13px; font-weight: 650; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
103
|
+
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
|
104
|
+
.card-head h2 { margin: 0; }
|
|
105
|
+
|
|
106
|
+
.empty { color: var(--muted); font-size: 13px; padding: 6px 0; }
|
|
107
|
+
|
|
108
|
+
/* ---- quick stats ------------------------------------------------------ */
|
|
109
|
+
|
|
110
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 14px; }
|
|
111
|
+
.stat { background: var(--surface-sunken); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; }
|
|
112
|
+
.stat .value { font-size: 22px; font-weight: 650; }
|
|
113
|
+
.stat .label { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
|
114
|
+
.stat.bad .value { color: var(--danger); }
|
|
115
|
+
|
|
116
|
+
.banner {
|
|
117
|
+
display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-radius: 10px;
|
|
118
|
+
background: var(--accent-soft); color: var(--accent); font-size: 13px; margin-bottom: 14px;
|
|
119
|
+
}
|
|
120
|
+
.banner.bad { background: color-mix(in srgb, var(--danger) 12%, transparent); color: var(--danger); }
|
|
121
|
+
|
|
122
|
+
/* ---- lists ------------------------------------------------------------ */
|
|
123
|
+
|
|
124
|
+
.rows { display: flex; flex-direction: column; }
|
|
125
|
+
.row {
|
|
126
|
+
display: flex; align-items: center; gap: 12px; padding: 10px 0;
|
|
127
|
+
border-top: 1px solid var(--border); min-width: 0;
|
|
128
|
+
}
|
|
129
|
+
.row:first-child { border-top: 0; }
|
|
130
|
+
.row .grow { flex: 1; min-width: 0; }
|
|
131
|
+
.row .title { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
132
|
+
.row .sub { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
133
|
+
|
|
134
|
+
.pill {
|
|
135
|
+
font-size: 11px; padding: 2px 8px; border-radius: 999px; font-weight: 600;
|
|
136
|
+
background: var(--surface-sunken); color: var(--muted); border: 1px solid var(--border);
|
|
137
|
+
white-space: nowrap;
|
|
138
|
+
}
|
|
139
|
+
.pill.running { color: var(--accent); border-color: currentColor; }
|
|
140
|
+
.pill.pending_approval { color: var(--warn); border-color: currentColor; }
|
|
141
|
+
.pill.failed { color: var(--danger); border-color: currentColor; }
|
|
142
|
+
.pill.risk-dangerous { color: var(--danger); border-color: currentColor; }
|
|
143
|
+
.pill.risk-gated { color: var(--warn); border-color: currentColor; }
|
|
144
|
+
|
|
145
|
+
/* ---- controls --------------------------------------------------------- */
|
|
146
|
+
|
|
147
|
+
button, select, input, textarea {
|
|
148
|
+
font: inherit; color: var(--text); border-radius: 8px;
|
|
149
|
+
border: 1px solid var(--border); background: var(--surface); padding: 7px 10px;
|
|
150
|
+
}
|
|
151
|
+
button { cursor: pointer; }
|
|
152
|
+
button:hover:not(:disabled) { border-color: var(--muted-strong); }
|
|
153
|
+
button:disabled { opacity: 0.5; cursor: default; }
|
|
154
|
+
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
|
155
|
+
button.danger { color: var(--danger); }
|
|
156
|
+
button.small { padding: 4px 10px; font-size: 12px; }
|
|
157
|
+
textarea { resize: vertical; width: 100%; }
|
|
158
|
+
input, select { width: 100%; }
|
|
159
|
+
label { display: block; font-size: 12px; color: var(--muted); margin: 12px 0 4px; font-weight: 550; }
|
|
160
|
+
.field-note { font-size: 12px; color: var(--muted); margin-top: 6px; }
|
|
161
|
+
.actions { display: flex; gap: 8px; align-items: center; margin-top: 16px; flex-wrap: wrap; }
|
|
162
|
+
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0 16px; }
|
|
163
|
+
|
|
164
|
+
.notice { font-size: 13px; padding: 8px 12px; border-radius: 8px; margin-top: 12px; }
|
|
165
|
+
.notice.ok { background: color-mix(in srgb, var(--ok) 12%, transparent); color: var(--ok); }
|
|
166
|
+
.notice.bad { background: color-mix(in srgb, var(--danger) 12%, transparent); color: var(--danger); }
|
|
167
|
+
|
|
168
|
+
/* ---- activity --------------------------------------------------------- */
|
|
169
|
+
|
|
170
|
+
.activity { display: flex; flex-direction: column; gap: 6px; max-height: 70vh; overflow-y: auto; }
|
|
171
|
+
.activity .line { display: flex; gap: 8px; font-size: 12px; color: var(--muted); }
|
|
172
|
+
.activity .when { color: var(--muted-strong); flex: none; font-variant-numeric: tabular-nums; }
|
|
173
|
+
.activity .what { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
174
|
+
|
|
175
|
+
.stream-status { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
|
|
176
|
+
.stream-status .dot.offline { background: var(--danger); }
|
|
177
|
+
|
|
178
|
+
/* ---- chat ------------------------------------------------------------- */
|
|
179
|
+
|
|
180
|
+
.chat { display: flex; flex-direction: column; height: calc(100vh - 210px); min-height: 380px; }
|
|
181
|
+
.transcript { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding-right: 4px; }
|
|
182
|
+
.bubble { max-width: 78%; padding: 9px 13px; border-radius: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
183
|
+
.bubble.user { align-self: flex-end; background: var(--accent); color: #fff; }
|
|
184
|
+
.bubble.assistant { align-self: flex-start; background: var(--surface-sunken); border: 1px solid var(--border); }
|
|
185
|
+
.bubble.tool { align-self: flex-start; background: none; border: 1px dashed var(--border); color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
186
|
+
.bubble.streaming::after { content: '▍'; opacity: 0.6; }
|
|
187
|
+
.composer { display: flex; gap: 8px; margin-top: 14px; align-items: flex-end; }
|
|
188
|
+
.composer textarea { min-height: 44px; max-height: 160px; }
|
|
189
|
+
|
|
190
|
+
.approval {
|
|
191
|
+
border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px;
|
|
192
|
+
display: flex; flex-direction: column; gap: 8px; background: var(--surface-sunken);
|
|
193
|
+
}
|
|
194
|
+
.approval .cmd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; overflow-wrap: anywhere; }
|
|
195
|
+
|
|
196
|
+
/* A config snippet somebody is meant to copy: readable, and scrollable
|
|
197
|
+
rather than reflowed, because a wrapped JSON line is a wrong JSON line. */
|
|
198
|
+
pre.cmd {
|
|
199
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
|
|
200
|
+
background: var(--surface-sunken); border: 1px solid var(--border); border-radius: 8px;
|
|
201
|
+
padding: 10px 12px; overflow-x: auto; margin: 10px 0 0;
|
|
202
|
+
}
|
|
203
|
+
.approvals-list { display: flex; flex-direction: column; gap: 10px; max-height: 320px; overflow-y: auto; }
|
|
204
|
+
|
|
205
|
+
@media (max-width: 1100px) {
|
|
206
|
+
.main { grid-template-columns: minmax(0, 1fr); }
|
|
207
|
+
.app { grid-template-columns: 200px minmax(0, 1fr); }
|
|
208
|
+
}
|