clavarium 0.2.0-next.5 → 0.2.0-next.6
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/deploy/install.sh +10 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api.mjs +17 -2
- package/server/conversations.mjs +104 -8
- package/server/instance.mjs +35 -0
- package/server/main.mjs +16 -8
- package/server/names.mjs +35 -0
- package/server/store.mjs +21 -0
- package/web/app.js +337 -44
- package/web/device.html +1 -0
- package/web/index.html +24 -1
- package/web/style.css +41 -0
package/deploy/install.sh
CHANGED
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
# server mode)
|
|
39
39
|
# --relay-installed the relay was set up already (by `clavarium online`):
|
|
40
40
|
# do not print the command to run on it
|
|
41
|
+
# --name NAME the name of this Clavarium, under its icon and in its
|
|
42
|
+
# tab (default: drawn from --domain); changed in Settings.
|
|
43
|
+
# Letters, digits, spaces, dots, hyphens: 30 at most
|
|
41
44
|
# --dry-run check the parameters, say what would be done, do nothing
|
|
42
45
|
#
|
|
43
46
|
# AFTER --local, going online is one command: `sudo clavarium online --relay
|
|
@@ -107,12 +110,12 @@ DEX_LAYER="680f1119fdbcdd7f0ad8985b2534ded871b08b5b77c40f337c38aebd062b59c2"
|
|
|
107
110
|
DEX_SHA256="217aa675738d1cbaf25fe8d7bc04cb00dbc60f91d8465b76b8d8af963d571337"
|
|
108
111
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
109
112
|
|
|
110
|
-
bad() { echo "install: $*" >&2; echo "usage: install.sh --domain NAME --operator EMAIL [--operator EMAIL...] [--port N] [--relay HOST [--relay-port N]] [--core-only] [--version X.Y.Z | --from-dir DIR] [--updates auto|asap|ask|off] [--update-window HH:MM-HH:MM] [--channel latest|next] [--dry-run]" >&2; echo " or: install.sh --local [--port N] [same options] [--dry-run] to try it on this machine, with no domain" >&2; exit 64; }
|
|
113
|
+
bad() { echo "install: $*" >&2; echo "usage: install.sh --domain NAME --operator EMAIL [--operator EMAIL...] [--port N] [--relay HOST [--relay-port N]] [--core-only] [--version X.Y.Z | --from-dir DIR] [--updates auto|asap|ask|off] [--update-window HH:MM-HH:MM] [--channel latest|next] [--name NAME] [--dry-run]" >&2; echo " or: install.sh --local [--port N] [same options] [--dry-run] to try it on this machine, with no domain" >&2; exit 64; }
|
|
111
114
|
refuse() { echo "install: REFUSED: $*" >&2; exit 78; }
|
|
112
115
|
step() { echo "== $*"; }
|
|
113
116
|
|
|
114
117
|
DOMAIN=""; OPERATORS=""; PORT="7717"; CORE_ONLY="no"; DRY="no"; RELAY=""; RELAY_PORT="22"; LOCAL="no"; KEEP_RELEASE="no"; RELAY_INSTALLED="no"
|
|
115
|
-
VERSION=""; FROM_DIR=""; UPDATES=""; WINDOW=""; CHANNEL=""
|
|
118
|
+
VERSION=""; FROM_DIR=""; UPDATES=""; WINDOW=""; CHANNEL=""; APP_NAME=""
|
|
116
119
|
while [ $# -gt 0 ]; do
|
|
117
120
|
case "$1" in
|
|
118
121
|
--domain) DOMAIN="${2:-}"; shift 2 || bad "--domain needs a value" ;;
|
|
@@ -129,6 +132,7 @@ while [ $# -gt 0 ]; do
|
|
|
129
132
|
--channel) CHANNEL="${2:-}"; shift 2 || bad "--channel needs a value" ;;
|
|
130
133
|
--keep-release) KEEP_RELEASE="yes"; shift ;;
|
|
131
134
|
--relay-installed) RELAY_INSTALLED="yes"; shift ;;
|
|
135
|
+
--name) APP_NAME="${2:-}"; [ -n "$APP_NAME" ] || bad "--name needs a value"; shift 2 ;;
|
|
132
136
|
--dry-run) DRY="yes"; shift ;;
|
|
133
137
|
*) bad "unknown parameter: $1" ;;
|
|
134
138
|
esac
|
|
@@ -170,6 +174,8 @@ fi
|
|
|
170
174
|
[ -z "$UPDATES" ] || [[ "$UPDATES" =~ ^(auto|asap|ask|off)$ ]] || bad "--updates is auto, asap, ask or off"
|
|
171
175
|
[ -z "$WINDOW" ] || { [[ "$WINDOW" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]$ ]] && [ "${WINDOW%-*}" != "${WINDOW#*-}" ]; } || bad "--update-window is two times, like 02:00-05:00"
|
|
172
176
|
[ -z "$CHANNEL" ] || [[ "$CHANNEL" =~ ^(latest|next)$ ]] || bad "--channel is latest or next"
|
|
177
|
+
# It goes in the unit file, between quotes: plain characters only (any other is chosen in Settings).
|
|
178
|
+
[ -z "$APP_NAME" ] || [[ "$APP_NAME" =~ ^[A-Za-z0-9]([A-Za-z0-9\ ._-]{0,28}[A-Za-z0-9.])?$ ]] || bad "--name is letters, digits, spaces, dots and hyphens, 30 at most, like \"Home lab\""
|
|
173
179
|
# What the updates will be, as the settings file has them or as asked now.
|
|
174
180
|
had() { { sed -n "s/^$1=//p" "$UPDATE_CONF" 2>/dev/null || true; } | head -1; }
|
|
175
181
|
SHOW_MODE="${UPDATES:-$(had MODE)}"; SHOW_MODE="${SHOW_MODE:-auto}"
|
|
@@ -188,6 +194,7 @@ if [ "$DRY" = "yes" ]; then
|
|
|
188
194
|
would install Clavarium:
|
|
189
195
|
address $([ "$LOCAL" = "yes" ] && echo "http://127.0.0.1:$PORT, from this machine only (--local)" || echo "https://$DOMAIN")
|
|
190
196
|
operators $([ "$LOCAL" = "yes" ] && echo "whoever can open a page on this machine: no sign-in" || echo "$OPERATORS")
|
|
197
|
+
name $([ -n "$APP_NAME" ] && echo "$APP_NAME" || echo "drawn from the address; changed in Settings")
|
|
191
198
|
server 127.0.0.1:$PORT, as the account "$ACCOUNT", Node.js $NODE_VERSION
|
|
192
199
|
release $([ "$KEEP_RELEASE" = "yes" ] && echo "the one that runs, kept (--keep-release)" || [ -n "$VERSION" ] && echo "clavarium@$VERSION from npm, checked (integrity, provenance from github.com/clavarium/clavarium, npm audit signatures)" || echo "the working tree $FROM_DIR, as it is (development: not checked)")
|
|
193
200
|
updates $(case "$SHOW_MODE" in auto) echo "automatic, between ${SHOW_WINDOW%-*} and ${SHOW_WINDOW#*-}" ;; asap) echo "as soon as a version is out" ;; ask) echo "said in Settings, done when you say" ;; off) echo "off" ;; esac), channel $SHOW_CHANNEL
|
|
@@ -335,7 +342,7 @@ User=$ACCOUNT
|
|
|
335
342
|
Group=$ACCOUNT
|
|
336
343
|
WorkingDirectory=$CODE/current/app
|
|
337
344
|
EnvironmentFile=$ETC/server.env
|
|
338
|
-
ExecStart=$CODE/current/node/bin/node server/main.mjs --host "sudo -n $HOST_PROGRAM" --data $DATA --vault-key $VAULT/vault.key --port $PORT$([ "$LOCAL" = "yes" ] || echo " --operators $OPERATORS --public-origin https://$DOMAIN")
|
|
345
|
+
ExecStart=$CODE/current/node/bin/node server/main.mjs --host "sudo -n $HOST_PROGRAM" --data $DATA --vault-key $VAULT/vault.key --port $PORT$([ "$LOCAL" = "yes" ] || echo " --operators $OPERATORS --public-origin https://$DOMAIN")$([ -z "$APP_NAME" ] || echo " --name \"$APP_NAME\"")
|
|
339
346
|
Restart=on-failure
|
|
340
347
|
RestartSec=3
|
|
341
348
|
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.0-next.
|
|
3
|
+
"version": "0.2.0-next.6",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "clavarium",
|
|
9
|
-
"version": "0.2.0-next.
|
|
9
|
+
"version": "0.2.0-next.6",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/package.json
CHANGED
package/server/api.mjs
CHANGED
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
* GET /api/conversations/<name>/guide what its tool is told about where it is: the exact texts, the owner's lines
|
|
23
23
|
* POST /api/conversations/<name>/guide {lines}: the owner's own lines for it (short, plain text)
|
|
24
24
|
* POST /api/conversations/<name>/guide/tell its guide written again, and one line typed in its terminal to read it
|
|
25
|
+
* POST /api/conversations/<name>/group {group}: its folder in the list ('' none); first in it
|
|
26
|
+
* POST /api/conversations/<name>/move {to: up|down}: one place among those of its folder
|
|
27
|
+
* POST /api/groups/rename {from, to}: for all its conversations
|
|
28
|
+
* POST /api/groups/move {group, to: up|down}
|
|
29
|
+
* POST /api/order {order?, groups?}: the whole order, as a drag and drop leaves it
|
|
30
|
+
* GET /api/instance {name, defaultName, given}: the name of this Clavarium
|
|
31
|
+
* POST /api/instance {name}: '' for the one drawn from its address
|
|
25
32
|
* GET /api/resources
|
|
26
33
|
* POST /api/resources {name} | {name, type: 'git', from?, via?: {method, key?, user?, secret?}}
|
|
27
34
|
* GET /api/git-keys SSH keys made here: the public half only
|
|
@@ -104,7 +111,7 @@ function readBody(request, max = BODY_MAX) {
|
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
/* Returns true when the request was an API request (answered here). */
|
|
107
|
-
export async function handleApi(request, response, conversations, headers, resources, originOk = sameOrigin, shortcuts, voice, sharing, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, repair, guide, capacity, updates) {
|
|
114
|
+
export async function handleApi(request, response, conversations, headers, resources, originOk = sameOrigin, shortcuts, voice, sharing, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, repair, guide, capacity, updates, instance) {
|
|
108
115
|
const { pathname } = new URL(request.url ?? '/', 'http://local');
|
|
109
116
|
if (!pathname.startsWith('/api/')) return false;
|
|
110
117
|
const send = (status, body) => {
|
|
@@ -194,6 +201,7 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
194
201
|
if (pathname === '/api/git-keys' && gitAccess) return send(200, gitAccess.keys()), true;
|
|
195
202
|
if (pathname === '/api/git-hosts' && gitAccess) return send(200, gitAccess.hosts()), true;
|
|
196
203
|
if (pathname === '/api/platform' && platform) return send(200, platform), true;
|
|
204
|
+
if (pathname === '/api/instance' && instance) return send(200, instance.about()), true;
|
|
197
205
|
if (pathname === '/api/shelf' && shelf) return send(200, shelf.about()), true;
|
|
198
206
|
/* Settings, Updates (updates.mjs): what runs, what is out, the mode, what happened. */
|
|
199
207
|
if (pathname === '/api/updates' && updates) return send(200, updates.about()), true;
|
|
@@ -293,7 +301,7 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
293
301
|
if (dropped && secrets) { secrets.remove(dropped[1]); return send(200, { ok: true }), true; }
|
|
294
302
|
if (pathname === '/api/alerts' && alerts) return send(201, alerts.add(body.subscription, body.label)), true;
|
|
295
303
|
if (pathname === '/api/alerts/remove' && alerts) return send(200, alerts.remove(String(body.endpoint ?? ''))), true;
|
|
296
|
-
if (pathname === '/api/alerts/test' && alerts) return send(200, { reached: await alerts.tell({ title: 'Clavarium', body: 'This is what an alert looks like.', url: '/' }) }), true;
|
|
304
|
+
if (pathname === '/api/alerts/test' && alerts) return send(200, { reached: await alerts.tell({ title: instance?.name() ?? 'Clavarium', body: 'This is what an alert looks like.', url: '/' }) }), true;
|
|
297
305
|
if (pathname === '/api/talk/settings' && talk) return send(200, talk.setWords({ lang: body.lang, own: body.own })), true;
|
|
298
306
|
if (pathname === '/api/talk/thread' && talk) return send(200, talk.setThread({ levels: body.levels })), true;
|
|
299
307
|
const guiding = /^\/api\/conversations\/([^/]+)\/guide(\/tell)?$/.exec(pathname);
|
|
@@ -428,6 +436,13 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
428
436
|
if (asking) return send(200, conversations.setAskFirst(decodeURIComponent(asking[1]), body.on)), true;
|
|
429
437
|
const naming = /^\/api\/conversations\/([^/]+)\/chat-name$/.exec(pathname);
|
|
430
438
|
if (naming) return send(200, conversations.setChatName(decodeURIComponent(naming[1]), body.name)), true;
|
|
439
|
+
/* The list: folders and order, the same on every device (conversations.mjs). */
|
|
440
|
+
if (pathname === '/api/instance' && instance) return send(200, instance.setName(body.name)), true;
|
|
441
|
+
if (pathname === '/api/order') return send(200, conversations.setOrder({ order: body.order, groups: body.groups })), true;
|
|
442
|
+
if (pathname === '/api/groups/rename') return send(200, conversations.renameGroup(body.from, body.to)), true;
|
|
443
|
+
if (pathname === '/api/groups/move') return send(200, conversations.moveGroup(body.group, body.to)), true;
|
|
444
|
+
const grouping = /^\/api\/conversations\/([^/]+)\/(group|move)$/.exec(pathname);
|
|
445
|
+
if (grouping) return send(200, grouping[2] === 'group' ? conversations.setGroup(decodeURIComponent(grouping[1]), body.group) : conversations.move(decodeURIComponent(grouping[1]), body.to)), true;
|
|
431
446
|
const titling = /^\/api\/conversations\/([^/]+)\/title$/.exec(pathname);
|
|
432
447
|
if (titling) return send(200, conversations.setTitle(decodeURIComponent(titling[1]), body.title)), true;
|
|
433
448
|
const hiding = /^\/api\/conversations\/([^/]+)\/hidden$/.exec(pathname);
|
package/server/conversations.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* - Sleep is a button, never a guess: telling a long job from an idle tool
|
|
7
7
|
* cannot be done without reading the tool, and this server does not read.
|
|
8
8
|
*/
|
|
9
|
-
import { isConversationName, identifierFrom, titleProblem } from './names.mjs';
|
|
9
|
+
import { isConversationName, identifierFrom, titleProblem, groupProblem } from './names.mjs';
|
|
10
|
+
import { JsonFile } from './store.mjs';
|
|
10
11
|
import { attachCommand, SOCKET } from './tmux.mjs';
|
|
11
12
|
import { WORK, VM_USER, SHARING_STEP } from './vm.mjs';
|
|
12
13
|
import { resumeOrFresh } from './tools.mjs';
|
|
@@ -60,7 +61,7 @@ const buildProgress = (since) => ({ kind: 'build', steps: ['starting', 'preparin
|
|
|
60
61
|
const firstLine = (text) => String(text ?? '').trim().split('\n').filter(Boolean).pop()?.slice(0, 200) ?? '';
|
|
61
62
|
|
|
62
63
|
export class Conversations {
|
|
63
|
-
#store; #vms; #tools; #stopDelayMs; #timers = new Map(); #watching = new Map(); #fresh = new Set(); #mountAgain = new Set(); #uid; #gid;
|
|
64
|
+
#layout; #store; #vms; #tools; #stopDelayMs; #timers = new Map(); #watching = new Map(); #fresh = new Set(); #mountAgain = new Set(); #uid; #gid;
|
|
64
65
|
/* Set by the server: prepares the VM before the tool is attached, and
|
|
65
66
|
names the program the tool is started through. See secrets.mjs. */
|
|
66
67
|
prepare = async () => {};
|
|
@@ -90,8 +91,8 @@ export class Conversations {
|
|
|
90
91
|
/* `uid`/`gid`: the account this server runs as; files the tool writes in
|
|
91
92
|
its kept work folder belong to it on the host. */
|
|
92
93
|
#startingMs; #answerMs; #pollMs;
|
|
93
|
-
constructor({ store, vms, tools, stopDelayMs = STOP_DELAY_MS, uid = process.getuid(), gid = process.getgid(), startingMs = STARTING_MS, answerMs = ANSWER_MS, pollMs = POLL_MS }) {
|
|
94
|
-
this.#store = store; this.#vms = vms; this.#tools = tools; this.#stopDelayMs = stopDelayMs; this.#uid = uid; this.#gid = gid;
|
|
94
|
+
constructor({ store, vms, tools, layout = new JsonFile(undefined, {}), stopDelayMs = STOP_DELAY_MS, uid = process.getuid(), gid = process.getgid(), startingMs = STARTING_MS, answerMs = ANSWER_MS, pollMs = POLL_MS }) {
|
|
95
|
+
this.#layout = layout; this.#store = store; this.#vms = vms; this.#tools = tools; this.#stopDelayMs = stopDelayMs; this.#uid = uid; this.#gid = gid;
|
|
95
96
|
this.#startingMs = startingMs; this.#answerMs = answerMs; this.#pollMs = pollMs;
|
|
96
97
|
/* A build interrupted by a restart of this server cannot be resumed
|
|
97
98
|
blindly: it is marked failed, and says so. */
|
|
@@ -177,11 +178,30 @@ export class Conversations {
|
|
|
177
178
|
/* The sign-in rules of a conversation's tool, or undefined. */
|
|
178
179
|
signinRules(name) { return this.#tools.find((t) => t.id === this.#store.get(name)?.tool)?.signin; }
|
|
179
180
|
|
|
181
|
+
/* THE ORDER OF THE LIST, the same on every device: the conversations
|
|
182
|
+
* (one order; a folder shows its own in it) and the folders. Kept in a
|
|
183
|
+
* file of its own; what is not in it (made before, or by an older
|
|
184
|
+
* version) comes after, in the order it was made. A folder is only a
|
|
185
|
+
* word on its conversations: with none left, it is gone. */
|
|
186
|
+
#order() {
|
|
187
|
+
const all = this.#store.all();
|
|
188
|
+
const names = new Set(all.map((c) => c.name));
|
|
189
|
+
const kept = this.#layout.get();
|
|
190
|
+
const order = [...new Set((Array.isArray(kept.order) ? kept.order : []).filter((n) => names.has(n)))];
|
|
191
|
+
for (const c of all) if (!order.includes(c.name)) order.push(c.name);
|
|
192
|
+
const present = [...new Set(all.map((c) => c.group).filter(Boolean))];
|
|
193
|
+
const groups = [...new Set((Array.isArray(kept.groups) ? kept.groups : []).filter((g) => present.includes(g)))];
|
|
194
|
+
for (const g of present) if (!groups.includes(g)) groups.push(g);
|
|
195
|
+
return { order, groups };
|
|
196
|
+
}
|
|
197
|
+
#keepOrder({ order, groups }) { this.#layout.set({ order, groups }); }
|
|
198
|
+
|
|
180
199
|
list() {
|
|
181
|
-
|
|
200
|
+
const { order, groups } = this.#order();
|
|
201
|
+
return this.#store.all().sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name)).map((c) => {
|
|
182
202
|
const tool = this.#tools.find((t) => t.id === c.tool);
|
|
183
203
|
/* An AI tool has an instruction file; a shell has none: what is dictated there is never marked. */
|
|
184
|
-
return { ...c, toolLabel: tool?.label ?? c.tool, memoryMinMb: tool?.memoryMb, resumes: Boolean(tool?.resume), rights: Boolean(tool?.rights), ai: Boolean(tool?.instructions), voiceMark: Boolean(tool?.instructions) && c.voiceMark !== false };
|
|
204
|
+
return { ...c, rank: order.indexOf(c.name), ...(c.group ? { groupRank: groups.indexOf(c.group) } : {}), toolLabel: tool?.label ?? c.tool, memoryMinMb: tool?.memoryMb, resumes: Boolean(tool?.resume), rights: Boolean(tool?.rights), ai: Boolean(tool?.instructions), voiceMark: Boolean(tool?.instructions) && c.voiceMark !== false };
|
|
185
205
|
});
|
|
186
206
|
}
|
|
187
207
|
|
|
@@ -211,8 +231,9 @@ export class Conversations {
|
|
|
211
231
|
return { name: id, title: title !== undefined && title !== id ? title : undefined };
|
|
212
232
|
}
|
|
213
233
|
|
|
214
|
-
create({ name: given, title: read, tool, memoryMb, workOnDisk }) {
|
|
234
|
+
create({ name: given, title: read, tool, memoryMb, workOnDisk, group }) {
|
|
215
235
|
const { name, title } = this.#naming({ name: given, title: read });
|
|
236
|
+
if (group !== undefined) { const problem = groupProblem(group); if (problem) throw new Refusal(400, problem); }
|
|
216
237
|
if (!isConversationName(name)) throw new Refusal(400, 'A name is 1 to 32 lower-case letters, digits or hyphens; it starts with a letter and does not end with a hyphen.');
|
|
217
238
|
const chosen = this.#tools.find((t) => t.id === tool);
|
|
218
239
|
if (!chosen) throw new Refusal(400, 'Unknown tool.');
|
|
@@ -222,8 +243,11 @@ export class Conversations {
|
|
|
222
243
|
}
|
|
223
244
|
if (this.#store.get(name)) throw new Refusal(409, 'A conversation already has this name.');
|
|
224
245
|
const now = new Date().toISOString();
|
|
225
|
-
const conversation = { name, ...(title ? { title } : {}), tool, memoryMb: memory, diskGb: DISK_GB, fixed: FIXES, work: workOnDisk === true ? 'disk' : 'kept', phase: 'building', step: 'Starting the machine', createdAt: now, buildingSince: now, progress: buildProgress(now) };
|
|
246
|
+
const conversation = { name, ...(title ? { title } : {}), ...(group ? { group } : {}), tool, memoryMb: memory, diskGb: DISK_GB, fixed: FIXES, work: workOnDisk === true ? 'disk' : 'kept', phase: 'building', step: 'Starting the machine', createdAt: now, buildingSince: now, progress: buildProgress(now) };
|
|
247
|
+
/* A new one comes first in its folder (or first of those in none). */
|
|
248
|
+
const { order, groups } = this.#order();
|
|
226
249
|
this.#store.put(conversation);
|
|
250
|
+
this.#keepOrder({ order: [name, ...order], groups: group && !groups.includes(group) ? [...groups, group] : groups });
|
|
227
251
|
this.built = this.#build(name, chosen, memory);
|
|
228
252
|
return conversation;
|
|
229
253
|
}
|
|
@@ -483,6 +507,78 @@ export class Conversations {
|
|
|
483
507
|
return this.#store.get(name);
|
|
484
508
|
}
|
|
485
509
|
|
|
510
|
+
/* ITS FOLDER in the list ('' for none): first among those of that folder. */
|
|
511
|
+
setGroup(name, group) {
|
|
512
|
+
const c = this.#store.get(name);
|
|
513
|
+
if (!c) throw new Refusal(404, 'No such conversation.');
|
|
514
|
+
const problem = groupProblem(group);
|
|
515
|
+
if (problem) throw new Refusal(400, problem);
|
|
516
|
+
if ((c.group ?? '') === group) return c;
|
|
517
|
+
const { order, groups } = this.#order();
|
|
518
|
+
this.#store.patch(name, { group: group || undefined });
|
|
519
|
+
this.#keepOrder({ order: [name, ...order.filter((n) => n !== name)], groups: group && !groups.includes(group) ? [...groups, group] : groups });
|
|
520
|
+
return this.#store.get(name);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/* A folder renamed, for all its conversations; to the name of another, they join it. */
|
|
524
|
+
renameGroup(from, to) {
|
|
525
|
+
const problem = groupProblem(to);
|
|
526
|
+
if (problem) throw new Refusal(400, problem);
|
|
527
|
+
if (!to) throw new Refusal(400, 'Give the new name of the folder.');
|
|
528
|
+
const inside = this.#store.all().filter((c) => typeof from === 'string' && from && c.group === from);
|
|
529
|
+
if (inside.length === 0) throw new Refusal(404, 'No such folder.');
|
|
530
|
+
const { order, groups } = this.#order();
|
|
531
|
+
for (const c of inside) this.#store.patch(c.name, { group: to });
|
|
532
|
+
const renamed = groups.includes(to) ? groups.filter((g) => g !== from) : groups.map((g) => (g === from ? to : g));
|
|
533
|
+
this.#keepOrder({ order, groups: renamed });
|
|
534
|
+
return { renamed: inside.length, group: to };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/* One place up or down among the others of its folder (`to`: 'up' or 'down').
|
|
538
|
+
A hidden one is passed over by one that is shown: it is not in sight. */
|
|
539
|
+
move(name, to) {
|
|
540
|
+
const c = this.#store.get(name);
|
|
541
|
+
if (!c) throw new Refusal(404, 'No such conversation.');
|
|
542
|
+
if (to !== 'up' && to !== 'down') throw new Refusal(400, 'Say up or down.');
|
|
543
|
+
const { order, groups } = this.#order();
|
|
544
|
+
const byName = new Map(this.#store.all().map((x) => [x.name, x]));
|
|
545
|
+
const peers = order.filter((n) => (byName.get(n).group ?? '') === (c.group ?? '') && (c.hidden === true || byName.get(n).hidden !== true || n === name));
|
|
546
|
+
const at = peers.indexOf(name);
|
|
547
|
+
const other = peers[to === 'up' ? at - 1 : at + 1];
|
|
548
|
+
if (other === undefined) return { order: this.list().map((x) => x.name) };
|
|
549
|
+
const i = order.indexOf(name); const j = order.indexOf(other);
|
|
550
|
+
[order[i], order[j]] = [order[j], order[i]];
|
|
551
|
+
this.#keepOrder({ order, groups });
|
|
552
|
+
return { order };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/* A folder one place up or down among the folders. */
|
|
556
|
+
moveGroup(group, to) {
|
|
557
|
+
if (to !== 'up' && to !== 'down') throw new Refusal(400, 'Say up or down.');
|
|
558
|
+
const { order, groups } = this.#order();
|
|
559
|
+
const at = groups.indexOf(group);
|
|
560
|
+
if (typeof group !== 'string' || at < 0) throw new Refusal(404, 'No such folder.');
|
|
561
|
+
const j = to === 'up' ? at - 1 : at + 1;
|
|
562
|
+
if (j >= 0 && j < groups.length) [groups[at], groups[j]] = [groups[j], groups[at]];
|
|
563
|
+
this.#keepOrder({ order, groups });
|
|
564
|
+
return { groups };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/* A whole order, as a drag and drop leaves it: names it does not know are
|
|
568
|
+
left out, and those it forgets keep their places after the others. */
|
|
569
|
+
setOrder({ order: wanted, groups: wantedGroups } = {}) {
|
|
570
|
+
const { order, groups } = this.#order();
|
|
571
|
+
const pick = (list, known) => {
|
|
572
|
+
if (list === undefined) return known;
|
|
573
|
+
if (!Array.isArray(list) || list.length > 10_000 || list.some((x) => typeof x !== 'string')) throw new Refusal(400, 'Give a list of names.');
|
|
574
|
+
const chosen = [...new Set(list.filter((x) => known.includes(x)))];
|
|
575
|
+
return [...chosen, ...known.filter((x) => !chosen.includes(x))];
|
|
576
|
+
};
|
|
577
|
+
const next = { order: pick(wanted, order), groups: pick(wantedGroups, groups) };
|
|
578
|
+
this.#keepOrder(next);
|
|
579
|
+
return next;
|
|
580
|
+
}
|
|
581
|
+
|
|
486
582
|
/* What is dictated into its terminal starts with a microphone sign, once
|
|
487
583
|
per message (true, the default for a tool that is an AI), or not. */
|
|
488
584
|
setVoiceMark(name, on) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/* THE NAME OF THIS CLAVARIUM: in the title of its tab, above its list, in
|
|
2
|
+
* its alerts, and under its icon once installed. Drawn from its address
|
|
3
|
+
* (clav.example.com -> Clav; "Clavarium" with none), given at the install
|
|
4
|
+
* (--name), changed in Settings. The manifest is made here, from the one in
|
|
5
|
+
* web/, with that name -- or, for one device, the name chosen there when
|
|
6
|
+
* installing (a parameter of the manifest's address: nothing is kept). */
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { JsonFile } from './store.mjs';
|
|
9
|
+
import { appNameFrom, appNameProblem, shortAppName } from './names.mjs';
|
|
10
|
+
import { Refusal } from './conversations.mjs';
|
|
11
|
+
|
|
12
|
+
const TEMPLATE = JSON.parse(readFileSync(new URL('../web/manifest.webmanifest', import.meta.url), 'utf8'));
|
|
13
|
+
|
|
14
|
+
export class Instance {
|
|
15
|
+
#file; #fallback;
|
|
16
|
+
constructor({ file = new JsonFile(undefined, {}), origin, given } = {}) {
|
|
17
|
+
this.#file = file;
|
|
18
|
+
this.#fallback = given && !appNameProblem(given) ? given : appNameFrom(origin);
|
|
19
|
+
}
|
|
20
|
+
name() { const kept = this.#file.get().name; return typeof kept === 'string' && !appNameProblem(kept) ? kept : this.#fallback; }
|
|
21
|
+
about() { return { name: this.name(), defaultName: this.#fallback, given: this.name() !== this.#fallback }; }
|
|
22
|
+
/* '' gives back the name drawn from the address. */
|
|
23
|
+
setName(name) {
|
|
24
|
+
if (typeof name !== 'string') throw new Refusal(400, 'Give a name, or nothing for the usual one.');
|
|
25
|
+
const said = name.trim();
|
|
26
|
+
if (said) { const problem = appNameProblem(said); if (problem) throw new Refusal(400, problem); }
|
|
27
|
+
this.#file.set(said && said !== this.#fallback ? { name: said } : {});
|
|
28
|
+
return this.about();
|
|
29
|
+
}
|
|
30
|
+
/* The manifest, as JSON; `asked`: the name one device chose, used only when it is a name. */
|
|
31
|
+
manifest(asked) {
|
|
32
|
+
const name = typeof asked === 'string' && asked && !appNameProblem(asked) ? asked : this.name();
|
|
33
|
+
return JSON.stringify({ ...TEMPLATE, name, short_name: shortAppName(name) }, null, 2);
|
|
34
|
+
}
|
|
35
|
+
}
|
package/server/main.mjs
CHANGED
|
@@ -21,7 +21,8 @@ import { attachBridge } from './bridge.mjs';
|
|
|
21
21
|
import { attachCommand } from './tmux.mjs';
|
|
22
22
|
import { handleApi } from './api.mjs';
|
|
23
23
|
import { Conversations, DRAWN_MS } from './conversations.mjs';
|
|
24
|
-
import { Store } from './store.mjs';
|
|
24
|
+
import { Store, JsonFile } from './store.mjs';
|
|
25
|
+
import { Instance } from './instance.mjs';
|
|
25
26
|
import { loadTools } from './tools.mjs';
|
|
26
27
|
import { Vms, runner } from './vm.mjs';
|
|
27
28
|
import { Resources } from './resources.mjs';
|
|
@@ -114,7 +115,9 @@ const withoutTmux = (env) => Object.fromEntries(Object.entries(env).filter(([key
|
|
|
114
115
|
|
|
115
116
|
const REFUSED = 'This Clavarium belongs to someone else: your account is not one of its operators.\n';
|
|
116
117
|
|
|
117
|
-
function serve(port, conversations, resources, { gate = () => 'local', originOk, shortcuts, voice, sharing, door, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, guide, repair, capacity, updates } = {}) {
|
|
118
|
+
function serve(port, conversations, resources, { gate = () => 'local', originOk, shortcuts, voice, sharing, door, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, guide, repair, capacity, updates, instance = new Instance() } = {}) {
|
|
119
|
+
/* Its manifest carries the name of this Clavarium, or the one a device chose (instance.mjs). */
|
|
120
|
+
const sendManifest = (response, url) => response.writeHead(200, { ...HEADERS, 'content-type': 'application/manifest+json' }).end(instance.manifest(new URL(url ?? '/', 'http://local').searchParams.get('name') ?? undefined));
|
|
118
121
|
const sendFile = async (response, file) => {
|
|
119
122
|
/* Several files are one, in their order. */
|
|
120
123
|
const body = Array.isArray(file[0]) ? Buffer.concat((await Promise.all(file[0].map((f) => readFile(f.startsWith('/') ? f : ROOT + f)))).flatMap((b) => [b, Buffer.from('\n;\n')])) : await readFile(file[0].startsWith('/') ? file[0] : ROOT + file[0]);
|
|
@@ -139,6 +142,7 @@ function serve(port, conversations, resources, { gate = () => 'local', originOk,
|
|
|
139
142
|
}
|
|
140
143
|
if (door && pathname.startsWith('/c/')) {
|
|
141
144
|
if (await handleDoor(request, response, door)) return;
|
|
145
|
+
if (pathname === '/c/manifest.webmanifest' && request.method === 'GET') { sendManifest(response, request.url); return; }
|
|
142
146
|
const file = DEVICE_FILES[pathname];
|
|
143
147
|
if (!file || (request.method !== 'GET' && request.method !== 'HEAD')) { response.writeHead(404, HEADERS).end(); return; }
|
|
144
148
|
await sendFile(response, file);
|
|
@@ -149,7 +153,8 @@ function serve(port, conversations, resources, { gate = () => 'local', originOk,
|
|
|
149
153
|
response.writeHead(403, { ...HEADERS, 'content-type': 'text/plain; charset=utf-8' }).end(REFUSED);
|
|
150
154
|
return;
|
|
151
155
|
}
|
|
152
|
-
if (conversations && await handleApi(request, response, conversations, HEADERS, resources, originOk, shortcuts, voice, sharing, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, repair, guide, capacity, updates)) return;
|
|
156
|
+
if (conversations && await handleApi(request, response, conversations, HEADERS, resources, originOk, shortcuts, voice, sharing, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, repair, guide, capacity, updates, instance)) return;
|
|
157
|
+
if (pathname === '/manifest.webmanifest' && request.method === 'GET') { sendManifest(response, request.url); return; }
|
|
153
158
|
const file = FILES[pathname];
|
|
154
159
|
if (!file || (request.method !== 'GET' && request.method !== 'HEAD')) {
|
|
155
160
|
response.writeHead(404, HEADERS).end();
|
|
@@ -179,16 +184,17 @@ const PARLEY_WAIT_MS = 20_000;
|
|
|
179
184
|
/* The product: conversations are VMs. `host` is how the host program is
|
|
180
185
|
reached on this installation, as an argument vector: it is the ONLY thing
|
|
181
186
|
this server runs with more rights than its own. */
|
|
182
|
-
export async function startHome({ port, host, dataDir, mntDir = dataDir, gitReserveBytes, env = process.env, conversations, resources, operators = [], publicOrigin, vaultKey, tools = loadTools(), stopDelayMs, mailReader, gitLocalRemotes = false, gitSsh, answerFetcher, answerEveryMs, wakeEveryMs, alertSend, talkEveryMs, gitEveryMs, shelfEveryMs, shelfFirstMs, healthEveryMs, healthSettleMs, setupPollMs, rereadAfterMs, serverRoomMs, updateState, updateConf }) {
|
|
187
|
+
export async function startHome({ port, host, dataDir, mntDir = dataDir, gitReserveBytes, env = process.env, conversations, resources, operators = [], publicOrigin, vaultKey, tools = loadTools(), stopDelayMs, mailReader, gitLocalRemotes = false, gitSsh, answerFetcher, answerEveryMs, wakeEveryMs, alertSend, talkEveryMs, gitEveryMs, shelfEveryMs, shelfFirstMs, healthEveryMs, healthSettleMs, setupPollMs, rereadAfterMs, serverRoomMs, updateState, updateConf, appName }) {
|
|
183
188
|
/* The data, in the format this code reads, before anything reads it (dataformat.mjs). */
|
|
184
189
|
const migrated = migrateData(dataDir);
|
|
185
190
|
if (migrated.ran.length) console.log(`data: format ${migrated.from} -> ${migrated.to}`);
|
|
186
191
|
const gate = makeGate(operators);
|
|
187
192
|
const originOk = makeOriginCheck(publicOrigin);
|
|
193
|
+
const instance = new Instance({ file: new JsonFile(`${dataDir}/instance.json`, {}), origin: publicOrigin, given: appName });
|
|
188
194
|
let repairTerminal = async () => { throw new Error('not ready'); };
|
|
189
195
|
const asHost = runner(host);
|
|
190
196
|
const vms = new Vms(asHost, host);
|
|
191
|
-
const all = conversations ?? new Conversations({ store: new Store(`${dataDir}/conversations.json`), vms, tools, ...(stopDelayMs === undefined ? {} : { stopDelayMs }) });
|
|
197
|
+
const all = conversations ?? new Conversations({ store: new Store(`${dataDir}/conversations.json`), layout: new JsonFile(`${dataDir}/layout.json`, {}), vms, tools, ...(stopDelayMs === undefined ? {} : { stopDelayMs }) });
|
|
192
198
|
/* Who may do what with what: ONE table, shared by everything below. */
|
|
193
199
|
const grants = new Grants(new Store(`${dataDir}/grants.json`));
|
|
194
200
|
const vault = new Vault(vaultKey ?? `${dataDir}/vault.key`);
|
|
@@ -287,7 +293,7 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
|
|
|
287
293
|
/* The owner's devices are told when something waits for them: see alerts.mjs. */
|
|
288
294
|
const alerts = new Alerts({ store: new Store(`${dataDir}/alerts.json`), keyFile: `${dataDir}/alerts-keys.json`, subject: publicOrigin, ...(alertSend ? { send: alertSend } : {}) });
|
|
289
295
|
const answerer = new Answerer({ store: new Store(`${dataDir}/answerer.json`), vms, config: answererConfig(env), conversations: all, dataDir, ...(answerFetcher ? { fetcher: answerFetcher } : {}),
|
|
290
|
-
onNeeds: (name, why) => { void alerts.tell({ title: `${name} waits for you`, body: why, url: `/?c=${name}` }); } });
|
|
296
|
+
onNeeds: (name, why) => { void alerts.tell({ title: `${instance.name()}: ${name} waits for you`, body: why, url: `/?c=${name}` }); } });
|
|
291
297
|
const answerTimer = answerer.start(answerEveryMs);
|
|
292
298
|
/* A tool is told to get to work when something arrives, or by the clock: see wakeups.mjs. */
|
|
293
299
|
let bridge;
|
|
@@ -348,7 +354,7 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
|
|
|
348
354
|
say: (name, text) => { try { talk.event(name, { kind: 'machine-problem', text, tone: 'warn', topic: 'Machine' }); } catch { /* its chat may be off */ } } });
|
|
349
355
|
all.room = (ask) => capacity.check(ask);
|
|
350
356
|
const stopWatch = capacity.start();
|
|
351
|
-
const { server, listening } = serve(port, all, folders, { gate, originOk, shortcuts, voice, sharing, door, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, guide, capacity, updates, repair: (name) => repairTerminal(name) });
|
|
357
|
+
const { server, listening } = serve(port, all, folders, { gate, originOk, shortcuts, voice, sharing, door, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, guide, capacity, updates, instance, repair: (name) => repairTerminal(name) });
|
|
352
358
|
server.on('close', () => { closing = true; stopShelf(); stopHealth(); stopUpdates(); stopWatch(); clearInterval(mailTimer); clearInterval(answerTimer); clearInterval(wakeTimer); clearInterval(gitTimer); talk.stop(); });
|
|
353
359
|
let closing = false;
|
|
354
360
|
bridge = attachBridge(server, {
|
|
@@ -418,7 +424,9 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
|
418
424
|
/* Where the shared folders live: $MNT, declared to the host program in
|
|
419
425
|
its own settings, read here so that both agree (see clavarium-host). */
|
|
420
426
|
const mntDir = hostSetting('MNT') || dataDir;
|
|
421
|
-
|
|
427
|
+
/* --name: the name of this Clavarium until one is chosen in Settings (instance.mjs). */
|
|
428
|
+
const name = option(argv, 'name', '') || undefined;
|
|
429
|
+
const server = await startHome({ port, host: host.split(' ').filter(Boolean), dataDir, mntDir, operators, publicOrigin, vaultKey, appName: name });
|
|
422
430
|
console.log(`http://127.0.0.1:${server.address().port}/`);
|
|
423
431
|
} else {
|
|
424
432
|
const command = option(argv, 'command', 'bash').split(' ').filter(Boolean);
|
package/server/names.mjs
CHANGED
|
@@ -39,3 +39,38 @@ export function identifierFrom(title, taken = () => false) {
|
|
|
39
39
|
if (isConversationName(id) && !taken(id)) return id;
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
/* A FOLDER of the list, and the name of this Clavarium, are names a person
|
|
44
|
+
reads too: the same rules, in their own words. '' is no folder. */
|
|
45
|
+
export const GROUP_MAX = 40;
|
|
46
|
+
export function groupProblem(value) {
|
|
47
|
+
if (typeof value !== 'string') return 'Give a folder name, or nothing for none.';
|
|
48
|
+
if (value === '') return '';
|
|
49
|
+
const problem = titleProblem(value);
|
|
50
|
+
if (problem) return problem.replace('A name', 'A folder name');
|
|
51
|
+
if ([...value].length > GROUP_MAX) return `A folder name is ${GROUP_MAX} characters at most.`;
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
export const APP_NAME_MAX = 30;
|
|
55
|
+
export function appNameProblem(value) {
|
|
56
|
+
const problem = titleProblem(value);
|
|
57
|
+
if (problem) return problem;
|
|
58
|
+
if ([...value].length > APP_NAME_MAX) return `A name is ${APP_NAME_MAX} characters at most.`;
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
/* The name drawn from an address: clav.example.com -> Clav; none -> Clavarium. */
|
|
62
|
+
export function appNameFrom(origin) {
|
|
63
|
+
let host = '';
|
|
64
|
+
try { host = origin ? new URL(origin).hostname : ''; } catch { host = ''; }
|
|
65
|
+
const first = host.split('.')[0] ?? '';
|
|
66
|
+
if (!/^[a-z][a-z0-9-]*$/i.test(first) || /^\d/.test(host) || host === 'localhost') return 'Clavarium';
|
|
67
|
+
return (first.charAt(0).toUpperCase() + first.slice(1)).slice(0, APP_NAME_MAX);
|
|
68
|
+
}
|
|
69
|
+
/* The short name under an icon: the name when it fits, else its first word, else its start. */
|
|
70
|
+
export function shortAppName(name) {
|
|
71
|
+
const chars = [...name];
|
|
72
|
+
if (chars.length <= 12) return name;
|
|
73
|
+
const word = name.split(' ')[0];
|
|
74
|
+
if ([...word].length <= 12) return word;
|
|
75
|
+
return chars.slice(0, 12).join('').trim();
|
|
76
|
+
}
|
package/server/store.mjs
CHANGED
|
@@ -46,3 +46,24 @@ export class Store {
|
|
|
46
46
|
renameSync(tmp, this.#path);
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/* ONE SMALL JSON VALUE in a file (the order of the list, the name of this
|
|
51
|
+
Clavarium), written the same way. Without a path, kept in memory. */
|
|
52
|
+
export class JsonFile {
|
|
53
|
+
#path; #value;
|
|
54
|
+
constructor(path, fallback) {
|
|
55
|
+
this.#path = path;
|
|
56
|
+
this.#value = fallback;
|
|
57
|
+
if (!path) return;
|
|
58
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
59
|
+
try { const read = JSON.parse(readFileSync(path, 'utf8')); if (read && typeof read === 'object') this.#value = read; } catch { /* none yet */ }
|
|
60
|
+
}
|
|
61
|
+
get() { return structuredClone(this.#value); }
|
|
62
|
+
set(value) {
|
|
63
|
+
this.#value = structuredClone(value);
|
|
64
|
+
if (!this.#path) return;
|
|
65
|
+
const tmp = `${this.#path}.tmp`;
|
|
66
|
+
writeFileSync(tmp, JSON.stringify(this.#value, null, 2), { mode: 0o600 });
|
|
67
|
+
renameSync(tmp, this.#path);
|
|
68
|
+
}
|
|
69
|
+
}
|
package/web/app.js
CHANGED
|
@@ -741,7 +741,7 @@
|
|
|
741
741
|
if (welcoming) return;
|
|
742
742
|
const c = conversations.find((x) => x.name === current);
|
|
743
743
|
$('name').textContent = (c ? shownName(c) : current) || 'No conversation';
|
|
744
|
-
|
|
744
|
+
pageTitle = current ? (c ? shownName(c) : current) : ''; paintTitle();
|
|
745
745
|
$('tool').hidden = !c; $('tool').textContent = c?.toolLabel ?? '';
|
|
746
746
|
paintActions();
|
|
747
747
|
$('copy-open').hidden = !current || (home && (!c || c.phase !== 'ready'));
|
|
@@ -823,6 +823,25 @@
|
|
|
823
823
|
void chatFirst();
|
|
824
824
|
}
|
|
825
825
|
|
|
826
|
+
/* ---------- THE NAME OF THIS CLAVARIUM (server/instance.mjs) ----------
|
|
827
|
+
Read from its manifest, the same for the owner's page and a device's:
|
|
828
|
+
in the title of the tab and above the list. The name chosen on this
|
|
829
|
+
device when installing goes in the address of the manifest (kept
|
|
830
|
+
here, put back at each start), so that the app is installed under it. */
|
|
831
|
+
let appName = 'Clavarium';
|
|
832
|
+
let pageTitle = '';
|
|
833
|
+
const INSTALL_NAME = 'clavarium-install-name';
|
|
834
|
+
const MANIFEST = DEVICE ? '/c/manifest.webmanifest' : '/manifest.webmanifest';
|
|
835
|
+
const manifestLink = document.querySelector('link[rel="manifest"]');
|
|
836
|
+
const installName = () => { try { return localStorage.getItem(INSTALL_NAME) ?? ''; } catch { return ''; } };
|
|
837
|
+
const pointManifest = (name) => { if (manifestLink) manifestLink.href = name ? `${MANIFEST}?name=${encodeURIComponent(name)}` : MANIFEST; };
|
|
838
|
+
function paintTitle() { document.title = pageTitle ? `${pageTitle} - ${appName}` : appName; $('list-title').textContent = appName; }
|
|
839
|
+
async function readAppName() {
|
|
840
|
+
try { const m = await (await fetch(MANIFEST)).json(); if (typeof m.name === 'string' && m.name) appName = m.name; } catch { /* the usual name */ }
|
|
841
|
+
paintTitle();
|
|
842
|
+
}
|
|
843
|
+
pointManifest(installName());
|
|
844
|
+
|
|
826
845
|
/* ---------- the list ---------- */
|
|
827
846
|
const post = async (path, body) => {
|
|
828
847
|
const r = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) });
|
|
@@ -837,43 +856,96 @@
|
|
|
837
856
|
|| (c.phase === 'ready' && !c.asleep && ['warn', 'bad'].includes(c.health?.level)));
|
|
838
857
|
/* Hidden conversations are shown on a click on "N hidden", for this page only. */
|
|
839
858
|
let showHidden = false;
|
|
859
|
+
/* ---------- FOLDERS, ORDER, SEARCH ----------
|
|
860
|
+
One level of folders: a word on each conversation, kept by the server
|
|
861
|
+
with the order, so that every device shows the same list. Those in no
|
|
862
|
+
folder first, then the folders, each folded or open on this device
|
|
863
|
+
only. A search filters as it is typed, hidden ones too (marked), and
|
|
864
|
+
opens the folders it finds something in. */
|
|
865
|
+
const FOLDED = 'clavarium-folded';
|
|
866
|
+
const folded = new Set((() => { try { const v = JSON.parse(localStorage.getItem(FOLDED) ?? '[]'); return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []; } catch { return []; } })());
|
|
867
|
+
const keepFolded = () => { try { localStorage.setItem(FOLDED, JSON.stringify([...folded])); } catch { /* a private window: this page only */ } };
|
|
868
|
+
let query = '';
|
|
869
|
+
let chosenHit = 0;
|
|
870
|
+
const plain = (text) => String(text ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
871
|
+
const matches = (c) => [shownName(c), c.name, c.group, c.toolLabel].some((x) => plain(x).includes(plain(query)));
|
|
872
|
+
const byRank = (a, b) => (a.rank ?? 0) - (b.rank ?? 0);
|
|
873
|
+
const groupsInOrder = () => [...new Set(conversations.filter((c) => c.group).sort((a, b) => (a.groupRank ?? 0) - (b.groupRank ?? 0)).map((c) => c.group))];
|
|
874
|
+
function itemButton(c) {
|
|
875
|
+
const b = document.createElement('button');
|
|
876
|
+
b.type = 'button'; b.className = 'item'; b.dataset.phase = c.phase; b.dataset.name = c.name;
|
|
877
|
+
if (c.group) b.dataset.group = c.group;
|
|
878
|
+
/* Ready, but its tool not there yet: it still reads as under way. */
|
|
879
|
+
if (holds(c)) b.dataset.busy = 'true';
|
|
880
|
+
if (c.name === current) b.setAttribute('aria-current', 'true');
|
|
881
|
+
const title = document.createElement('b');
|
|
882
|
+
const dot = document.createElement('span'); dot.className = 'dot';
|
|
883
|
+
title.append(dot, shownName(c));
|
|
884
|
+
if (c.title) b.title = c.name;
|
|
885
|
+
if (c.hidden === true) b.dataset.hidden = 'true';
|
|
886
|
+
const sub = document.createElement('small');
|
|
887
|
+
/* Under way: the step the panel shows, in its words (the tool named once). */
|
|
888
|
+
const under = c.progress && ['building', 'restarting', 'ready'].includes(c.phase) ? stepNow(c.progress, c.toolLabel) : '';
|
|
889
|
+
sub.textContent = under ? (c.toolLabel && under.includes(c.toolLabel) ? under : `${c.toolLabel} - ${under}`) : holds(c) && c.phase !== 'building' ? `${c.toolLabel} - ${c.progress.kind === 'build' ? 'starting' : 'restarting'}` : c.phase === 'ready' ? `${c.toolLabel}${c.asleep ? ' - asleep' : ''}` : c.phase === 'building' ? `${c.toolLabel} - ${c.step ?? 'building'}` : c.phase === 'moving' ? `${c.toolLabel} - moving its work` : c.phase === 'restarting' ? `${c.toolLabel} - restarting` : `${c.toolLabel} - failed`;
|
|
890
|
+
if (c.asleep) b.dataset.asleep = 'true';
|
|
891
|
+
b.append(title, sub);
|
|
892
|
+
/* Found by a search while hidden: said. */
|
|
893
|
+
if (c.hidden === true && query) { const mark = document.createElement('small'); mark.className = 'shared-mark'; mark.textContent = 'hidden'; b.append(mark); }
|
|
894
|
+
/* Several models answer its simple questions; or they stopped, and it waits for its owner. */
|
|
895
|
+
const waking = (c.wakeups?.items ?? []).filter((w) => w.on).length;
|
|
896
|
+
if (waking > 0) { const mark = document.createElement('small'); mark.className = 'shared-mark'; mark.textContent = `${waking} wake-up${waking > 1 ? 's' : ''}`; b.append(mark); }
|
|
897
|
+
/* It talks through the app, and asks or waits for its owner there. */
|
|
898
|
+
if (c.talk && (c.talk.waiting > 0 || c.talk.state === 'waiting')) { const mark = document.createElement('small'); mark.className = 'needs-mark'; mark.textContent = c.talk.waiting > 0 ? 'asks you' : 'waits for you'; b.append(mark); }
|
|
899
|
+
if (c.answerer?.on) { const mark = document.createElement('small'); mark.className = c.answerer.needs ? 'needs-mark' : 'shared-mark'; mark.textContent = c.answerer.needs ? 'asks for you' : 'answered in your place'; b.append(mark); }
|
|
900
|
+
/* Seen at a glance: this conversation is in other hands too. */
|
|
901
|
+
const holders = sharedDevices.filter((d) => d.grants.some((g) => g.asset === `conversation:${c.name}`)).length;
|
|
902
|
+
if (holders > 0) { const mark = document.createElement('small'); mark.className = 'shared-mark'; mark.textContent = `shared with ${holders} device${holders > 1 ? 's' : ''}`; b.append(mark); }
|
|
903
|
+
b.addEventListener('click', () => select(c.name, { step: true }));
|
|
904
|
+
return b;
|
|
905
|
+
}
|
|
906
|
+
function groupBox(g, inside, all, open) {
|
|
907
|
+
const box = document.createElement('div'); box.className = 'group'; box.dataset.group = g;
|
|
908
|
+
const head = document.createElement('button'); head.type = 'button'; head.className = 'group-head'; head.dataset.group = g;
|
|
909
|
+
head.setAttribute('aria-expanded', String(open));
|
|
910
|
+
const caret = document.createElement('span'); caret.className = 'caret'; caret.setAttribute('aria-hidden', 'true'); caret.textContent = '›';
|
|
911
|
+
const label = document.createElement('span'); label.className = 'group-name'; label.textContent = g;
|
|
912
|
+
const count = document.createElement('small'); count.className = 'count'; count.textContent = String(all.length);
|
|
913
|
+
count.setAttribute('aria-label', `${all.length} conversation${all.length > 1 ? 's' : ''}`);
|
|
914
|
+
/* A small dot for each (up to six); the one that needs you is said even folded. */
|
|
915
|
+
const dots = document.createElement('span'); dots.className = 'group-dots'; dots.setAttribute('aria-hidden', 'true');
|
|
916
|
+
for (const c of all.slice(0, 6)) { const d = document.createElement('span'); d.className = 'mini'; d.dataset.phase = holds(c) ? 'building' : c.phase; if (c.asleep) d.dataset.asleep = 'true'; dots.append(d); }
|
|
917
|
+
head.append(caret, label, count, dots);
|
|
918
|
+
if (all.some(needsYou)) { const n = document.createElement('span'); n.className = 'needs-dot'; n.setAttribute('aria-label', 'one needs you'); head.append(n); head.dataset.needs = 'true'; }
|
|
919
|
+
head.addEventListener('click', () => {
|
|
920
|
+
if (query) return;
|
|
921
|
+
if (folded.has(g)) folded.delete(g); else folded.add(g);
|
|
922
|
+
keepFolded(); paint();
|
|
923
|
+
});
|
|
924
|
+
box.append(head);
|
|
925
|
+
if (open) { const body = document.createElement('div'); body.className = 'group-body'; body.append(...inside.map(itemButton)); box.append(body); }
|
|
926
|
+
return box;
|
|
927
|
+
}
|
|
840
928
|
function paint() {
|
|
841
929
|
const items = $('items');
|
|
842
930
|
items.replaceChildren();
|
|
843
931
|
const hidden = conversations.filter((c) => c.hidden === true);
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
/*
|
|
850
|
-
if (
|
|
851
|
-
|
|
852
|
-
const title = document.createElement('b');
|
|
853
|
-
const dot = document.createElement('span'); dot.className = 'dot';
|
|
854
|
-
title.append(dot, shownName(c));
|
|
855
|
-
if (c.title) b.title = c.name;
|
|
856
|
-
if (c.hidden === true) b.dataset.hidden = 'true';
|
|
857
|
-
const sub = document.createElement('small');
|
|
858
|
-
/* Under way: the step the panel shows, in its words (the tool named once). */
|
|
859
|
-
const under = c.progress && ['building', 'restarting', 'ready'].includes(c.phase) ? stepNow(c.progress, c.toolLabel) : '';
|
|
860
|
-
sub.textContent = under ? (c.toolLabel && under.includes(c.toolLabel) ? under : `${c.toolLabel} - ${under}`) : holds(c) && c.phase !== 'building' ? `${c.toolLabel} - ${c.progress.kind === 'build' ? 'starting' : 'restarting'}` : c.phase === 'ready' ? `${c.toolLabel}${c.asleep ? ' - asleep' : ''}` : c.phase === 'building' ? `${c.toolLabel} - ${c.step ?? 'building'}` : c.phase === 'moving' ? `${c.toolLabel} - moving its work` : c.phase === 'restarting' ? `${c.toolLabel} - restarting` : `${c.toolLabel} - failed`;
|
|
861
|
-
if (c.asleep) b.dataset.asleep = 'true';
|
|
862
|
-
b.append(title, sub);
|
|
863
|
-
/* Several models answer its simple questions; or they stopped, and it waits for its owner. */
|
|
864
|
-
const waking = (c.wakeups?.items ?? []).filter((w) => w.on).length;
|
|
865
|
-
if (waking > 0) { const mark = document.createElement('small'); mark.className = 'shared-mark'; mark.textContent = `${waking} wake-up${waking > 1 ? 's' : ''}`; b.append(mark); }
|
|
866
|
-
/* It talks through the app, and asks or waits for its owner there. */
|
|
867
|
-
if (c.talk && (c.talk.waiting > 0 || c.talk.state === 'waiting')) { const mark = document.createElement('small'); mark.className = 'needs-mark'; mark.textContent = c.talk.waiting > 0 ? 'asks you' : 'waits for you'; b.append(mark); }
|
|
868
|
-
if (c.answerer?.on) { const mark = document.createElement('small'); mark.className = c.answerer.needs ? 'needs-mark' : 'shared-mark'; mark.textContent = c.answerer.needs ? 'asks for you' : 'answered in your place'; b.append(mark); }
|
|
869
|
-
/* Seen at a glance: this conversation is in other hands too. */
|
|
870
|
-
const holders = sharedDevices.filter((d) => d.grants.some((g) => g.asset === `conversation:${c.name}`)).length;
|
|
871
|
-
if (holders > 0) { const mark = document.createElement('small'); mark.className = 'shared-mark'; mark.textContent = `shared with ${holders} device${holders > 1 ? 's' : ''}`; b.append(mark); }
|
|
872
|
-
b.addEventListener('click', () => select(c.name, { step: true }));
|
|
873
|
-
items.append(b);
|
|
932
|
+
/* The one open stays in sight, hidden or not; a search finds hidden ones too. */
|
|
933
|
+
const shown = conversations.filter((c) => (query ? matches(c) : c.hidden !== true || showHidden || c.name === current)).sort(byRank);
|
|
934
|
+
items.append(...shown.filter((c) => !c.group).map(itemButton));
|
|
935
|
+
for (const g of groupsInOrder()) {
|
|
936
|
+
const inside = shown.filter((c) => c.group === g);
|
|
937
|
+
/* Nothing of it in sight (all hidden, or nothing found): not shown. */
|
|
938
|
+
if (inside.length === 0) continue;
|
|
939
|
+
items.append(groupBox(g, inside, conversations.filter((c) => c.group === g).sort(byRank), Boolean(query) || !folded.has(g)));
|
|
874
940
|
}
|
|
941
|
+
/* The folders there are, offered where one is chosen. */
|
|
942
|
+
$('group-names').replaceChildren(...groupsInOrder().map((g) => { const o = document.createElement('option'); o.value = g; return o; }));
|
|
943
|
+
const hits = [...items.querySelectorAll('.item')];
|
|
944
|
+
chosenHit = Math.max(0, Math.min(chosenHit, hits.length - 1));
|
|
945
|
+
if (query) hits.forEach((h, i) => { h.toggleAttribute('data-chosen', i === chosenHit); if (i === chosenHit) h.scrollIntoView?.({ block: 'nearest' }); });
|
|
946
|
+
$('search-none').hidden = !query || hits.length > 0;
|
|
875
947
|
const toggle = $('hidden-toggle');
|
|
876
|
-
toggle.hidden = DEVICE || hidden.length === 0;
|
|
948
|
+
toggle.hidden = DEVICE || hidden.length === 0 || Boolean(query);
|
|
877
949
|
toggle.textContent = showHidden ? `Hide ${hidden.length} hidden` : `${hidden.length} hidden`;
|
|
878
950
|
toggle.setAttribute('aria-expanded', String(showHidden));
|
|
879
951
|
const calling = hidden.some(needsYou);
|
|
@@ -883,6 +955,151 @@
|
|
|
883
955
|
$('empty').hidden = conversations.length > 0;
|
|
884
956
|
paintDeviceCount();
|
|
885
957
|
}
|
|
958
|
+
|
|
959
|
+
/* THE SEARCH: a step of its own (Back closes it); Escape empties it, then closes it. */
|
|
960
|
+
function openSearch() {
|
|
961
|
+
if (DEVICE) return;
|
|
962
|
+
/* Asked for: the keyboard comes with it, lock or not. */
|
|
963
|
+
if (!$('search').hidden) { $('search-field').focus(); return; }
|
|
964
|
+
$('search').hidden = false; $('search-open').setAttribute('aria-expanded', 'true');
|
|
965
|
+
nav.push('search', () => closeSearch());
|
|
966
|
+
$('search-field').focus();
|
|
967
|
+
}
|
|
968
|
+
function closeSearch() {
|
|
969
|
+
$('search').hidden = true; $('search-open').setAttribute('aria-expanded', 'false');
|
|
970
|
+
$('search-field').value = ''; query = ''; chosenHit = 0;
|
|
971
|
+
paint();
|
|
972
|
+
}
|
|
973
|
+
$('search-open').addEventListener('click', () => { if ($('search').hidden) openSearch(); else if (!nav.close('search')) closeSearch(); });
|
|
974
|
+
$('search-field').addEventListener('input', () => { query = $('search-field').value.trim(); chosenHit = 0; paint(); });
|
|
975
|
+
$('search-field').addEventListener('keydown', (event) => {
|
|
976
|
+
if (event.isComposing) return;
|
|
977
|
+
const hits = [...$('items').querySelectorAll('.item')];
|
|
978
|
+
if (event.key === 'Escape') {
|
|
979
|
+
event.preventDefault(); event.stopPropagation();
|
|
980
|
+
if ($('search-field').value) { $('search-field').value = ''; query = ''; chosenHit = 0; paint(); return; }
|
|
981
|
+
if (!nav.close('search')) closeSearch();
|
|
982
|
+
$('search-open').focus();
|
|
983
|
+
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
984
|
+
event.preventDefault();
|
|
985
|
+
chosenHit += event.key === 'ArrowDown' ? 1 : -1;
|
|
986
|
+
paint();
|
|
987
|
+
} else if (event.key === 'Enter') {
|
|
988
|
+
event.preventDefault();
|
|
989
|
+
const hit = hits[chosenHit] ?? hits[0];
|
|
990
|
+
if (hit) select(hit.dataset.name, { step: true });
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
/* "/" or Ctrl+K, from anywhere but a field and the terminal (its tool needs its keys). */
|
|
994
|
+
document.addEventListener('keydown', (event) => {
|
|
995
|
+
if (DEVICE || event.defaultPrevented || event.isComposing || event.altKey) return;
|
|
996
|
+
const slash = event.key === '/' && !event.ctrlKey && !event.metaKey;
|
|
997
|
+
const k = (event.ctrlKey || event.metaKey) && (event.key === 'k' || event.key === 'K');
|
|
998
|
+
if (!slash && !k) return;
|
|
999
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
1000
|
+
if (target?.closest('#terminal') || target?.matches('input, textarea, select, [contenteditable]:not([contenteditable=false])')) return;
|
|
1001
|
+
if ($('list').hidden || (narrow() && app.dataset.view !== 'list')) return;
|
|
1002
|
+
event.preventDefault();
|
|
1003
|
+
openSearch();
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
/* MOVED BY HAND: dragged with a mouse; held, then dragged, with a finger;
|
|
1007
|
+
Alt and an arrow key from the keyboard. The server keeps the order. */
|
|
1008
|
+
let drag = null; let justDragged = false;
|
|
1009
|
+
const dropTarget = (d, x, y) => {
|
|
1010
|
+
const under = document.elementFromPoint(x, y);
|
|
1011
|
+
let el = under?.closest('#items .item, #items .group-head');
|
|
1012
|
+
if (!el || el === d.el) return null;
|
|
1013
|
+
if (d.kind === 'group') {
|
|
1014
|
+
if (el.classList.contains('item')) { if (!el.dataset.group) return null; el = el.closest('.group').querySelector('.group-head'); }
|
|
1015
|
+
if (el === d.el) return null;
|
|
1016
|
+
}
|
|
1017
|
+
const box = el.getBoundingClientRect();
|
|
1018
|
+
const where = d.kind === 'item' && el.classList.contains('group-head') ? 'into' : y < box.top + box.height / 2 ? 'before' : 'after';
|
|
1019
|
+
return { el, where };
|
|
1020
|
+
};
|
|
1021
|
+
const unmark = () => { for (const x of $('items').querySelectorAll('[data-drop]')) x.removeAttribute('data-drop'); };
|
|
1022
|
+
function endDrag() {
|
|
1023
|
+
if (!drag) return;
|
|
1024
|
+
clearTimeout(drag.timer);
|
|
1025
|
+
drag.el.removeAttribute('data-dragging'); $('items').removeAttribute('data-dragging');
|
|
1026
|
+
unmark(); drag = null;
|
|
1027
|
+
}
|
|
1028
|
+
function startDrag() {
|
|
1029
|
+
if (!drag) return;
|
|
1030
|
+
drag.on = true; drag.el.setAttribute('data-dragging', 'true'); $('items').setAttribute('data-dragging', 'true');
|
|
1031
|
+
if (drag.touch) navigator.vibrate?.(15);
|
|
1032
|
+
}
|
|
1033
|
+
async function dropped(d, target) {
|
|
1034
|
+
if (!target) return;
|
|
1035
|
+
try {
|
|
1036
|
+
if (d.kind === 'group') {
|
|
1037
|
+
const groups = groupsInOrder().filter((g) => g !== d.el.dataset.group);
|
|
1038
|
+
const at = groups.indexOf(target.el.dataset.group) + (target.where === 'after' ? 1 : 0);
|
|
1039
|
+
groups.splice(at, 0, d.el.dataset.group);
|
|
1040
|
+
await post(`${API}/order`, { groups });
|
|
1041
|
+
} else {
|
|
1042
|
+
const name = d.el.dataset.name;
|
|
1043
|
+
const was = conversations.find((c) => c.name === name)?.group ?? '';
|
|
1044
|
+
const group = target.where === 'into' ? target.el.dataset.group : target.el.dataset.group ?? '';
|
|
1045
|
+
const order = [...conversations].sort(byRank).map((c) => c.name).filter((n) => n !== name);
|
|
1046
|
+
const firstOf = order.findIndex((n) => (conversations.find((c) => c.name === n)?.group ?? '') === group);
|
|
1047
|
+
const at = target.where === 'into' ? (firstOf < 0 ? 0 : firstOf) : order.indexOf(target.el.dataset.name) + (target.where === 'after' ? 1 : 0);
|
|
1048
|
+
order.splice(at, 0, name);
|
|
1049
|
+
if (group !== was) await post(`${API}/conversations/${encodeURIComponent(name)}/group`, { group });
|
|
1050
|
+
await post(`${API}/order`, { order });
|
|
1051
|
+
if (target.where === 'into' && folded.has(group)) { folded.delete(group); keepFolded(); }
|
|
1052
|
+
}
|
|
1053
|
+
} catch (error) { show(error.message, 'Back', () => { notice.hidden = true; }); }
|
|
1054
|
+
await refresh();
|
|
1055
|
+
}
|
|
1056
|
+
$('items').addEventListener('pointerdown', (event) => {
|
|
1057
|
+
if (DEVICE || query || event.button !== 0) return;
|
|
1058
|
+
const el = event.target.closest('.item, .group-head');
|
|
1059
|
+
if (!el) return;
|
|
1060
|
+
endDrag();
|
|
1061
|
+
drag = { el, kind: el.classList.contains('item') ? 'item' : 'group', x: event.clientX, y: event.clientY, id: event.pointerId, on: false, touch: event.pointerType !== 'mouse' };
|
|
1062
|
+
if (drag.touch) drag.timer = setTimeout(startDrag, 450);
|
|
1063
|
+
});
|
|
1064
|
+
addEventListener('pointermove', (event) => {
|
|
1065
|
+
if (!drag || event.pointerId !== drag.id) return;
|
|
1066
|
+
const far = Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 8;
|
|
1067
|
+
/* A finger that moves before it was held scrolls the list: no drag. */
|
|
1068
|
+
if (!drag.on) { if (drag.touch) { if (far) endDrag(); return; } if (!far) return; startDrag(); }
|
|
1069
|
+
event.preventDefault();
|
|
1070
|
+
unmark();
|
|
1071
|
+
const target = dropTarget(drag, event.clientX, event.clientY);
|
|
1072
|
+
if (target) target.el.dataset.drop = target.where;
|
|
1073
|
+
}, { passive: false });
|
|
1074
|
+
addEventListener('pointerup', (event) => {
|
|
1075
|
+
if (!drag || event.pointerId !== drag.id) return;
|
|
1076
|
+
const d = drag; const target = d.on ? dropTarget(d, event.clientX, event.clientY) : null;
|
|
1077
|
+
endDrag();
|
|
1078
|
+
if (!d.on) return;
|
|
1079
|
+
/* The click that ends a drag opens nothing. */
|
|
1080
|
+
justDragged = true; setTimeout(() => { justDragged = false; }, 0);
|
|
1081
|
+
void dropped(d, target);
|
|
1082
|
+
});
|
|
1083
|
+
addEventListener('pointercancel', () => endDrag());
|
|
1084
|
+
/* Held: the page neither scrolls nor offers its own menu under the finger. */
|
|
1085
|
+
document.addEventListener('touchmove', (event) => { if (drag?.on) event.preventDefault(); }, { passive: false });
|
|
1086
|
+
$('items').addEventListener('contextmenu', (event) => { if (drag) event.preventDefault(); });
|
|
1087
|
+
$('items').addEventListener('click', (event) => { if (justDragged) { event.stopPropagation(); event.preventDefault(); } }, true);
|
|
1088
|
+
$('items').addEventListener('keydown', async (event) => {
|
|
1089
|
+
if (!event.altKey || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return;
|
|
1090
|
+
const el = event.target.closest('.item, .group-head');
|
|
1091
|
+
if (!el) return;
|
|
1092
|
+
event.preventDefault();
|
|
1093
|
+
const to = event.key === 'ArrowUp' ? 'up' : 'down';
|
|
1094
|
+
try {
|
|
1095
|
+
if (el.classList.contains('item')) await post(`${API}/conversations/${encodeURIComponent(el.dataset.name)}/move`, { to });
|
|
1096
|
+
else await post(`${API}/groups/move`, { group: el.dataset.group, to });
|
|
1097
|
+
} catch { /* said by the list as it is */ }
|
|
1098
|
+
await refresh();
|
|
1099
|
+
const again = el.classList.contains('item') ? `.item[data-name="${CSS.escape(el.dataset.name)}"]` : `.group-head[data-group="${CSS.escape(el.dataset.group)}"]`;
|
|
1100
|
+
$('items').querySelector(again)?.focus();
|
|
1101
|
+
});
|
|
1102
|
+
|
|
886
1103
|
async function refresh() {
|
|
887
1104
|
clearTimeout(pollTimer);
|
|
888
1105
|
try {
|
|
@@ -968,8 +1185,9 @@
|
|
|
968
1185
|
const title = $('create-name').value.trim();
|
|
969
1186
|
const id = $('create-id').value.trim();
|
|
970
1187
|
try {
|
|
971
|
-
const
|
|
972
|
-
|
|
1188
|
+
const group = $('create-group').value.trim();
|
|
1189
|
+
const made = await post(`${API}/conversations`, { title, ...(id ? { name: id } : {}), ...(group ? { group } : {}), tool: chosenTool, workOnDisk: $('create-disk').checked });
|
|
1190
|
+
$('create').hidden = true; $('create-name').value = ''; $('create-id').value = ''; $('create-group').value = ''; $('create-disk').checked = false;
|
|
973
1191
|
await refresh();
|
|
974
1192
|
select(made.name);
|
|
975
1193
|
} catch (error) { $('create-error').textContent = error.message; $('create-error').hidden = false; }
|
|
@@ -1587,7 +1805,12 @@
|
|
|
1587
1805
|
disk.value = String(now);
|
|
1588
1806
|
memory.dataset.of = of;
|
|
1589
1807
|
}
|
|
1590
|
-
|
|
1808
|
+
/* Not ready (built, restarted, moved): its sizes are shown, and chosen once it is. */
|
|
1809
|
+
$('machine-size').hidden = DEVICE || !here || here.phase === 'failed';
|
|
1810
|
+
const waiting = here && (here.phase !== 'ready' || holds(here)) ? (here.phase === 'moving' ? 'Available once it has moved.' : here.phase === 'restarting' || (here.phase === 'ready' && here.progress?.kind !== 'build') ? 'Available once it has restarted.' : 'Available once its machine is built.') : '';
|
|
1811
|
+
for (const id of ['machine-memory', 'machine-disk', 'machine-size-apply']) $(id).disabled = Boolean(waiting);
|
|
1812
|
+
$('machine-size').toggleAttribute('data-waiting', Boolean(waiting));
|
|
1813
|
+
$('machine-size-wait').textContent = waiting; $('machine-size-wait').hidden = !waiting;
|
|
1591
1814
|
/* When its tool starts: its last session, or a new one -- for a tool that can take one up. */
|
|
1592
1815
|
$('resume-box').hidden = DEVICE || !here?.resumes;
|
|
1593
1816
|
for (const b of $('resume-on').children) b.setAttribute('aria-pressed', String((b.dataset.on === 'true') === (here?.resume !== false)));
|
|
@@ -1947,7 +2170,11 @@
|
|
|
1947
2170
|
const here = conversations.find((x) => x.name === current);
|
|
1948
2171
|
if (document.activeElement !== $('chat-name')) $('chat-name').value = here?.chatName ?? '';
|
|
1949
2172
|
if (document.activeElement !== $('title-name')) $('title-name').value = shownName(here);
|
|
1950
|
-
$('title-box').hidden = $('hide-box').hidden = DEVICE || !here;
|
|
2173
|
+
$('title-box').hidden = $('hide-box').hidden = $('group-box').hidden = DEVICE || !here;
|
|
2174
|
+
if (document.activeElement !== $('group-name')) $('group-name').value = here?.group ?? '';
|
|
2175
|
+
$('group-rename-form').hidden = !here?.group;
|
|
2176
|
+
if (document.activeElement !== $('group-rename')) $('group-rename').value = here?.group ?? '';
|
|
2177
|
+
$('group-up').hidden = $('group-down').hidden = !here?.group;
|
|
1951
2178
|
for (const b of $('hide-on').children) b.setAttribute('aria-pressed', String((b.dataset.on === 'true') === (here?.hidden === true)));
|
|
1952
2179
|
$('voice-mark-box').hidden = !here?.ai;
|
|
1953
2180
|
for (const b of $('voice-mark').children) b.setAttribute('aria-pressed', String((b.dataset.on === 'true') === (here?.voiceMark === true)));
|
|
@@ -1967,6 +2194,26 @@
|
|
|
1967
2194
|
catch (error) { $('panel-error').textContent = error.message; $('panel-error').hidden = false; }
|
|
1968
2195
|
});
|
|
1969
2196
|
}
|
|
2197
|
+
const panelTry = async (job) => {
|
|
2198
|
+
$('panel-error').hidden = true;
|
|
2199
|
+
try { await job(); await refresh(); await paintTalkBox(); }
|
|
2200
|
+
catch (error) { $('panel-error').textContent = error.message; $('panel-error').hidden = false; }
|
|
2201
|
+
};
|
|
2202
|
+
$('group-form').addEventListener('submit', (event) => {
|
|
2203
|
+
event.preventDefault();
|
|
2204
|
+
void panelTry(async () => { await post(`${API}/conversations/${encodeURIComponent(current)}/group`, { group: $('group-name').value.trim() }); $('group-name').blur(); });
|
|
2205
|
+
});
|
|
2206
|
+
$('group-rename-form').addEventListener('submit', (event) => {
|
|
2207
|
+
event.preventDefault();
|
|
2208
|
+
const from = conversations.find((x) => x.name === current)?.group;
|
|
2209
|
+
void panelTry(async () => {
|
|
2210
|
+
const to = $('group-rename').value.trim();
|
|
2211
|
+
await post(`${API}/groups/rename`, { from, to }); $('group-rename').blur();
|
|
2212
|
+
if (folded.delete(from)) { folded.add(to); keepFolded(); }
|
|
2213
|
+
});
|
|
2214
|
+
});
|
|
2215
|
+
for (const [id, to] of [['move-up', 'up'], ['move-down', 'down']]) $(id).addEventListener('click', () => void panelTry(() => post(`${API}/conversations/${encodeURIComponent(current)}/move`, { to })));
|
|
2216
|
+
for (const [id, to] of [['group-up', 'up'], ['group-down', 'down']]) $(id).addEventListener('click', () => void panelTry(() => post(`${API}/groups/move`, { group: conversations.find((x) => x.name === current)?.group, to })));
|
|
1970
2217
|
$('title-form').addEventListener('submit', async (event) => {
|
|
1971
2218
|
event.preventDefault();
|
|
1972
2219
|
$('panel-error').hidden = true;
|
|
@@ -2886,9 +3133,27 @@
|
|
|
2886
3133
|
? `Who may sign in: ${platform.operators.join(', ')}. Address: ${platform.address ?? 'not declared'}. Both were set when this was installed.`
|
|
2887
3134
|
: 'Development mode: anyone on this machine is the operator.';
|
|
2888
3135
|
$('settings-about').textContent = `Clavarium ${platform.version ?? ''}`;
|
|
3136
|
+
void paintInstance();
|
|
2889
3137
|
await Promise.all([paintHosts(), paintShelf(), paintServer(), paintUpdates()]);
|
|
2890
3138
|
}
|
|
2891
3139
|
|
|
3140
|
+
/* ---------- the name of this Clavarium, in Settings ---------- */
|
|
3141
|
+
async function paintInstance() {
|
|
3142
|
+
const about = await fetch(`${API}/instance`).then((r) => r.json()).catch(() => null);
|
|
3143
|
+
if (!about) return;
|
|
3144
|
+
$('instance-name').placeholder = about.defaultName;
|
|
3145
|
+
if (document.activeElement !== $('instance-name')) $('instance-name').value = about.given ? about.name : '';
|
|
3146
|
+
}
|
|
3147
|
+
$('instance-form').addEventListener('submit', async (event) => {
|
|
3148
|
+
event.preventDefault();
|
|
3149
|
+
$('instance-error').hidden = true;
|
|
3150
|
+
try {
|
|
3151
|
+
const about = await post(`${API}/instance`, { name: $('instance-name').value.trim() });
|
|
3152
|
+
appName = about.name; paintTitle(); $('instance-name').blur(); await paintInstance();
|
|
3153
|
+
if (!installName()) pointManifest('');
|
|
3154
|
+
} catch (error) { $('instance-error').textContent = error.message; $('instance-error').hidden = false; }
|
|
3155
|
+
});
|
|
3156
|
+
|
|
2892
3157
|
/* ---------- Updates: what runs, what is out, how it updates itself (server/updates.mjs) ---------- */
|
|
2893
3158
|
let updatesTimer; let updatesBusy = false;
|
|
2894
3159
|
const RESULT_WORDS = { done: 'Done', 'rolled-back': 'Did not work: went back', failed: 'Failed' };
|
|
@@ -5359,17 +5624,22 @@
|
|
|
5359
5624
|
let installEvent = null;
|
|
5360
5625
|
let installOffered = false;
|
|
5361
5626
|
const offerInstall = () => { installOffered = true; $('install').hidden = false; };
|
|
5362
|
-
|
|
5363
|
-
|
|
5627
|
+
/* Chrome reads the manifest again when its address changes, and may then
|
|
5628
|
+
offer the install anew: that offer, if it comes soon, is the one used. */
|
|
5629
|
+
let freshOffer = null;
|
|
5630
|
+
addEventListener('beforeinstallprompt', (event) => {
|
|
5631
|
+
event.preventDefault(); installEvent = event; offerInstall();
|
|
5632
|
+
if (freshOffer) { const take = freshOffer; freshOffer = null; take(event); }
|
|
5633
|
+
});
|
|
5634
|
+
addEventListener('appinstalled', () => { installEvent = null; installOffered = false; $('install').hidden = true; $('install-name-form').hidden = true; });
|
|
5364
5635
|
if (apple && !asApp) offerInstall();
|
|
5636
|
+
/* FIRST A NAME FOR THIS DEVICE, then the browser's own question. */
|
|
5365
5637
|
$('install').addEventListener('click', async () => {
|
|
5366
5638
|
const back = () => { notice.hidden = true; focusTerminal(); };
|
|
5367
5639
|
if (installEvent) {
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
if (choice.outcome === 'accepted') { installOffered = false; $('install').hidden = true; }
|
|
5372
|
-
else installEvent = asked;
|
|
5640
|
+
$('install-name').value = installName() || appName;
|
|
5641
|
+
$('install-name-form').hidden = false;
|
|
5642
|
+
focusField($('install-name'));
|
|
5373
5643
|
return;
|
|
5374
5644
|
}
|
|
5375
5645
|
show(DEVICE
|
|
@@ -5378,6 +5648,28 @@
|
|
|
5378
5648
|
'Back', back);
|
|
5379
5649
|
});
|
|
5380
5650
|
|
|
5651
|
+
$('install-name-cancel').addEventListener('click', () => { $('install-name-form').hidden = true; });
|
|
5652
|
+
$('install-name-form').addEventListener('submit', async (event) => {
|
|
5653
|
+
event.preventDefault();
|
|
5654
|
+
if (!installEvent) { $('install-name-form').hidden = true; return; }
|
|
5655
|
+
/* Text only, as the server takes it; anything else falls back to the name of this Clavarium. */
|
|
5656
|
+
const name = $('install-name').value.trim().replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, '').slice(0, 30).trim();
|
|
5657
|
+
const chosen = name && name !== appName ? name : '';
|
|
5658
|
+
$('install-name-form').hidden = true;
|
|
5659
|
+
let asked = installEvent;
|
|
5660
|
+
if (chosen !== installName()) {
|
|
5661
|
+
try { if (chosen) localStorage.setItem(INSTALL_NAME, chosen); else localStorage.removeItem(INSTALL_NAME); } catch { /* the address carries it all the same */ }
|
|
5662
|
+
pointManifest(chosen);
|
|
5663
|
+
asked = await new Promise((resolve) => { const t = setTimeout(() => { freshOffer = null; resolve(installEvent); }, 1200); freshOffer = (e) => { clearTimeout(t); resolve(e); }; });
|
|
5664
|
+
}
|
|
5665
|
+
if (!asked) return;
|
|
5666
|
+
installEvent = null;
|
|
5667
|
+
asked.prompt();
|
|
5668
|
+
const choice = await asked.userChoice.catch(() => ({ outcome: 'dismissed' }));
|
|
5669
|
+
if (choice.outcome === 'accepted') { installOffered = false; $('install').hidden = true; }
|
|
5670
|
+
else installEvent = asked;
|
|
5671
|
+
});
|
|
5672
|
+
|
|
5381
5673
|
/* ---------- the other conversations of this device ---------- */
|
|
5382
5674
|
/* A device may have been given several conversations, each by a code of
|
|
5383
5675
|
its own. One is shown at a time; the others are a tap away. */
|
|
@@ -5417,6 +5709,7 @@
|
|
|
5417
5709
|
});
|
|
5418
5710
|
|
|
5419
5711
|
async function start() {
|
|
5712
|
+
void readAppName();
|
|
5420
5713
|
if (DEVICE) {
|
|
5421
5714
|
view('room');
|
|
5422
5715
|
const fresh = location.hash.length > 1; /* arriving with a code: a first visit */
|
|
@@ -5431,7 +5724,7 @@
|
|
|
5431
5724
|
if (door.conversation === null) {
|
|
5432
5725
|
/* Given folders and no conversation: this page is their files, and nothing else. */
|
|
5433
5726
|
for (const id of ['keys', 'shortcuts', 'copy-open', 'theme', 'files-close']) $(id).hidden = true;
|
|
5434
|
-
$('name').textContent = 'Shared files';
|
|
5727
|
+
$('name').textContent = 'Shared files'; pageTitle = 'Shared files'; paintTitle();
|
|
5435
5728
|
say('', 'ended'); $('state').hidden = true; notice.hidden = true;
|
|
5436
5729
|
await paintFiles(); $('files').hidden = false;
|
|
5437
5730
|
$('files-none').textContent = 'Nothing is shared with this device any more.';
|
package/web/device.html
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
</div>
|
|
29
29
|
<button type="button" id="menu" aria-expanded="false" aria-label="More">⋯</button>
|
|
30
30
|
</header>
|
|
31
|
+
<form id="install-name-form" class="row install-bar" autocomplete="off" hidden><label for="install-name">Name on this device</label><input id="install-name" maxlength="30" spellcheck="false"><button type="submit" class="primary">Install</button><button type="button" id="install-name-cancel">Cancel</button></form>
|
|
31
32
|
<main>
|
|
32
33
|
<div id="terminal"></div>
|
|
33
34
|
<!-- Where its conversation talks through the app: its chat, drawn by
|
package/web/index.html
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
<link rel="stylesheet" href="/vendor/diff2html.css">
|
|
14
14
|
<div id="app" data-place="conversations">
|
|
15
15
|
<aside id="list" hidden>
|
|
16
|
-
<div class="top"><b>
|
|
16
|
+
<div class="top"><b id="list-title" title="Conversations">Clavarium</b><span class="top-acts"><button type="button" id="search-open" class="icon-btn" aria-label="Search conversations" title="Search (/ or Ctrl+K)" aria-expanded="false" aria-controls="search"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg></button><button type="button" id="new" class="primary">+ New</button></span></div>
|
|
17
|
+
<!-- SEARCH, discreet: opened by the magnifier, "/" or Ctrl+K; filters as it is typed. -->
|
|
18
|
+
<div id="search" role="search" hidden><input id="search-field" type="search" placeholder="Search" aria-label="Search conversations: name, folder, identifier, tool" autocomplete="off" spellcheck="false" maxlength="60" aria-controls="items"></div>
|
|
17
19
|
<form id="create" hidden autocomplete="off">
|
|
18
20
|
<label for="create-name">Name</label>
|
|
19
21
|
<input id="create-name" name="name" required maxlength="60" placeholder="Website redesign" spellcheck="false">
|
|
@@ -22,6 +24,9 @@
|
|
|
22
24
|
<input id="create-id" maxlength="32" pattern="[a-z]([a-z0-9\-]{0,30}[a-z0-9])?" title="Lower-case letters, digits and hyphens; starts with a letter, does not end with a hyphen" placeholder="website-redesign" spellcheck="false" autocapitalize="none">
|
|
23
25
|
<p class="hint">Used for its machine, its folders, its address and what is given to it. Drawn from the name when empty; it cannot be changed later. The name can.</p>
|
|
24
26
|
</details>
|
|
27
|
+
<label for="create-group">Folder <span class="hint">(optional)</span></label>
|
|
28
|
+
<input id="create-group" maxlength="40" list="group-names" placeholder="None" spellcheck="false" autocomplete="off">
|
|
29
|
+
<datalist id="group-names"></datalist>
|
|
25
30
|
<span class="legend">What runs in it</span>
|
|
26
31
|
<div id="create-tools" class="choices" role="radiogroup" aria-label="What runs in it"></div>
|
|
27
32
|
<label class="check"><input type="checkbox" id="create-disk"> Work on the machine's own disk</label>
|
|
@@ -31,6 +36,7 @@
|
|
|
31
36
|
<span class="hint">a new machine takes a few minutes</span></div>
|
|
32
37
|
</form>
|
|
33
38
|
<div id="items"></div>
|
|
39
|
+
<p id="search-none" class="hint" hidden>Nothing found.</p>
|
|
34
40
|
<!-- Hidden conversations still run; this shows them, and says when one needs you. -->
|
|
35
41
|
<button type="button" id="hidden-toggle" class="inline" hidden aria-expanded="false"></button>
|
|
36
42
|
<p id="empty" class="hint" hidden>No conversation yet. A conversation is a machine of its own with one tool running in it.</p>
|
|
@@ -122,6 +128,13 @@
|
|
|
122
128
|
<div id="title-box" hidden>
|
|
123
129
|
<form id="title-form" class="row" autocomplete="off"><label for="title-name">Name</label><input id="title-name" maxlength="60" spellcheck="false"><button type="submit">Save</button></form>
|
|
124
130
|
</div>
|
|
131
|
+
<div id="group-box" hidden>
|
|
132
|
+
<form id="group-form" class="row" autocomplete="off"><label for="group-name">Folder</label><input id="group-name" maxlength="40" list="group-names" placeholder="None" spellcheck="false"><button type="submit">Save</button></form>
|
|
133
|
+
<p class="hint">A folder of the list, shared by every device: choose one, type a new one, or empty it for none.</p>
|
|
134
|
+
<form id="group-rename-form" class="row" autocomplete="off" hidden><label for="group-rename">Rename its folder</label><input id="group-rename" maxlength="40" spellcheck="false"><button type="submit">Rename for all</button></form>
|
|
135
|
+
<div class="row" id="order-row"><span>Its place</span><button type="button" id="move-up">Move up</button><button type="button" id="move-down">Move down</button><button type="button" id="group-up" hidden>Folder up</button><button type="button" id="group-down" hidden>Folder down</button></div>
|
|
136
|
+
<p class="hint" id="order-hint">The same order on every device. In the list: drag it (on a phone or tablet, hold it, then drag), or Alt and an arrow key.</p>
|
|
137
|
+
</div>
|
|
125
138
|
<div id="hide-box" hidden>
|
|
126
139
|
<div class="row"><span>In the list</span><div class="levels" id="hide-on" role="group" aria-label="Hidden from the list"><button type="button" data-on="false">Shown</button><button type="button" data-on="true">Hidden</button></div></div>
|
|
127
140
|
<p class="hint">Hidden, it runs as before, keeps what it was given and stays in Access; "hidden" at the bottom of the list shows it.</p>
|
|
@@ -184,6 +197,7 @@
|
|
|
184
197
|
<select id="machine-memory"></select>
|
|
185
198
|
<label for="machine-disk">Disk</label>
|
|
186
199
|
<select id="machine-disk"></select>
|
|
200
|
+
<p class="hint" id="machine-size-wait" role="status" hidden></p>
|
|
187
201
|
<p class="hint" id="machine-size-hint">Changing them stops the machine cleanly, sets them and starts it again: its work folder, folders and branches stay. A disk can only grow.</p>
|
|
188
202
|
<div class="row"><button type="submit" id="machine-size-apply">Apply...</button></div>
|
|
189
203
|
</form>
|
|
@@ -532,6 +546,7 @@
|
|
|
532
546
|
<summary><span class="chapter-title">Screen</span><span class="chapter-about">Light or dark, text size, the app.</span></summary>
|
|
533
547
|
<div class="chapter-body">
|
|
534
548
|
<div class="row"><span>Screen</span><button type="button" id="theme" aria-label="Light or dark screen">Light</button><button type="button" id="install" class="primary" hidden>Install the app</button></div>
|
|
549
|
+
<form id="install-name-form" class="row" autocomplete="off" hidden><label for="install-name">Name on this device</label><input id="install-name" maxlength="30" spellcheck="false"><button type="submit" class="primary">Install</button><button type="button" id="install-name-cancel">Cancel</button></form>
|
|
535
550
|
<div class="row"><span>Text size</span><span class="text-size" id="settings-text" role="group" aria-label="Text size"><button type="button" data-text="smaller" aria-label="Smaller text">A−</button><button type="button" data-text="normal" aria-label="Normal text size" class="text-now">100%</button><button type="button" data-text="larger" aria-label="Larger text">A+</button></span></div>
|
|
536
551
|
<p class="hint">All text, the terminal's too, on this device only. Also: two fingers pinched, Ctrl and the wheel, Ctrl + and Ctrl -.</p>
|
|
537
552
|
</div>
|
|
@@ -544,6 +559,14 @@
|
|
|
544
559
|
</div>
|
|
545
560
|
</details>
|
|
546
561
|
<h3 class="chapter-group">This server</h3>
|
|
562
|
+
<details class="chapter" id="settings-ch-general" data-chapter="s-general">
|
|
563
|
+
<summary><span class="chapter-title">General</span><span class="chapter-about">The name of this Clavarium.</span></summary>
|
|
564
|
+
<div class="chapter-body">
|
|
565
|
+
<form id="instance-form" class="row" autocomplete="off"><label for="instance-name">Name of this Clavarium</label><input id="instance-name" maxlength="30" spellcheck="false"><button type="submit">Save</button></form>
|
|
566
|
+
<p class="hint" id="instance-hint">In the title of its tab, above its list, in its alerts, and under its icon once installed. Empty: the name drawn from its address.</p>
|
|
567
|
+
<p class="error" id="instance-error" role="alert" hidden></p>
|
|
568
|
+
</div>
|
|
569
|
+
</details>
|
|
547
570
|
<details class="chapter" id="settings-ch-shortcuts" data-chapter="s-shortcuts">
|
|
548
571
|
<summary><span class="chapter-title">Shortcuts</span><span class="chapter-about">The ready-made answers under the terminal.</span></summary>
|
|
549
572
|
<div class="chapter-body">
|
package/web/style.css
CHANGED
|
@@ -1215,3 +1215,44 @@ select.pill { appearance: auto; }
|
|
|
1215
1215
|
.lk-col h3.lk-colh .lk-add { width: 26px; height: 26px; font-size: .95rem; letter-spacing: 0; }
|
|
1216
1216
|
.lk-phead { margin: 4px 0 6px; }
|
|
1217
1217
|
.as-kinds { display: flex; flex-wrap: wrap; gap: 8px; } .as-kinds button { display: inline-flex; align-items: center; gap: 8px; }
|
|
1218
|
+
|
|
1219
|
+
/* ---------- the list: search, folders, moved by hand ---------- */
|
|
1220
|
+
#list .top-acts { display: flex; align-items: center; gap: 6px; }
|
|
1221
|
+
#list-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
1222
|
+
.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; padding: 0; background: transparent; border-color: transparent; color: var(--ink2); }
|
|
1223
|
+
.icon-btn:hover, .icon-btn[aria-expanded="true"] { color: inherit; border-color: var(--line); }
|
|
1224
|
+
.icon-btn .ic { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
|
|
1225
|
+
#search[hidden] { display: none; }
|
|
1226
|
+
#search-field { width: 100%; box-sizing: border-box; padding: 6px 10px; font-size: .9rem; border-radius: 8px; }
|
|
1227
|
+
#search-none { margin: 0 .25rem; }
|
|
1228
|
+
.item[data-chosen] { border-color: var(--brass); }
|
|
1229
|
+
.group { display: flex; flex-direction: column; gap: 6px; flex-shrink: 0; }
|
|
1230
|
+
.group-head { display: flex; align-items: center; gap: 6px; width: 100%; text-align: left; padding: 6px 8px; background: transparent; border-color: transparent; font-weight: 600; color: var(--ink2); }
|
|
1231
|
+
.group-head:hover { border-color: var(--line); }
|
|
1232
|
+
.group-head .caret { display: inline-block; width: 1em; transition: transform .15s; }
|
|
1233
|
+
.group-head[aria-expanded="true"] .caret { transform: rotate(90deg); }
|
|
1234
|
+
.group-head .group-name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink, inherit); }
|
|
1235
|
+
.group-head .count { font-weight: 400; color: var(--ink2); }
|
|
1236
|
+
.group-head .group-dots { display: inline-flex; gap: 3px; margin-left: auto; }
|
|
1237
|
+
.group-head .mini { width: 6px; height: 6px; border-radius: 50%; background: var(--ink2); }
|
|
1238
|
+
.group-head .mini[data-phase="ready"] { background: var(--ok); }
|
|
1239
|
+
.group-head .mini[data-phase="building"], .group-head .mini[data-phase="restarting"], .group-head .mini[data-phase="moving"] { background: var(--warn); }
|
|
1240
|
+
.group-head .mini[data-phase="failed"] { background: var(--danger); }
|
|
1241
|
+
.group-head .mini[data-asleep="true"] { background: var(--ink2); }
|
|
1242
|
+
.group-head .needs-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--brass); flex-shrink: 0; }
|
|
1243
|
+
.group-body { display: flex; flex-direction: column; gap: 6px; padding-left: 12px; border-left: 2px solid var(--line); margin-left: 8px; }
|
|
1244
|
+
#items .item, #items .group-head { -webkit-touch-callout: none; user-select: none; -webkit-user-select: none; }
|
|
1245
|
+
#items [data-dragging] { opacity: .5; }
|
|
1246
|
+
#items[data-dragging] { cursor: grabbing; }
|
|
1247
|
+
#items [data-drop="before"] { box-shadow: 0 -3px 0 0 var(--brass); }
|
|
1248
|
+
#items [data-drop="after"] { box-shadow: 0 3px 0 0 var(--brass); }
|
|
1249
|
+
#items [data-drop="into"] { outline: 2px dashed var(--brass); outline-offset: 1px; }
|
|
1250
|
+
#machine-size[data-waiting] select, #machine-size[data-waiting] button { opacity: .55; cursor: not-allowed; }
|
|
1251
|
+
.install-bar { padding: 6px 10px; flex-wrap: wrap; }
|
|
1252
|
+
.install-bar[hidden], #install-name-form[hidden] { display: none; }
|
|
1253
|
+
#search-field, #group-name, #group-rename, #instance-name, #install-name { font: .875rem var(--sans); color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: 6px 9px; min-width: 0; min-height: 36px; box-sizing: border-box; }
|
|
1254
|
+
#group-name, #group-rename, #instance-name, #install-name { flex: 1 1 140px; }
|
|
1255
|
+
#search-field::-webkit-search-cancel-button { filter: grayscale(1) brightness(1.6); }
|
|
1256
|
+
.group-head .caret { font-size: 1.1rem; line-height: 1; text-align: center; }
|
|
1257
|
+
#machine-size[data-waiting] select { color: var(--ink2); border-style: dashed; }
|
|
1258
|
+
#machine-size-wait { color: var(--brass); font-weight: 600; }
|