@edryslabs/edrys-dev 0.1.2 → 0.3.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/README.md +175 -42
- package/dist/chat.js +353 -28
- package/dist/index.js +29 -5
- package/dist/init.js +108 -0
- package/dist/mcp.js +319 -38
- package/dist/modules.js +14 -3
- package/dist/origin.js +55 -3
- package/dist/server.js +28 -14
- package/dist/sync.js +9 -3
- package/dist/watch.js +18 -0
- package/package.json +2 -2
- package/views/panel.js +119 -11
package/dist/origin.js
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
const WILDCARDS = ['0.0.0.0', '::'];
|
|
3
|
+
/**
|
|
4
|
+
* The host names a server listening on `host` answers to: this machine's own,
|
|
5
|
+
* plus every address of its network interfaces when it listens on all of them.
|
|
6
|
+
* A page that rebinds its own domain to 127.0.0.1 sends that domain instead.
|
|
7
|
+
*/
|
|
8
|
+
export function hostNames(host) {
|
|
9
|
+
const names = ['localhost', '127.0.0.1', '[::1]'];
|
|
10
|
+
const add = (address) => names.push(address.includes(':') ? `[${address}]` : address);
|
|
11
|
+
if (WILDCARDS.includes(host)) {
|
|
12
|
+
for (const addresses of Object.values(os.networkInterfaces())) {
|
|
13
|
+
for (const { address } of addresses ?? [])
|
|
14
|
+
add(address);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
add(host);
|
|
19
|
+
}
|
|
20
|
+
return [...new Set(names)];
|
|
21
|
+
}
|
|
22
|
+
/** Whether `host` listens on more than this machine's loopback addresses. */
|
|
23
|
+
export function reachable(host) {
|
|
24
|
+
return !['localhost', '127.0.0.1', '::1', '[::1]'].includes(host);
|
|
25
|
+
}
|
|
26
|
+
// `value` (a Host, or an Origin without its scheme) is one of `names` on this server's port
|
|
27
|
+
function ours(req, names, value) {
|
|
28
|
+
const port = req.socket.localPort;
|
|
29
|
+
// browsers leave out the default port
|
|
30
|
+
return names.some((name) => value === `${name}:${port}` || (port === 80 && value === name));
|
|
31
|
+
}
|
|
32
|
+
/** Refuses requests whose Host header is not one of `names` on this server's port. */
|
|
33
|
+
export function hostGuard(names) {
|
|
34
|
+
return (req, res, next) => (ours(req, names, req.headers.host) ? next() : res.sendStatus(403));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether a WebSocket upgrade comes from one of edrys-dev's own pages. Browsers
|
|
38
|
+
* send an Origin with every WebSocket, and let any site open one; a student
|
|
39
|
+
* tab (127.0.0.1) connects to the owner's address (localhost), so any of
|
|
40
|
+
* `names` will do, not only the Host.
|
|
41
|
+
*/
|
|
42
|
+
export function ownPage(req, names) {
|
|
3
43
|
const origin = req.headers.origin;
|
|
4
|
-
return
|
|
44
|
+
return (ours(req, names, req.headers.host) &&
|
|
45
|
+
typeof origin === 'string' &&
|
|
46
|
+
origin.startsWith('http://') &&
|
|
47
|
+
ours(req, names, origin.slice('http://'.length)));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Posts from edrys-Lite's page on this server only: browsers always send an
|
|
51
|
+
* Origin with a POST, so one without it is no page of ours (another local user,
|
|
52
|
+
* another program). Pages of another origin, e.g. a module's own site, are
|
|
53
|
+
* refused too. Run after hostGuard, which vouches for the Host.
|
|
54
|
+
*/
|
|
55
|
+
export function sameOrigin(req) {
|
|
56
|
+
return req.headers.origin === `http://${req.headers.host}`;
|
|
5
57
|
}
|
package/dist/server.js
CHANGED
|
@@ -6,10 +6,10 @@ import { EventEmitter } from 'node:events';
|
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
7
|
import express from 'express';
|
|
8
8
|
import { unpack } from 'msgpackr';
|
|
9
|
-
import { watchFile, watchModules } from './watch.js';
|
|
9
|
+
import { readSaved, watchFile, watchModules } from './watch.js';
|
|
10
10
|
import { moduleApp } from './modules.js';
|
|
11
|
-
import { sameOrigin } from './origin.js';
|
|
12
|
-
import { Chat, panelFiles, panelScript } from './chat.js';
|
|
11
|
+
import { hostGuard, hostNames, sameOrigin } from './origin.js';
|
|
12
|
+
import { Chat, explain, panelFiles, panelScript } from './chat.js';
|
|
13
13
|
// entries the log keeps, oldest first out
|
|
14
14
|
const LOG_MAX = 300;
|
|
15
15
|
// a body in the log is cut to this many characters of JSON
|
|
@@ -50,6 +50,9 @@ export function edrysDist() {
|
|
|
50
50
|
*/
|
|
51
51
|
export async function startDevServer(options) {
|
|
52
52
|
const app = express();
|
|
53
|
+
// a page that rebinds its own domain to this machine would pass sameOrigin
|
|
54
|
+
const names = hostNames(options.host);
|
|
55
|
+
app.use(hostGuard(names));
|
|
53
56
|
const bus = new EventEmitter();
|
|
54
57
|
const clients = new Set();
|
|
55
58
|
let share = null;
|
|
@@ -74,7 +77,7 @@ export async function startDevServer(options) {
|
|
|
74
77
|
app.get('/edrys-dev/lab', async (req, res) => {
|
|
75
78
|
res.json({
|
|
76
79
|
path: options.lab,
|
|
77
|
-
text: await
|
|
80
|
+
text: await readSaved(options.lab),
|
|
78
81
|
// Without it the class keeps its own (or edrys-Lite's default) transport.
|
|
79
82
|
communication: options.local
|
|
80
83
|
? { communicationMethod: 'Websocket', websocketUrl: `ws://${req.headers.host}` }
|
|
@@ -125,6 +128,11 @@ export async function startDevServer(options) {
|
|
|
125
128
|
const { type, ...state } = report;
|
|
126
129
|
classState = state;
|
|
127
130
|
}
|
|
131
|
+
// the user may not see the terminal or the agent: the chat panel says it too
|
|
132
|
+
if (report.type === 'error')
|
|
133
|
+
chat.labProblem(explain(String(report.message)));
|
|
134
|
+
if (report.type === 'applied')
|
|
135
|
+
chat.labProblem(null);
|
|
128
136
|
if (report.type === 'messages' && Array.isArray(report.messages)) {
|
|
129
137
|
for (const m of report.messages)
|
|
130
138
|
addLog({ ...m, body: readableBody(m.body), kind: 'message' });
|
|
@@ -134,11 +142,15 @@ export async function startDevServer(options) {
|
|
|
134
142
|
res.sendStatus(204);
|
|
135
143
|
});
|
|
136
144
|
// The error reporter of local modules (see modules.ts): from the module
|
|
137
|
-
// server's origin, or "null" for a module edrys-Lite inlined.
|
|
145
|
+
// server's origin, or "null" for a module edrys-Lite inlined. Any page can
|
|
146
|
+
// be "null" (a sandboxed frame), so the reporter also carries the token,
|
|
147
|
+
// which only local module pages hold.
|
|
138
148
|
let moduleOrigins = [];
|
|
139
|
-
|
|
140
|
-
|
|
149
|
+
const reportToken = crypto.randomBytes(16).toString('hex');
|
|
150
|
+
app.post('/edrys-dev/module-error/:token', express.text({ type: () => true }), (req, res) => {
|
|
151
|
+
if (req.params.token !== reportToken || !moduleOrigins.includes(String(req.headers.origin))) {
|
|
141
152
|
return res.sendStatus(403);
|
|
153
|
+
}
|
|
142
154
|
let error;
|
|
143
155
|
try {
|
|
144
156
|
error = JSON.parse(req.body);
|
|
@@ -157,7 +169,7 @@ export async function startDevServer(options) {
|
|
|
157
169
|
bus.emit('report', { type: 'module-error', ...entry });
|
|
158
170
|
res.sendStatus(204);
|
|
159
171
|
});
|
|
160
|
-
const chat = new Chat(path.join(path.dirname(options.lab), '.edrys-dev',
|
|
172
|
+
const chat = new Chat(path.join(path.dirname(options.lab), '.edrys-dev'), SETTLE_MS);
|
|
161
173
|
// without an agent, nobody would answer
|
|
162
174
|
if (options.agent)
|
|
163
175
|
chat.routes(app);
|
|
@@ -170,7 +182,7 @@ export async function startDevServer(options) {
|
|
|
170
182
|
app.use(express.static(edrysDist()));
|
|
171
183
|
const server = http.createServer(app);
|
|
172
184
|
// Loaded only when used: @edryslabs/websocket-server is an optional dependency.
|
|
173
|
-
const stopSync = options.local ? (await import('./sync.js')).attachSync(server) : () => { };
|
|
185
|
+
const stopSync = options.local ? (await import('./sync.js')).attachSync(server, names) : () => { };
|
|
174
186
|
const send = (event, data) => {
|
|
175
187
|
for (const client of clients)
|
|
176
188
|
client.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
@@ -201,15 +213,16 @@ export async function startDevServer(options) {
|
|
|
201
213
|
const dir = options.modules;
|
|
202
214
|
// the class tab may use either local address
|
|
203
215
|
const pageOrigins = [...new Set(['localhost', '127.0.0.1', options.host].map((host) => `http://${host}:${port}`))];
|
|
204
|
-
const moduleServer = http.createServer(moduleApp(dir, `http://localhost:${port}/edrys-dev/module-error`, pageOrigins));
|
|
216
|
+
const moduleServer = http.createServer(moduleApp(dir, `http://localhost:${port}/edrys-dev/module-error/${reportToken}`, pageOrigins, names));
|
|
205
217
|
const modulePort = await listen(moduleServer, port + 1, options.host);
|
|
206
218
|
modules = { dir, url: `http://localhost:${modulePort}/` };
|
|
207
219
|
moduleOrigins = [`http://localhost:${modulePort}`, `http://127.0.0.1:${modulePort}`, 'null'];
|
|
208
220
|
const stopWatchingModules = watchModules(dir, (name) => {
|
|
209
|
-
// the lab may name either local address
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
221
|
+
// the lab may name either local address; one event, so the class tab
|
|
222
|
+
// scrapes the module once
|
|
223
|
+
send('reload-module', {
|
|
224
|
+
urls: ['localhost', '127.0.0.1'].map((host) => `http://${host}:${modulePort}/${name}/`),
|
|
225
|
+
});
|
|
213
226
|
for (const other of others)
|
|
214
227
|
other.write('event: reload-module\ndata: {}\n\n');
|
|
215
228
|
bus.emit('module-changed', name);
|
|
@@ -239,6 +252,7 @@ export async function startDevServer(options) {
|
|
|
239
252
|
client.end();
|
|
240
253
|
server.closeAllConnections();
|
|
241
254
|
await new Promise((resolve) => server.close(resolve));
|
|
255
|
+
await chat.flush();
|
|
242
256
|
},
|
|
243
257
|
};
|
|
244
258
|
}
|
package/dist/sync.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { WebSocketServer } from 'ws';
|
|
2
2
|
import { setupWSConnection } from '@edryslabs/websocket-server/utils';
|
|
3
|
+
import { ownPage } from './origin.js';
|
|
3
4
|
/**
|
|
4
5
|
* Yjs sync on the dev server's own port (`--local`), as in the edrys
|
|
5
|
-
* websocket-server: every path is a doc named after it.
|
|
6
|
-
*
|
|
6
|
+
* websocket-server: every path is a doc named after it. Only edrys-dev's own
|
|
7
|
+
* pages may connect (`names`, see hostGuard), not any site the user visits.
|
|
8
|
+
* Returns a function that shuts it down.
|
|
7
9
|
*/
|
|
8
|
-
export function attachSync(server) {
|
|
10
|
+
export function attachSync(server, names) {
|
|
9
11
|
const docs = new WebSocketServer({ noServer: true });
|
|
10
12
|
docs.on('connection', setupWSConnection);
|
|
11
13
|
server.on('upgrade', (request, socket, head) => {
|
|
14
|
+
if (!ownPage(request, names)) {
|
|
15
|
+
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
12
18
|
docs.handleUpgrade(request, socket, head, (ws) => docs.emit('connection', ws, request));
|
|
13
19
|
});
|
|
14
20
|
return () => docs.close();
|
package/dist/watch.js
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { watch } from 'chokidar';
|
|
4
|
+
/**
|
|
5
|
+
* Reads `file`; while it is missing, tries again for up to `ms`: a save that
|
|
6
|
+
* renames a copy over it, or deletes and writes it, leaves it gone a moment.
|
|
7
|
+
*/
|
|
8
|
+
export async function readSaved(file, ms = 500) {
|
|
9
|
+
const end = Date.now() + ms;
|
|
10
|
+
for (;;) {
|
|
11
|
+
try {
|
|
12
|
+
return await fs.readFile(file, 'utf8');
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code !== 'ENOENT' || Date.now() >= end)
|
|
16
|
+
throw error;
|
|
17
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
3
21
|
/**
|
|
4
22
|
* Calls `onChange` once per save of `file`: editors often write a file in
|
|
5
23
|
* several steps (truncate + write, or write a copy and rename it over).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edryslabs/edrys-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Develop edrys-Lite labs locally: serves edrys-Lite, syncs on its own port and republishes the class whenever the lab file changes",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"homepage": "https://github.com/edrys-labs/edrys-devserver#readme",
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@edryslabs/edrys-lite": "3.2.0-dev.
|
|
39
|
+
"@edryslabs/edrys-lite": "3.2.0-dev.1",
|
|
40
40
|
"chokidar": "^4.0.3",
|
|
41
41
|
"dompurify": "^3.4.17",
|
|
42
42
|
"express": "^5.1.0",
|
package/views/panel.js
CHANGED
|
@@ -49,10 +49,14 @@
|
|
|
49
49
|
}
|
|
50
50
|
.panel.open { display: flex; }
|
|
51
51
|
header {
|
|
52
|
-
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
|
53
|
-
font-size: 14px; font-weight: 600; display: flex;
|
|
52
|
+
padding: 8px 10px 8px 14px; border-bottom: 1px solid var(--line);
|
|
53
|
+
font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px;
|
|
54
|
+
}
|
|
55
|
+
header small { font-weight: 400; color: var(--muted); white-space: nowrap; }
|
|
56
|
+
header select {
|
|
57
|
+
flex: 1; min-width: 0; font-size: 13px; padding: 3px 4px; border-radius: 6px;
|
|
58
|
+
border: 1px solid var(--line); background: var(--bg); color: var(--fg);
|
|
54
59
|
}
|
|
55
|
-
header small { font-weight: 400; color: var(--muted); }
|
|
56
60
|
.log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
57
61
|
.msg { max-width: 88%; padding: 8px 11px; border-radius: 10px; font-size: 14px; line-height: 1.4;
|
|
58
62
|
overflow-wrap: anywhere; }
|
|
@@ -71,7 +75,16 @@
|
|
|
71
75
|
border: 1px solid var(--accent); background: transparent; color: var(--accent);
|
|
72
76
|
}
|
|
73
77
|
.msg form[aria-disabled="true"] { opacity: .5; pointer-events: none; }
|
|
78
|
+
.problem { margin: 0 12px 8px; padding: 8px 10px; border-radius: 8px; font-size: 13px; line-height: 1.4;
|
|
79
|
+
border: 1px solid #e0452b; background: rgba(224,69,43,.1); }
|
|
80
|
+
.problem[hidden] { display: none; }
|
|
81
|
+
.problem summary { cursor: pointer; color: var(--muted); font-size: 12px; margin-top: 4px; }
|
|
82
|
+
.problem pre { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, monospace; }
|
|
74
83
|
.status { padding: 0 12px 6px; font-size: 12px; color: var(--muted); min-height: 1em; }
|
|
84
|
+
.hint { padding: 0 12px 6px; font-size: 12px; color: var(--muted); font-style: italic; }
|
|
85
|
+
.hint:empty { display: none; }
|
|
86
|
+
.reason { margin-top: 4px; font-size: 12px; color: var(--muted); }
|
|
87
|
+
.skipped { align-self: center; font-size: 12px; color: var(--muted); }
|
|
75
88
|
.compose { display: flex; gap: 6px; padding: 8px; border-top: 1px solid var(--line); }
|
|
76
89
|
textarea {
|
|
77
90
|
flex: 1; resize: none; height: 40px; padding: 8px; font-size: 14px; border-radius: 8px;
|
|
@@ -92,9 +105,16 @@
|
|
|
92
105
|
🤖<span class="dot"></span>
|
|
93
106
|
</button>
|
|
94
107
|
<section class="panel" id="panel" aria-label="Agent chat">
|
|
95
|
-
<header>
|
|
108
|
+
<header>
|
|
109
|
+
Agent
|
|
110
|
+
<label for="sessions" hidden>Chats</label>
|
|
111
|
+
<select id="sessions"></select>
|
|
112
|
+
<small title="edrys-dev: not the class's chat">only you see this</small>
|
|
113
|
+
</header>
|
|
96
114
|
<div class="log" role="log" aria-live="polite"></div>
|
|
115
|
+
<div class="problem" role="alert" hidden></div>
|
|
97
116
|
<div class="status" aria-live="polite"></div>
|
|
117
|
+
<div class="hint" aria-live="polite"></div>
|
|
98
118
|
<form class="compose">
|
|
99
119
|
<label for="text" hidden>Message to the agent</label>
|
|
100
120
|
<textarea id="text" placeholder="Message to the agent (Enter sends)"></textarea>
|
|
@@ -108,10 +128,40 @@
|
|
|
108
128
|
const panel = root.querySelector('.panel')
|
|
109
129
|
const log = root.querySelector('.log')
|
|
110
130
|
const statusLine = root.querySelector('.status')
|
|
131
|
+
const hintLine = root.querySelector('.hint')
|
|
132
|
+
const problemBox = root.querySelector('.problem')
|
|
111
133
|
const compose = root.querySelector('.compose')
|
|
112
134
|
const textarea = root.querySelector('textarea')
|
|
135
|
+
const menu = root.querySelector('#sessions')
|
|
113
136
|
|
|
114
137
|
let open = null // the ask id of the form the user can still answer
|
|
138
|
+
let current = null // the chat shown, {id, title}
|
|
139
|
+
let sessions = [] // the saved chats, the one changed last first
|
|
140
|
+
|
|
141
|
+
const NEW = ''
|
|
142
|
+
// + New chat, then the chats; the current one is new until it has a file
|
|
143
|
+
function showMenu() {
|
|
144
|
+
const option = (value, text) => {
|
|
145
|
+
const o = document.createElement('option')
|
|
146
|
+
o.value = value
|
|
147
|
+
o.textContent = text
|
|
148
|
+
return o
|
|
149
|
+
}
|
|
150
|
+
const options = [option(NEW, '+ New chat')]
|
|
151
|
+
if (current && !sessions.some((s) => s.id === current.id)) options.push(option(current.id, 'New chat'))
|
|
152
|
+
for (const s of sessions) options.push(option(s.id, s.title))
|
|
153
|
+
menu.replaceChildren(...options)
|
|
154
|
+
if (current) menu.value = current.id
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
menu.addEventListener('change', async () => {
|
|
158
|
+
const response = await post({ session: menu.value === NEW ? null : menu.value })
|
|
159
|
+
// the server sends the chat it shows now; otherwise back to the one before
|
|
160
|
+
if (!response?.ok) {
|
|
161
|
+
showMenu()
|
|
162
|
+
notSent()
|
|
163
|
+
} else if (menu.value === NEW) showMenu()
|
|
164
|
+
})
|
|
115
165
|
|
|
116
166
|
function setOpen(isOpen) {
|
|
117
167
|
panel.classList.toggle('open', isOpen)
|
|
@@ -124,20 +174,30 @@
|
|
|
124
174
|
toggle.addEventListener('click', () => setOpen(!panel.classList.contains('open')))
|
|
125
175
|
setOpen(sessionStorage.getItem(OPEN_KEY) === '1')
|
|
126
176
|
|
|
177
|
+
// the response, or null if edrys-dev could not be reached
|
|
127
178
|
function post(body) {
|
|
128
179
|
return fetch('/edrys-dev/chat', {
|
|
129
180
|
method: 'POST',
|
|
130
181
|
headers: { 'Content-Type': 'application/json' },
|
|
131
182
|
body: JSON.stringify(body),
|
|
132
|
-
})
|
|
183
|
+
}).catch(() => null)
|
|
133
184
|
}
|
|
134
185
|
|
|
135
|
-
|
|
186
|
+
// shown in the status line until the agent sets the next one
|
|
187
|
+
function notSent() {
|
|
188
|
+
statusLine.textContent = 'Not sent: is edrys-dev still running? Try again.'
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
compose.addEventListener('submit', async (event) => {
|
|
136
192
|
event.preventDefault()
|
|
137
193
|
const text = textarea.value.trim()
|
|
138
194
|
if (!text) return
|
|
139
195
|
textarea.value = ''
|
|
140
|
-
post({ text })
|
|
196
|
+
const response = await post({ text })
|
|
197
|
+
if (response?.ok) return
|
|
198
|
+
// back into the box, unless the user typed something new meanwhile
|
|
199
|
+
if (!textarea.value) textarea.value = text
|
|
200
|
+
notSent()
|
|
141
201
|
})
|
|
142
202
|
textarea.addEventListener('keydown', (event) => {
|
|
143
203
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
@@ -212,15 +272,42 @@
|
|
|
212
272
|
values[input.id] = input.type === 'radio' ? checked[0] ?? '' : checked
|
|
213
273
|
}
|
|
214
274
|
const response = await post({ ask: entry.ask, values })
|
|
215
|
-
|
|
275
|
+
// closed in the meantime; otherwise the form stays, to send again
|
|
276
|
+
if (response?.status === 409) disable(entry.ask)
|
|
277
|
+
else if (!response?.ok) notSent()
|
|
216
278
|
})
|
|
217
279
|
|
|
218
280
|
if (entry.ask !== open) form.setAttribute('aria-disabled', 'true')
|
|
219
281
|
return form
|
|
220
282
|
}
|
|
221
283
|
|
|
222
|
-
function disable(ask) {
|
|
223
|
-
root.querySelectorAll(`form[data-ask="${ask}"]`).forEach((form) =>
|
|
284
|
+
function disable(ask, reason) {
|
|
285
|
+
root.querySelectorAll(`form[data-ask="${ask}"]`).forEach((form) => {
|
|
286
|
+
form.setAttribute('aria-disabled', 'true')
|
|
287
|
+
// why the agent closed it, shown only live
|
|
288
|
+
if (reason && !form.nextElementSibling?.classList.contains('reason')) {
|
|
289
|
+
const note = document.createElement('div')
|
|
290
|
+
note.className = 'reason'
|
|
291
|
+
note.textContent = reason
|
|
292
|
+
form.after(note)
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// the lab file does not load: until it does, in plain words, edrys-Lite's message folded
|
|
298
|
+
function showProblem(problem) {
|
|
299
|
+
problemBox.hidden = !problem
|
|
300
|
+
if (!problem) return problemBox.replaceChildren()
|
|
301
|
+
const text = document.createElement('div')
|
|
302
|
+
text.textContent = problem.text
|
|
303
|
+
const details = document.createElement('details')
|
|
304
|
+
const summary = document.createElement('summary')
|
|
305
|
+
summary.textContent = 'Details'
|
|
306
|
+
const pre = document.createElement('pre')
|
|
307
|
+
pre.textContent = problem.detail
|
|
308
|
+
details.append(summary, pre)
|
|
309
|
+
problemBox.replaceChildren(text, details)
|
|
310
|
+
if (!panel.classList.contains('open')) toggle.classList.add('unread')
|
|
224
311
|
}
|
|
225
312
|
|
|
226
313
|
function show(entry) {
|
|
@@ -248,9 +335,23 @@
|
|
|
248
335
|
case 'history':
|
|
249
336
|
log.replaceChildren()
|
|
250
337
|
open = message.open
|
|
338
|
+
current = message.session
|
|
339
|
+
sessions = message.sessions
|
|
340
|
+
showMenu()
|
|
341
|
+
if (message.skipped) {
|
|
342
|
+
const note = document.createElement('div')
|
|
343
|
+
note.className = 'skipped'
|
|
344
|
+
note.textContent = `${message.skipped} earlier messages not shown`
|
|
345
|
+
log.appendChild(note)
|
|
346
|
+
}
|
|
251
347
|
message.entries.forEach(show)
|
|
252
348
|
statusLine.textContent = message.status
|
|
349
|
+
hintLine.textContent = message.hint || ''
|
|
253
350
|
toggle.classList.remove('unread')
|
|
351
|
+
showProblem(message.lab)
|
|
352
|
+
break
|
|
353
|
+
case 'lab':
|
|
354
|
+
showProblem(message.problem)
|
|
254
355
|
break
|
|
255
356
|
case 'entry':
|
|
256
357
|
if (message.entry.type === 'ai' && message.entry.ask) open = message.entry.ask
|
|
@@ -260,9 +361,16 @@
|
|
|
260
361
|
case 'status':
|
|
261
362
|
statusLine.textContent = message.text
|
|
262
363
|
break
|
|
364
|
+
case 'sessions':
|
|
365
|
+
sessions = message.sessions
|
|
366
|
+
showMenu()
|
|
367
|
+
break
|
|
368
|
+
case 'hint':
|
|
369
|
+
hintLine.textContent = message.text
|
|
370
|
+
break
|
|
263
371
|
case 'close':
|
|
264
372
|
if (open === message.ask) open = null
|
|
265
|
-
disable(message.ask)
|
|
373
|
+
disable(message.ask, message.reason)
|
|
266
374
|
break
|
|
267
375
|
}
|
|
268
376
|
}
|