clavarium 0.2.375-next.1 → 0.2.377-next.1

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.375-next.1",
3
+ "version": "0.2.377-next.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.375-next.1",
9
+ "version": "0.2.377-next.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.375-next.1",
3
+ "version": "0.2.377-next.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
package/server/main.mjs CHANGED
@@ -718,6 +718,8 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
718
718
  } });
719
719
  rooms.boxesBound(boxes.all());
720
720
  rooms.refresh();
721
+ /* A Tether call held for an AI at work for a room: said in that room too, closed there once decided (tethers.mjs). */
722
+ tethers.rooms = { held: (name, event) => rooms.held(name, event), closed: (ids, event) => rooms.closed(ids, event) };
721
723
  /* FEATURES (features.mjs): read from what the modules say, never a secret's value. The remote screen and the
722
724
  catalogue are slow to ask: kept a while, and never waited for long. */
723
725
  const screenNow = cached(() => meshcentral.status().catch(() => null), 60_000);
package/server/rooms.mjs CHANGED
@@ -30,6 +30,11 @@ export const HOPS_MAX = 8;
30
30
  /* How long an AI given a message is said to be thinking, at most (it may never answer). */
31
31
  export const THINKING_MS = 10 * 60_000;
32
32
  const KINDS = ['person', 'ai', 'box'];
33
+ /* A ROOM'S OWN LINES (a Tether call held for one of its AIs, and what closes it): the kinds, their level, at most kept. */
34
+ const LINE_LEVELS = Object.freeze({ 'tether-demande': 'needs-you', 'tether-done': 'trace' });
35
+ const LEVELS = ['needs-you', 'news', 'trace'];
36
+ const LINE_ACTION = /^[a-z]{1,20}(:[a-z0-9][a-z0-9._-]{0,63})?$/;
37
+ export const ROOM_LINES_MAX = 50;
33
38
  /* A FILE THE PERSON ATTACHES IN A ROOM (06-11: a room carries what the chat carries): kept in the room's own folder
34
39
  (rooms/<id>/attached/<uuid>/<name>), named in its thread by ROOM_FILE's path; a copy goes to each AI the message
35
40
  goes to, attached in its own chat as the person attaches one there (talk.attach) -- never to the others. */
@@ -56,6 +61,11 @@ const nameOf = (v) => String(v ?? '').replace(/\s+/g, ' ').trim().slice(0, ROOM_
56
61
  a secret never passes from one AI to another through a room. */
57
62
  export const masked = (text) => { const str = String(text ?? ''); const hits = scan(str); if (!hits.length) return str; let out = ''; let last = 0; for (const h of hits) { out += str.slice(last, h.start) + '••••••'; last = h.end; } return out + str.slice(last); };
58
63
 
64
+ /* Every text of a line masked (a card's title, facts, quote...: plain data, words of the screen and their values) --
65
+ never a button's id nor a word's key. */
66
+ const maskedAll = (v, depth = 0) => (typeof v === 'string' ? masked(v) : depth > 8 || v === null || typeof v !== 'object' ? v
67
+ : Array.isArray(v) ? v.map((x) => maskedAll(x, depth + 1)) : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, k === 'id' || k === 'k' ? x : maskedAll(x, depth + 1)])));
68
+
59
69
  /* A sign as the cards draw it (web/kit.js hueOf, initialOf): the same name, the same letter and colour. */
60
70
  export const lookOf = (name) => { const text = String(name ?? ''); let h = 0; for (const ch of text) h = (h * 31 + ch.codePointAt(0)) >>> 0; return { initial: ([...text.trim()][0] ?? '?').toUpperCase(), hue: h % 360 }; };
61
71
 
@@ -171,6 +181,10 @@ export class Rooms {
171
181
  if (options?.seen !== false) { const list = this.#all(); const r = list.find((x) => x.id === id); if (r) { r.seen = this.#now().toISOString(); this.#file.set(list); } }
172
182
  const thinking = this.#thinkingOf(id, members); const doing = this.#doingOf(id, members);
173
183
  const messages = (read.messages ?? []).map((m) => this.#opened(m, members)).map((m) => (typeof m.text === 'string' && m.text.includes('@{') ? { ...m, text: named(m.text) } : m));
184
+ /* Its lines (a Tether call of one of its AIs, held): among its messages, where they happened -- those of the time read. */
185
+ const from = read.window?.more > 0 ? String(messages[0]?.at ?? '') : '';
186
+ const lines = (Array.isArray(room.lines) ? room.lines : []).filter((l) => String(l.at) >= from).map((l) => ({ from: 'system', kind: 'system', choices: [], ...l }));
187
+ if (lines.length) { messages.push(...lines); messages.sort((a, b) => String(a.at).localeCompare(String(b.at))); }
174
188
  /* The files of the thread (shown, cited): each as the message that has it opens it; one nobody opens here, not listed. */
175
189
  const opens = new Map(); for (const m of messages) for (const f of [...(m.refs ?? []), ...(m.files ?? []), ...(m.open ? [m] : [])]) if (f.path && f.open) opens.set(f.path, f.open);
176
190
  const files = Array.isArray(read.files) ? read.files.map((f) => (String(f.open ?? '').startsWith(CITE) ? (opens.has(f.path) ? { ...f, open: opens.get(f.path) } : null) : f)).filter(Boolean) : read.files;
@@ -387,6 +401,41 @@ export class Rooms {
387
401
  return copies;
388
402
  }
389
403
 
404
+ /* A TETHER CALL HELD FOR AN AI AT WORK FOR A ROOM (07-11): an AI given a message of the room and not done with it
405
+ asks the person to allow a call -- its card goes in that room's thread too (and each room it works for), the
406
+ same line its own chat has (talk.event's shape: Accept / Refuse, Parley's request card), named by the AI.
407
+ Kept apart from the thread (the room's `lines`, ROOM_LINES_MAX), read among its messages; masked as any line
408
+ of a room. Gives the rooms it went to: what is said once it is decided goes there (closed). */
409
+ held(conversation, event) {
410
+ const list = this.#all(); const now = Date.now(); const ids = [];
411
+ for (const r of list) {
412
+ const on = this.#thinking.get(r.id);
413
+ const me = on && this.#shown(r).find((m) => m.kind === 'ai' && m.ref === conversation && on.has(m.id) && now - on.get(m.id).at < THINKING_MS);
414
+ if (!me) continue;
415
+ const request = event?.request && typeof event.request === 'object' ? { ...event.request, sub: [me.name, ...(Array.isArray(event.request.sub) ? event.request.sub : event.request.sub ? [event.request.sub] : [])].slice(0, 4) } : undefined;
416
+ const text = event?.text && typeof event.text === 'object' ? event.text : `${me.name}: ${String(event?.text ?? '')}`;
417
+ if (this.#line(r, { ...event, text, ...(request ? { request } : {}) })) ids.push(r.id);
418
+ }
419
+ if (ids.length) this.#file.set(list);
420
+ return ids;
421
+ }
422
+ /* What closes it (a 'tether-done' carrying its id), in the rooms its card went to. */
423
+ closed(ids, event) {
424
+ const list = this.#all(); let any = false;
425
+ for (const r of list) if ((Array.isArray(ids) ? ids : []).includes(r.id) && this.#line(r, event)) any = true;
426
+ if (any) this.#file.set(list);
427
+ }
428
+ #line(room, { kind, text, level, tone, topic, brief, actions, request, outcome } = {}) {
429
+ if (!Object.hasOwn(LINE_LEVELS, kind)) return null;
430
+ const kept = { id: randomUUID(), at: this.#now().toISOString(), event: kind, text: maskedAll(text), level: LEVELS.includes(level) ? level : LINE_LEVELS[kind],
431
+ ...(tone === 'warn' || tone === 'bad' ? { tone } : {}), ...(topic ? { topic: maskedAll(topic) } : {}), ...(typeof brief === 'string' && brief ? { brief: brief.slice(0, 60) } : {}),
432
+ actions: (Array.isArray(actions) ? actions : []).filter((a) => typeof a === 'string' && LINE_ACTION.test(a)).slice(0, 4),
433
+ ...(request && typeof request === 'object' && JSON.stringify(request).length <= 8000 ? { request: maskedAll(request) } : {}),
434
+ ...(typeof outcome === 'string' && outcome ? { outcome: outcome.slice(0, 20) } : {}) };
435
+ room.lines = [...(Array.isArray(room.lines) ? room.lines : []), kept].slice(-ROOM_LINES_MAX);
436
+ return kept;
437
+ }
438
+
390
439
  /* Each conversation told the rooms it is in, and who is there. */
391
440
  #tellConversations(list) {
392
441
  const shown = list.map((r) => ({ ...r, members: this.#shown(r) }));
@@ -40,15 +40,15 @@ export const SETTINGS_WORDS_FR = {
40
40
  'set-backup-now': 'Exporter · importer', 'set-git-key': '1 clé git', 'set-git-keys': '{n} clés git', 'set-restart': 'redémarrer',
41
41
  'set-logs-now': 'Clavarium, machines, système',
42
42
  'set-dict-backup': 'Secours en service', 'set-dict-expired': 'Clé expirée', 'set-dict-with-backup': 'avec secours', 'set-dict-server': 'réglée sur le serveur',
43
- 'set-attached': '{n} joints', 'set-secret': '1 secret', 'set-secrets': '{n} secrets', 'set-offline': 'hors ligne',
43
+ 'set-offline': 'hors ligne',
44
44
  'set-device': '1 appareil', 'set-devices': '{n} appareils', 'set-no-device': 'Aucun appareil', 'set-chat': 'Chat',
45
45
  'set-wakeups-on': '{on} sur {n} actifs', 'set-asleep': 'En veille', 'set-not-looked': 'Pas encore regardée',
46
46
  'set-running-well': 'En marche · rien d\'anormal', 'set-conv-advanced': 'Archiver · remettre à neuf · supprimer',
47
47
  /* New asset, one box (05-11, step 3): one verb per gesture -- Créer, Attacher, Terminé. */
48
- 'set-na-new': 'Nouvel asset', 'set-na-attach': 'Attacher', 'set-na-there': 'Déjà là', 'set-na-or-new': 'ou un nouveau',
48
+ 'set-na-new': 'Nouvel asset', 'set-na-there': 'Déjà là', 'set-na-or-new': 'ou un nouveau',
49
49
  'set-na-step-kind': 'Type', 'set-na-step-set': 'Réglage', 'set-na-step-made': 'Créé', 'set-na-back': 'Retour',
50
- 'set-na-will': 'Sera relié à {name}', 'set-na-level': 'Ce qu\'il pourra faire', 'set-na-read': 'Lecture', 'set-na-write': 'Écriture',
51
- 'set-na-made': '{name} créé', 'set-na-made-to': '{name} créé et relié à {to}', 'set-na-another': 'En créer un autre', 'set-na-done': 'Terminé',
50
+ 'set-na-will': 'Sera donné à {name}', 'set-na-level': 'Ce qu\'il pourra faire',
51
+ 'set-na-made': '{name} créé', 'set-na-made-to': '{name} créé et donné à {to}', 'set-na-another': 'En créer un autre', 'set-na-done': 'Terminé',
52
52
  'set-na-create': 'Créer', 'set-na-new-key': '+ Nouvelle clé…',
53
53
  /* The top bar's way back (06-11, kit.js TopBar). */
54
54
  'set-back': 'Retour', 'set-back-convs': 'Conversations',
@@ -160,9 +160,12 @@ export const SETTINGS_WORDS_FR = {
160
160
  'set-rt-mcp-hint-propose': 'Tous les outils, chaque modification approuvée', 'set-rt-mcp-hint-free': 'Tous les outils ; suppressions demandées',
161
161
  'set-rt-mcp-hint-frame': 'Votre choix pour chaque sorte d\'outil',
162
162
  'set-cx-ssh': 'machine', 'set-cx-computer': 'ordinateur', 'set-cx-browser': 'navigateur', 'set-cx-screen': 'écran', 'set-cx-device': 'appareil',
163
- 'set-acc-share': 'Partager avec un appareil…', 'set-acc-in-assets': 'Ouvrir dans Assets', 'set-acc-detach': 'Retirer',
163
+ 'set-acc-share': 'Partager avec un appareil…', 'set-acc-in-assets': 'Ouvrir dans Assets',
164
164
  'set-acc-used-1': 'Utilisé par 1 conversation : mettez « Aucun »', 'set-acc-used-n': 'Utilisé par {n} conversations : mettez « Aucun »',
165
- 'set-acc-folders': 'Dossiers, dépôts, boîtes mail', 'set-acc-secrets': 'Secrets remis à son outil',
165
+ /* Give… (07-11, assets step 4): in a conversation's tab; Settings › Access one line; a secret's next start. */
166
+ 'set-give-go': 'Donner', 'set-give-all': 'Elle a déjà chaque asset.', 'set-group-none-all': 'Tout à Aucun',
167
+ 'set-acc-assets-1': '1 asset · voir', 'set-acc-assets-n': '{n} assets · voir', 'set-acc-asset-1': '1 asset', 'set-acc-asset-n': '{n} assets',
168
+ 'set-secret-next-on': '{name} : remis à son outil au prochain démarrage', 'set-secret-next-off': '{name} : plus remis dès son prochain démarrage',
166
169
  'set-files-list': 'Liste', 'set-files-icons': 'Icônes', 'set-files-empty': 'Ce dossier est vide.',
167
170
  'set-files-read-only': 'Lecture seule : rien ne peut y être changé.',
168
171
  'set-this-conv': 'Cette conversation · {name}', 'set-ask-first': 'Demander avant d\'agir',
@@ -103,6 +103,9 @@ export class Tethers {
103
103
  /* Set by the server once the thread is ready (main.mjs): (name, event) => talk.event(...).
104
104
  A call waiting for the owner is said in its conversation's thread when there is one. */
105
105
  announce = null;
106
+ /* Set by the server too (main.mjs): the rooms (rooms.mjs) -- a call held for an AI at work for a room is said in
107
+ that room as well (held: the rooms it went to), and closed there (closed) when it is decided. */
108
+ rooms = null;
106
109
 
107
110
  /* `resources`: Resources (workFolder). `conversations()`: the names of the conversations.
108
111
  `secrets`: the vault, read by name for a connector only (reveal). `dockets`: { on(name), post(name,
@@ -152,7 +155,10 @@ export class Tethers {
152
155
  /* `request`: the card Parley draws (demandes.mjs); the text and its buttons stay for an older screen. */
153
156
  let request;
154
157
  try { request = demandeView(r); } catch { request = undefined; }
155
- try { this.announce(name, { kind: 'tether-demande', text: text || (r.verb ?? 'A Tether call'), topic: TOPIC_REQUEST, tone: 'warn', actions: [`allow:${r.id}`, `deny:${r.id}`], ...(request ? { request } : {}) }); } catch { /* its chat is off */ }
158
+ const event = { kind: 'tether-demande', text: text || (r.verb ?? 'A Tether call'), topic: TOPIC_REQUEST, tone: 'warn', actions: [`allow:${r.id}`, `deny:${r.id}`], ...(request ? { request } : {}) };
159
+ try { this.announce(name, event); } catch { /* its chat is off */ }
160
+ /* At work for a room (a message of it not yet answered): the same card there, decided there as here. */
161
+ try { const ids = this.rooms?.held(name, event); if (Array.isArray(ids) && ids.length) this.#seen.get(r.id).rooms = ids; } catch { /* its rooms' */ }
156
162
  }
157
163
  /* An approved call only the owner's browser can do (a device to flash): said once, with where to do it. */
158
164
  let jobs = [];
@@ -178,7 +184,9 @@ export class Tethers {
178
184
  const accepted = outcome === 'yes' || outcome === 'always';
179
185
  if (accepted) { this.#acting.push({ id, ...info }); if (this.#acting.length > ACTING_MAX) this.#acting.shift(); }
180
186
  const text = endedLine(info.verb, outcome);
181
- try { this.announce(info.name, { kind: 'tether-done', text, topic: TOPIC_REQUEST, brief: id, ...(outcome ? { outcome } : {}) }); } catch { /* its chat is off */ }
187
+ const done = { kind: 'tether-done', text, topic: TOPIC_REQUEST, brief: id, ...(outcome ? { outcome } : {}) };
188
+ try { this.announce(info.name, done); } catch { /* its chat is off */ }
189
+ if (info.rooms) try { this.rooms?.closed(info.rooms, done); } catch { /* its rooms' */ }
182
190
  }
183
191
  for (const id of [...this.#ended.keys()]) if (!this.#seen.has(id)) this.#ended.delete(id);
184
192
  }
package/web/app.js CHANGED
@@ -2137,7 +2137,6 @@
2137
2137
  void offerVoice();
2138
2138
 
2139
2139
  /* ---------- folders ---------- */
2140
- const LEVELS = [['none', 'Off'], ['read', 'Read'], ['write', 'Write']];
2141
2140
  /* THE BAR (G): Chat | Terminal | Assets N, then its settings. What was
2142
2141
  given to it is counted on "Assets", and shown there. */
2143
2142
  let assetsShown = false; let roomPick = '';
@@ -2196,7 +2195,7 @@
2196
2195
  const tabNow = () => (assetsShown ? 'assets' : listsShown ? 'lists' : panelShown ? 'settings' : simple ? 'chat' : 'terminal');
2197
2196
  const TAB_WORD = { chat: 'Chat', terminal: 'Terminal', assets: 'Assets', get lists() { return LISTS_WORD.many; }, settings: 'Settings' };
2198
2197
  function openPanelZone() { closeRoomLists(); if (panelShown) return; panelShown = true; $('panel').hidden = false; paintViews(); }
2199
- function closePanelZone() { if (!panelShown) return; panelShown = false; $('panel').hidden = true; $('attach-picker').hidden = true; paintViews(); }
2198
+ function closePanelZone() { if (!panelShown) return; panelShown = false; $('panel').hidden = true; paintViews(); }
2200
2199
  function showTab(which, pick) {
2201
2200
  if (which === 'assets') { closePanelZone(); void openRoomAssets(pick); return; }
2202
2201
  if (which === 'settings') { closeRoomAssets(); openPanelZone(); return; }
@@ -2391,65 +2390,11 @@
2391
2390
  async function paintPanel() {
2392
2391
  const here = conversations.find((x) => x.name === current);
2393
2392
  $('panel-title').textContent = uiSaid('set-this-conv', 'This conversation - {name}', { name: current });
2394
- const all = await (await fetch(`${API}/resources`)).json();
2395
- const mine = new Map((here?.resources ?? []).map((r) => [r.name, r.access]));
2396
- const rows = $('panel-rows');
2397
- rows.replaceChildren();
2398
- for (const resource of all.filter((r) => mine.has(r.name))) {
2399
- const kind = kindOf(resource);
2400
- const row = document.createElement('div'); row.className = 'folder'; row.dataset.name = resource.name;
2401
- const label = document.createElement('b'); label.textContent = resource.name;
2402
- const about = document.createElement('small'); about.className = 'hint'; about.textContent = ` ${uiSaid(`set-as-kind-${kind}`, KIND_WORD[kind] ?? kind)}`; label.append(about);
2403
- const levels = kind === 'mailbox' ? fixedRight('mailbox')
2404
- : modes([['read', uiSaid('set-rt-pick-read', 'Read')], ['write', uiSaid('set-rt-pick-write', 'Write')]], mine.get(resource.name), async (access) => { await attachTo(current, resource.name, access); await paintPanel(); }, `Access of this conversation to ${resource.name}`);
2405
- row.append(label, levels);
2406
- if (kind === 'git') {
2407
- const review = document.createElement('button'); review.type = 'button'; review.textContent = 'Review & send';
2408
- review.addEventListener('click', () => { void openGit(resource.name); });
2409
- row.append(review);
2410
- }
2411
- /* Detached, not removed: it stays in Assets. */
2412
- const detach = document.createElement('button'); detach.type = 'button'; detach.textContent = uiSaid('set-acc-detach', 'Detach'); detach.setAttribute('aria-label', `Detach ${resource.name} from this conversation`);
2413
- detach.addEventListener('click', async () => { detach.disabled = true; detach.textContent = 'Detaching...'; await attachTo(current, resource.name, 'none'); await paintPanel(); });
2414
- row.append(detach);
2415
- /* Right under the access it depends on: read-only, the agent has nothing of its own to send. */
2416
- if (kind === 'git' && mine.get(resource.name) === 'write' && !DEVICE) row.append(autoSending(current, resource.name));
2417
- rows.append(row);
2418
- }
2419
- $('panel-empty').hidden = mine.size > 0;
2420
- /* The secrets its tool is handed. */
2421
- const secrets = DEVICE ? [] : await (await fetch(`${API}/secrets`)).json().catch(() => []);
2422
- const handed = $('access-secrets'); handed.replaceChildren();
2423
- for (const secret of secrets.filter((x) => x.conversations.includes(current))) {
2424
- const row = document.createElement('div'); row.className = 'folder'; row.dataset.name = secret.name;
2425
- const label = document.createElement('b'); label.textContent = secret.name;
2426
- const off = document.createElement('button'); off.type = 'button'; off.textContent = uiSaid('set-acc-detach', 'Detach'); off.setAttribute('aria-label', `Stop handing ${secret.name} to this conversation`);
2427
- off.addEventListener('click', async () => {
2428
- await handTo(current, secret.name, false); await paintPanel();
2429
- $('secret-note').textContent = `${secret.name} will no longer be handed to the tool the next time it starts (leave the tool, then "Start it again").`;
2430
- });
2431
- row.append(label, fixedUse(), off); handed.append(row);
2432
- }
2433
- $('access-secrets-empty').hidden = handed.children.length > 0;
2393
+ /* ITS ASSETS (07-11, assets step 4): one line, « N assets · see », that opens its Assets tab -- what it was given
2394
+ lives there only (given, its right set, taken back: « None »). Its sign-in mailbox (not an asset) stays below. */
2395
+ const n = givenCount(here);
2396
+ $('access-assets').textContent = uiSaid(n === 1 ? 'set-acc-assets-1' : 'set-acc-assets-n', n === 1 ? '1 asset · see' : '{n} assets · see', { n });
2434
2397
  paintConvStates(here);
2435
- /* What can still be attached, for the picker. */
2436
- const picker = $('attach-rows'); picker.replaceChildren();
2437
- const offer = (name, kind, buttons) => {
2438
- const row = document.createElement('div'); row.className = 'folder'; row.dataset.name = name;
2439
- const label = document.createElement('b'); label.textContent = name;
2440
- const about = document.createElement('small'); about.className = 'hint'; about.textContent = ` ${kind}`; label.append(about);
2441
- row.append(label, ...buttons); picker.append(row);
2442
- };
2443
- const act = (text, run) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = text; b.addEventListener('click', async () => { b.disabled = true; b.textContent = '...'; await run(); await paintPanel(); }); return b; };
2444
- for (const resource of all.filter((r) => !mine.has(r.name))) {
2445
- const kind = kindOf(resource);
2446
- offer(resource.name, KIND_WORD[kind], kind === 'mailbox' ? [act('Read', () => attachTo(current, resource.name, 'read'))]
2447
- : [act('Read', () => attachTo(current, resource.name, 'read')), act('Write', () => attachTo(current, resource.name, 'write'))]);
2448
- }
2449
- for (const secret of secrets.filter((x) => !x.conversations.includes(current))) {
2450
- offer(secret.name, 'secret', [act('Use', async () => { await handTo(current, secret.name, true); $('secret-note').textContent = `${secret.name} will be handed to the tool the next time it starts (leave the tool, then "Start it again").`; })]);
2451
- }
2452
- $('attach-empty').hidden = picker.children.length > 0;
2453
2398
  /* Where the tool works, and the way to the other place. */
2454
2399
  const kept = here?.work === 'kept';
2455
2400
  $('work-box').hidden = DEVICE || !here;
@@ -2860,8 +2805,8 @@
2860
2805
  const join = (...parts) => parts.filter(Boolean).join(' · ');
2861
2806
  chapterNow('general', join(here.title || here.name, here.group, here.toolLabel ?? here.tool));
2862
2807
  chapterNow('machine', join(sizeWords(here.memoryMb), uiSaid(here.cpus === 1 ? 'set-core' : 'set-cores', here.cpus === 1 ? '1 core' : '{n} cores', { n: here.cpus ?? 1 }), here.diskGb ? `${here.diskGb} GB` : ''));
2863
- const attached = (here.resources ?? []).length; const secrets = $('access-secrets').children.length;
2864
- chapterNow('access', join(uiSaid('set-attached', '{n} attached', { n: attached }), secrets ? uiSaid(secrets === 1 ? 'set-secret' : 'set-secrets', secrets === 1 ? '1 secret' : '{n} secrets', { n: secrets }) : '', here.offline ? uiSaid('set-offline', 'offline') : ''));
2808
+ const given = givenCount(here);
2809
+ chapterNow('access', join(uiSaid(given === 1 ? 'set-acc-asset-1' : 'set-acc-asset-n', given === 1 ? '1 asset' : '{n} assets', { n: given }), here.offline ? uiSaid('set-offline', 'offline') : ''));
2865
2810
  const shared = $('share-here').children.length;
2866
2811
  chapterNow('sharing', shared ? uiSaid(shared === 1 ? 'set-device' : 'set-devices', shared === 1 ? '1 device' : '{n} devices', { n: shared }) : uiSaid('set-no-device', 'No device'));
2867
2812
  const chatOn = levelsWord('talk-on');
@@ -3172,7 +3117,7 @@
3172
3117
  /* A message attached to a card (#180), from its menu: its id, as the card opens it again. */
3173
3118
  ...(!DEVICE ? { card: (m) => attachToCard({ kind: 'message', ref: m.id, title: String(m.short || m.text || '').replace(/\s+/g, ' ').trim().slice(0, 80) }) } : {}),
3174
3119
  /* The buttons of a line of the machine: what this screen may do. */
3175
- act: (id) => { if (id === 'seen-done') return; if (id.startsWith('salon:')) { void openSalon(id.slice('salon:'.length)); return; } if (id === 'seen') void markSeen(); else if (id === 'memory') void openMemory(); else if (id === 'disk') void openDisk(); else if (id === 'room') seeTerminal(); else if (id === 'restart') void restartMachine(false); else if (id === 'terminal') seeTerminal(); else if (/^(allow|deny|always):/.test(id)) void decideTether(id.slice(id.indexOf(':') + 1), DECISION[id.slice(0, id.indexOf(':'))], () => thread?.refresh?.()); else if (id.startsWith('flash:')) void flashFromThread(id.slice('flash:'.length)); else if (id.startsWith('review:')) void openGit(id.slice('review:'.length)); else openCommits(id); },
3120
+ act: (id) => { if (id === 'seen-done') return; if (id.startsWith('salon:')) { void openSalon(id.slice('salon:'.length)); return; } if (id === 'seen') void markSeen(); else if (id === 'memory') void openMemory(); else if (id === 'disk') void openDisk(); else if (id === 'room') seeTerminal(); else if (id === 'restart') void restartMachine(false); else if (id === 'terminal') seeTerminal(); else if (tetherAct(id, () => thread?.refresh?.())) return; else if (id.startsWith('flash:')) void flashFromThread(id.slice('flash:'.length)); else if (id.startsWith('review:')) void openGit(id.slice('review:'.length)); else openCommits(id); },
3176
3121
  /* What it opens over the thread (a picture, its media, a search...) is a step of this page's: one Back closes it. */
3177
3122
  back: { open: (name, close) => nav.over(`chat-${name}`, () => close(), { stack: true }), closed: (step) => { if (step) nav.drop(step); } },
3178
3123
  /* Files attached to a message: wherever the chat may be written in. */
@@ -3311,6 +3256,8 @@
3311
3256
  /* A Tether call an agent asked the owner to allow, offered right in the thread: allow:<id> / deny:<id>. */
3312
3257
  const DEMANDE = /^(allow|deny|always|flash):[0-9a-f-]{36}$/;
3313
3258
  const DECISION = { allow: 'yes', deny: 'no', always: 'always' };
3259
+ /* Accept / Refuse a Tether call from a line that asks (a conversation's chat, a room): decided as the owner, then `after`. */
3260
+ const tetherAct = (id, after) => { const m = /^(allow|deny|always):(.+)$/.exec(String(id)); if (!m) return false; void decideTether(m[2], DECISION[m[1]], after); return true; };
3314
3261
  const COMMIT = /^commit:([a-z0-9][a-z0-9-]*)\.([0-9a-f]{7,40})$/;
3315
3262
  function openCommits(id) {
3316
3263
  if (DEVICE) return;
@@ -3822,7 +3769,6 @@
3822
3769
  try { await post(`${API}/conversations/${encodeURIComponent(current)}/answerer/resume`, {}); await refresh(); await paintAnswerer(); }
3823
3770
  catch (error) { $('panel-error').textContent = error.message; $('panel-error').hidden = false; }
3824
3771
  });
3825
- const fixedUse = () => { const s = document.createElement('span'); s.className = 'hint'; s.textContent = uiSaid('set-rt-pick-use', 'Use'); return s; };
3826
3772
  for (const b of $('network').children) {
3827
3773
  b.addEventListener('click', async () => {
3828
3774
  $('panel-error').hidden = true;
@@ -3832,64 +3778,21 @@
3832
3778
  await paintPanel();
3833
3779
  });
3834
3780
  }
3835
- /* `picking`: open on what can be attached (the "+ Attach" of the bar). */
3836
- async function openAccess(picking = false) {
3781
+ /* Its settings, on the Access chapter. `giving`: « Give… » instead, in its Assets tab (07-11, assets step 4). */
3782
+ async function openAccess(giving = false) {
3837
3783
  if (DEVICE || !current) return;
3838
3784
  if (place !== 'conversations') leavePlace();
3785
+ if (giving) { openGive(); return; }
3839
3786
  goTab('settings');
3840
- /* What is attached may have changed from another screen: asked again before it is shown. */
3787
+ /* What it has may have changed from another screen: asked again before it is shown. */
3841
3788
  await refresh();
3842
3789
  await paintPanel();
3843
- $('attach-picker').hidden = !picking;
3844
- if (picking) openAttach();
3845
3790
  /* The folders button shows the folders: their chapter opened, in its tab. */
3846
- openChapter('access', { show: !picking });
3847
- if (picking) $('attach-picker').scrollIntoView({ block: 'nearest' });
3791
+ openChapter('access');
3848
3792
  }
3849
3793
  $('folders').addEventListener('click', () => { void openAccess(false); });
3850
- /* ATTACH: ONE BUTTON (05-11). What is there already, to tick, or a new one -- the New asset box, with this
3851
- conversation as its context: what is made there is attached to it at the end. */
3852
- let attachNew = { kind: '', level: 'write' };
3853
- function openAttach() {
3854
- attachNew = { kind: '', level: 'write' };
3855
- if (justMade?.to) justMade = null;
3856
- $('attach-error').hidden = true;
3857
- paintAttach();
3858
- }
3859
- function closeAttach() {
3860
- $('attach-picker').hidden = true; $('attach-close').hidden = false;
3861
- attachNew.kind = ''; if (justMade?.to) justMade = null;
3862
- for (const f of $('attach-box').querySelectorAll('.as-form')) $('assets-forms').append(f);
3863
- }
3864
- function paintAttach() {
3865
- const box = $('attach-box');
3866
- for (const f of box.querySelectorAll('.as-form')) $('assets-forms').append(f);
3867
- $('attach-park').append($('attach-there'));
3868
- const who = shownName(conversations.find((c) => c.name === current)) || current;
3869
- const made = justMade?.to === current ? justMade : null;
3870
- const kind = made ? '' : attachNew.kind;
3871
- /* At its ending, one Done: the box's own. */
3872
- $('attach-close').hidden = Boolean(made) || (kind === 'box' && Boolean(boxFlow?.made));
3873
- /* A secret is used or not, a voice box speaks there: no level to choose. */
3874
- const level = kind && !['secret', 'box'].includes(kind) ? { options: [{ value: 'read', label: uiSaid('set-na-read', 'Read') }, { value: 'write', label: uiSaid('set-na-write', 'Write') }], value: attachNew.level, label: uiSaid('set-na-level', 'What it may do'), onChange: (v) => { attachNew.level = v; } } : null;
3875
- box.replaceChildren(newAssetBox({
3876
- kind, existing: $('attach-there'), title: '',
3877
- context: made ? null : { line: uiSaid('set-na-will', 'Will be attached to {name}', { name: who }), level },
3878
- made: made ? { said: uiSaid('set-na-made-to', '{name} created and attached to {to}', { name: made.name, to: who }), note: made.note } : null,
3879
- onKind: (k) => {
3880
- attachNew.kind = k; justMade = null; $('attach-error').hidden = true; paintAttach(); focusFirst(box);
3881
- /* Its keys, read here too: Assets may not have been opened yet. */
3882
- if (k === 'git') void getJson('/git-keys', []).then((keys) => paintKeys(keys));
3883
- },
3884
- onBack: () => { attachNew.kind = ''; $('attach-error').hidden = true; paintAttach(); },
3885
- onAnother: () => { attachNew.kind = made.kind; justMade = null; paintAttach(); focusFirst(box); },
3886
- onDone: () => { closeAttach(); },
3887
- boxHost: { where: current, repaint: () => paintAttach(), close: () => closeAttach() },
3888
- }));
3889
- }
3890
- $('attach-open').addEventListener('click', () => { openChapter('access'); $('attach-picker').hidden = false; openAttach(); $('attach-picker').scrollIntoView({ block: 'nearest' }); });
3891
- $('attach-close').addEventListener('click', () => { closeAttach(); });
3892
- $('attach-manage').addEventListener('click', () => { void openAssets(); });
3794
+ /* « N assets · see »: its Assets tab. */
3795
+ $('access-assets').addEventListener('click', () => { goTab('assets'); });
3893
3796
 
3894
3797
  /* ---------- ASSETS: one design, two pages (G and H) ----------
3895
3798
  The server's (a place, beside Access and Settings): everything kept
@@ -4043,11 +3946,6 @@
4043
3946
  /* Once a finger's drag has begun, the page must not scroll under it. */
4044
3947
  holder.addEventListener('touchmove', (e) => { if (drag?.on) e.preventDefault(); }, { passive: false });
4045
3948
  }
4046
- /* The level buttons an asset takes, by its type (same sets as its own detail). */
4047
- const levelOptionsFor = (item) => (item.target ? [['none', 'Off'], ['watch', 'Watch'], ['propose', 'Propose'], ['frame', 'Frame'], ['free', 'Free']]
4048
- : item.kind === 'secret' ? [['off', 'Off'], ['use', 'Use']]
4049
- : item.kind === 'mailbox' ? [['none', 'Off'], ['read', 'Read']]
4050
- : [['none', 'Off'], ['read', 'Read'], ['write', 'Write']]);
4051
3949
  /* The shortcut "give all": each member at the lightest real level for its type. */
4052
3950
  const giveValue = (item, on) => (!on ? (item.target || item.kind !== 'secret' ? 'none' : 'off')
4053
3951
  : item.target ? 'watch' : item.kind === 'secret' ? 'use' : 'read');
@@ -4132,14 +4030,17 @@
4132
4030
  const block = el('div', 'as-give-conv');
4133
4031
  const head = el('div', 'as-give-head');
4134
4032
  const giveAll = el('button', 'as-mini', 'Give all'); giveAll.type = 'button'; giveAll.addEventListener('click', () => setAll(c, true));
4135
- const clearAll = el('button', 'as-mini', 'Remove all'); clearAll.type = 'button'; clearAll.addEventListener('click', () => setAll(c, false));
4033
+ const clearAll = el('button', 'as-mini', uiSaid('set-group-none-all', 'All to None')); clearAll.type = 'button'; clearAll.addEventListener('click', () => setAll(c, false));
4136
4034
  head.append(el('b', '', shownName(c)), el('span', 'as-give-acts', ''));
4137
4035
  head.lastChild.append(giveAll, clearAll);
4138
4036
  block.append(head);
4139
4037
  for (const m of members) {
4140
4038
  const line = el('div', 'as-give-row');
4141
4039
  const nm = el('span', 'as-give-name'); nm.append(kindIcon(m.kind), el('span', '', m.name));
4142
- line.append(nm, modes(levelOptionsFor(m), memberCurrent(m, c.name), async (v) => { await applyMemberGrant(m, c.name, v); repaint(); }, `${m.name} for ${shownName(c)}`));
4040
+ /* The one picker (kit RightsPicker, « None » first): the same levels as the asset's own panel. */
4041
+ const pick = KIT.RightsPicker({ key: KIT.rightsKey(m), kind: m.kind, value: memberCurrent(m, c.name), label: `${m.name} for ${shownName(c)}`,
4042
+ onChange: async (level, stored) => { await applyMemberGrant(m, c.name, stored ?? level); repaint(); } });
4043
+ if (pick) line.append(nm, pick); else line.append(nm);
4143
4044
  block.append(line);
4144
4045
  }
4145
4046
  if (members.length === 0) block.append(el('p', 'hint', 'The group is empty.'));
@@ -4708,7 +4609,80 @@
4708
4609
  /* A conversation's tab, searched: its words, and whose they are. */
4709
4610
  let cassetsQuery = ''; let cassetsFor = '';
4710
4611
  $('cassets-search').addEventListener('input', () => { cassetsQuery = $('cassets-search').value.trim(); void paintRoomAssets(); });
4711
- $('cassets-attach').addEventListener('click', () => { void openAccess(true); });
4612
+ $('cassets-attach').addEventListener('click', () => { openGive(); });
4613
+ /* GIVE… (07-11, assets step 4): in its tab, in place of an asset opened -- the one list of what it has not yet
4614
+ (kit AssetPicker), its right, then « Give »; a new asset below (the New asset box, given at its end). Its state:
4615
+ the search typed, the one chosen, the kind of a new one being made. */
4616
+ const GIVE = 'give:';
4617
+ let giveQuery = ''; let giveChosen = ''; let giveNew = { kind: '', level: '' };
4618
+ function openGive() {
4619
+ if (DEVICE || !current) return;
4620
+ giveQuery = ''; giveChosen = ''; giveNew = { kind: '', level: '' };
4621
+ if (justMade?.to) justMade = null;
4622
+ $('cassets-error').hidden = true;
4623
+ if (tabNow() !== 'assets') goTab('assets');
4624
+ pickRoomAsset(GIVE);
4625
+ }
4626
+ const closeGive = () => { giveNew = { kind: '', level: '' }; if (justMade?.to) justMade = null; if (roomPick === GIVE && !nav.close('asset')) { roomPick = ''; void paintRoomAssets(); } };
4627
+ /* A secret given or taken back: its tool has it, or not, at its next start -- said under the tab a moment. */
4628
+ let noteTimer = 0;
4629
+ function secretNote(name, on) {
4630
+ const p = $('cassets-note');
4631
+ p.textContent = uiSaid(on ? 'set-secret-next-on' : 'set-secret-next-off', on ? '{name}: handed to its tool at its next start' : '{name}: no longer handed from its next start', { name });
4632
+ p.hidden = false;
4633
+ clearTimeout(noteTimer); noteTimer = setTimeout(() => { p.hidden = true; }, 12_000);
4634
+ }
4635
+ /* Every asset as a card, for Give…: what the Assets menu lists, without who has it. */
4636
+ function everyCard({ all = [], boxes = [], secrets = [], tether = {}, lists = {} } = {}) {
4637
+ const out = [];
4638
+ for (const r of Array.isArray(all) ? all : []) {
4639
+ const kind = kindOf(r); const box = kind === 'mailbox' ? (Array.isArray(boxes) ? boxes : []).find((m) => m.name === r.name) : undefined;
4640
+ out.push({ key: `${kind}:${r.name}`, kind, name: r.name, resource: r, box, sub: kind === 'git' ? gitSub(r) : kind === 'mailbox' ? boxSub(box) : '', tone: box?.failure ? 'bad' : '' });
4641
+ }
4642
+ for (const s2 of Array.isArray(secrets) ? secrets : []) out.push({ key: `secret:${s2.name}`, kind: 'secret', name: s2.name, secret: s2, ...secretItem(s2, uiSaid('set-as-name-only', 'name only')) });
4643
+ for (const tgt of Array.isArray(tether?.targets) ? tether.targets : []) out.push({ key: `connexion:${tgt.id}`, kind: tetherCat(tgt.kind), name: tgt.title, target: tgt, live: true, sub: connexionOwnSub(tgt.kind) });
4644
+ for (const l of lists?.ok && Array.isArray(lists.data) ? lists.data.filter((x) => !x.archived) : []) out.push({ key: `list:${l.id}`, kind: 'list', name: l.title, list: l, sub: cardsSub(l.count) });
4645
+ return out;
4646
+ }
4647
+ /* Given from the picker: the same grant as « None » takes back (applyMemberGrant), then the list again. */
4648
+ async function giveOne(item, level, stored) {
4649
+ $('cassets-error').hidden = true;
4650
+ await applyMemberGrant(item, current, stored ?? level);
4651
+ if (!$('cassets-error').hidden) return false;
4652
+ if (item.kind === 'secret') secretNote(item.name, true);
4653
+ giveChosen = '';
4654
+ await paintRoomAssets();
4655
+ return true;
4656
+ }
4657
+ /* The levels a new asset of that kind is given at (a live link made: Read watches, Write proposes -- targetLevel). */
4658
+ const newLevels = (kind) => (['secret', 'box', 'device'].includes(kind) ? [] : kind === 'mailbox' ? ['read'] : ['read', 'write']);
4659
+ function paintGive(detail, data, items) {
4660
+ const c = conversations.find((x) => x.name === current);
4661
+ const who = shownName(c) || current;
4662
+ const made = justMade?.to === current ? justMade : null;
4663
+ const kind = made ? '' : giveNew.kind;
4664
+ const levels = newLevels(kind);
4665
+ if (kind && !levels.includes(giveNew.level)) giveNew.level = levels[0] ?? '';
4666
+ const level = levels.length ? { options: levels.map((l) => ({ value: l, label: KIT.rightWord(l, 'pick') })), value: giveNew.level, label: uiSaid('set-na-level', 'What it may do'), onChange: (v) => { giveNew.level = v; } } : null;
4667
+ const box = newAssetBox({
4668
+ kind, title: '',
4669
+ context: kind && !made ? { line: uiSaid('set-na-will', 'Will be given to {name}', { name: who }), level } : null,
4670
+ made: made ? { said: uiSaid('set-na-made-to', '{name} created and given to {to}', { name: made.name, to: who }), note: made.note } : null,
4671
+ onKind: (k) => {
4672
+ giveNew.kind = k; justMade = null; $('cassets-error').hidden = true; void paintRoomAssets();
4673
+ /* Its keys, read here too: Assets may not have been opened yet. */
4674
+ if (k === 'git') void getJson('/git-keys', []).then((keys) => paintKeys(keys));
4675
+ },
4676
+ onBack: () => { giveNew.kind = ''; $('cassets-error').hidden = true; void paintRoomAssets(); },
4677
+ onAnother: () => { giveNew.kind = made.kind; justMade = null; void paintRoomAssets(); },
4678
+ onDone: () => { closeGive(); },
4679
+ boxHost: { where: current, repaint: () => { void paintRoomAssets(); }, close: () => closeGive() },
4680
+ });
4681
+ const body = kind || made ? box : KIT.AssetPicker({ items: everyCard(data).map((i) => ({ ...cardOf(i), item: i })), given: items.map((i) => i.key), kinds: assetKinds(), query: giveQuery, chosen: giveChosen,
4682
+ newAsset: box, words: { who }, onPick: (key) => { giveChosen = key; }, onQuery: (q) => { giveQuery = q; }, onGive: (card, l, stored) => giveOne(card.item, l, stored) });
4683
+ detail.append(KIT.AssetPanel({ key: GIVE, kind: 'give', name: uiSaid('set-as-give', 'Give…'), sub: who }, { back: { onBack: () => { if (!nav.close('asset')) { roomPick = ''; void paintRoomAssets(); } } }, parts: [body] }));
4684
+ if (kind) focusFirst(detail);
4685
+ }
4712
4686
  /* WHAT A CONVERSATION WAS GIVEN, the rows of its Assets tab (each with its right): its work folder, its folders,
4713
4687
  repositories and mailboxes, its secrets, what it was given live (Tether) and its binders. The tab draws them,
4714
4688
  its bar counts them, a room's settings show them for each of its AIs: the same rows everywhere. */
@@ -4755,13 +4729,17 @@
4755
4729
  const secrets = secretsKnown.length ? secretsKnown : (c.secrets ?? []).map((n) => ({ name: n, conversations: [c.name] }));
4756
4730
  return givenItems(c.name, { all, secrets, tether: givenKnown.tether, lists: givenKnown.lists }).length;
4757
4731
  }
4732
+ /* Painted again while a paint reads (a right changed, then the list refreshed): only the last one draws. */
4733
+ let roomPainting = 0;
4758
4734
  async function paintRoomAssets() {
4759
4735
  if (!assetsShown) return;
4736
+ const mine = ++roomPainting; const stale = () => mine !== roomPainting || !assetsShown;
4760
4737
  watchTether();
4761
4738
  const c = conversations.find((x) => x.name === current);
4762
4739
  roomSig = JSON.stringify([current, c?.resources, c?.secrets, c?.work]);
4763
4740
  const [all, secrets, boxes, tether, lists] = await Promise.all([getJson('/resources', []), getJson('/secrets', []), getJson('/mailboxes', []), getJson('/tether', {}), binderCall('lists', {}), readDevices()]);
4764
4741
  keepGiven(tether, lists); paintAssetsCount();
4742
+ if (stale()) return;
4765
4743
  /* The calls this conversation's agent asked the owner to allow: validated here,
4766
4744
  in the parloir, without leaving for the Assets page. */
4767
4745
  const mineDemandes = (Array.isArray(tether.approvals) ? tether.approvals : []).filter((q) => (q.agent?.id ?? '') === `conversation:${current}`);
@@ -4774,9 +4752,11 @@
4774
4752
  const outbox = gits.length ? { key: 'send:all', kind: 'send', name: uiSaid('set-as-to-send', 'To send'), sub: uiSaid('set-as-to-send-sub', 'every repository, reviewed and sent at once') } : null;
4775
4753
  const waitingCount = mineDemandes.length + mineJobs.length;
4776
4754
  const demandes = waitingCount ? { key: 'demandes:mine', kind: 'demandes', name: 'Demandes', sub: uiSaid('set-as-waiting', '{n} waiting for you', { n: waitingCount }) } : null;
4777
- const chosen = (outbox && roomPick === outbox.key ? outbox : null) ?? (demandes && roomPick === demandes.key ? demandes : null) ?? items.find((i) => i.key === roomPick) ?? null;
4755
+ const giving = roomPick === GIVE;
4756
+ const chosen = giving ? null : (outbox && roomPick === outbox.key ? outbox : null) ?? (demandes && roomPick === demandes.key ? demandes : null) ?? items.find((i) => i.key === roomPick) ?? null;
4778
4757
  const as = $('cassets-as');
4779
- as.dataset.open = chosen ? 'detail' : 'list';
4758
+ as.dataset.open = chosen || giving ? 'detail' : 'list';
4759
+ $('cassets-attach').setAttribute('aria-pressed', String(giving));
4780
4760
  /* THE ONE LIST (kit AssetList), scope this conversation: by type, its right on each row; To send and
4781
4761
  Demandes pinned above. Its search is its own, emptied when another conversation is shown. */
4782
4762
  if (cassetsFor !== current) { cassetsFor = current; cassetsQuery = ''; $('cassets-search').value = ''; }
@@ -4788,11 +4768,22 @@
4788
4768
  paintAssetList($('cassets-rows'), model, { chosen: chosen?.key, onOpen: pickRoomAsset, pinned, empty: uiSaid('set-as-none-given', 'Nothing is given to it yet.'), noMatch: uiSaid('set-as-no-match', 'Nothing by that name.') });
4789
4769
  if (c?.work === 'disk') $('cassets-rows').append(el('p', 'hint as-disk', 'Its work is on the disk of its machine: nothing of it is kept here.'));
4790
4770
  const detail = $('cassets-detail');
4791
- if (!chosen) { unembedFiles(); detail.replaceChildren(); if (items.length) detail.append(noAssetChosen()); return; }
4771
+ /* The New asset forms wait in Assets: put back before the detail is drawn again (Give… may hold one). */
4772
+ const parkForms = () => { for (const f of detail.querySelectorAll('.as-form')) $('assets-forms').append(f); };
4773
+ if (giving) {
4774
+ /* Drawn again only when what it shows changed: a form being filled, a search typed, keep their place. */
4775
+ const sig = JSON.stringify([GIVE, giveNew.kind, justMade?.to === current ? justMade.key : '', items.map((i) => i.key), [all, secrets, tether?.targets, lists?.data].map((x) => (Array.isArray(x) ? x.length : 0))]);
4776
+ if (detail.dataset.asset === GIVE && detail.dataset.sig === sig) return;
4777
+ unembedFiles(); parkForms(); detail.replaceChildren();
4778
+ detail.dataset.asset = GIVE; detail.dataset.sig = sig;
4779
+ paintGive(detail, { all, boxes, secrets, tether, lists }, items);
4780
+ return;
4781
+ }
4782
+ if (!chosen) { unembedFiles(); parkForms(); detail.replaceChildren(); delete detail.dataset.asset; if (items.length) detail.append(noAssetChosen()); return; }
4792
4783
  /* The same folder still shown, nothing changed in who has it: only its files are read again (no blink). */
4793
4784
  const sig = JSON.stringify([chosen.key, chosen.own ? '' : chosen.kind === 'send' || chosen.kind === 'demandes' ? waitingCount : accessHolders(chosen)]);
4794
4785
  const keepFiles = (chosen.kind === 'folder' || chosen.kind === 'mailbox') && !chosen.target && $('files').classList.contains('embedded') && detail.contains($('files')) && detail.dataset.asset === chosen.key && detail.dataset.sig === sig;
4795
- if (!keepFiles) { unembedFiles(); detail.replaceChildren(); }
4786
+ if (!keepFiles) { unembedFiles(); parkForms(); detail.replaceChildren(); }
4796
4787
  detail.dataset.asset = chosen.key; detail.dataset.sig = sig;
4797
4788
  const back = () => { roomPick = ''; void paintRoomAssets(); };
4798
4789
  if (keepFiles) { void paintFiles(); return; }
@@ -4809,7 +4800,7 @@
4809
4800
  } else await sendDetail(body, gits);
4810
4801
  return;
4811
4802
  }
4812
- await assetPanel(detail, chosen, { from: current, back, tether, gits });
4803
+ await assetPanel(detail, chosen, { from: current, back, tether, gits, stale });
4813
4804
  }
4814
4805
  /* One asset opened: a step (another one opened takes its place); Back, the list. */
4815
4806
  const assetStep = () => { if (nav.last(['asset']) < 0) nav.push('asset', ({ quiet }) => { roomPick = ''; if (!quiet && assetsShown) void paintRoomAssets(); }); };
@@ -4838,6 +4829,8 @@
4838
4829
  function closeRoomAssets() {
4839
4830
  if (!assetsShown) return;
4840
4831
  assetsShown = false; $('cassets').hidden = true; unembedFiles(); paintViews();
4832
+ for (const f of $('cassets-detail').querySelectorAll('.as-form')) $('assets-forms').append(f);
4833
+ if (roomPick === GIVE) { roomPick = ''; giveNew = { kind: '', level: '' }; delete $('cassets-detail').dataset.asset; }
4841
4834
  if ($('cassets').contains($('gitview'))) shutGit();
4842
4835
  }
4843
4836
 
@@ -5073,19 +5066,19 @@
5073
5066
  const focusFirst = (holder) => { const f = holder.querySelector('.as-form input:not([type=radio])'); if (f) focusField(f); };
5074
5067
  /* What was just made: the box's ending ("✓ <name> created"), until Done. `to`: the conversation it was attached to. */
5075
5068
  let justMade = null;
5076
- /* A form is in the conversation's Attach (its context), or in Assets. */
5077
- const inAttach = (form) => $('attach-box').contains(form);
5078
- const newAssetError = (form, message) => { const e = inAttach(form) ? $('attach-error') : $('assets-error'); e.textContent = message; e.hidden = false; };
5069
+ /* A form is in a conversation's Give… (its context), or in Assets. */
5070
+ const inGive = (form) => $('cassets-detail').contains(form);
5071
+ const newAssetError = (form, message) => { const e = inGive(form) ? $('cassets-error') : $('assets-error'); e.textContent = message; e.hidden = false; };
5079
5072
  /* THE ONE ENDING. `give(conversation, level)` attaches it, when the box was opened from a conversation; in
5080
5073
  Assets it is opened, the ending above it. */
5081
5074
  async function assetMade(form, { key, name, give = null, note = '' }) {
5082
- if (inAttach(form) && current) {
5083
- const kind = attachNew.kind;
5084
- if (give) await give(current, attachNew.level);
5075
+ if (inGive(form) && current) {
5076
+ const kind = giveNew.kind;
5077
+ if (give) await give(current, giveNew.level || 'read');
5085
5078
  justMade = { kind, key, name, note, to: current };
5086
- attachNew.kind = '';
5087
- await paintPanel();
5088
- paintAttach();
5079
+ giveNew.kind = '';
5080
+ await refresh();
5081
+ await paintRoomAssets();
5089
5082
  return;
5090
5083
  }
5091
5084
  justMade = { kind: assetsAdding, key, name, note };
@@ -5093,10 +5086,10 @@
5093
5086
  }
5094
5087
  /* Another kind asked from inside a form (New mailbox's Google, Microsoft: the account's form). */
5095
5088
  async function newAssetKind(form, kind) {
5096
- if (inAttach(form)) { attachNew.kind = kind; paintAttach(); return; }
5089
+ if (inGive(form)) { giveNew.kind = kind; await paintRoomAssets(); return; }
5097
5090
  assetsAdding = kind; await paintAssets();
5098
5091
  }
5099
- /* A Tether target given from Attach: Read watches, Write proposes (asks before each action). */
5092
+ /* A Tether target made in Give…: Read watches, Write proposes (asks before each action). */
5100
5093
  const targetLevel = (level) => (level === 'write' ? 'propose' : 'watch');
5101
5094
  /* ONE ASSET OPENED (06-11, assets step 3): the same panel (kit AssetPanel, AccessList) in the Assets menu and in a
5102
5095
  conversation's tab. Seen from the menu (`from` ''), the asset itself is changed there too: its group, its name, its
@@ -5126,7 +5119,11 @@
5126
5119
  if (stored) await post(`${API}/devices/${id}/give`, { asset: chosen.key, rights: stored });
5127
5120
  else await post(`${API}/devices/${id}/take`, { asset: chosen.key });
5128
5121
  await readDevices();
5129
- } else await applyMemberGrant(chosen, String(row.id).slice('conversation:'.length), stored ?? level);
5122
+ } else {
5123
+ await applyMemberGrant(chosen, String(row.id).slice('conversation:'.length), stored ?? level);
5124
+ /* A secret: its tool has it, or no longer, at its next start (said where it was changed, its tab). */
5125
+ if (chosen.kind === 'secret' && place !== 'assets' && row.id === `conversation:${current}`) secretNote(chosen.name, level !== 'none');
5126
+ }
5130
5127
  } catch (error) { sayAsErr(error); return false; }
5131
5128
  await repaintAsset();
5132
5129
  return true;
@@ -5197,7 +5194,7 @@
5197
5194
  }
5198
5195
  /* `detail`: where it is drawn. `from`: the conversation it is seen from ('' : the menu). `back`: the way back (a phone).
5199
5196
  `tether`, `groups`: as the page read them. `gits`: the conversation's repositories (To send, beside). */
5200
- async function assetPanel(detail, chosen, { from = '', back, tether = {}, groups = [], gits = [] } = {}) {
5197
+ async function assetPanel(detail, chosen, { from = '', back, tether = {}, groups = [], gits = [], stale = () => false } = {}) {
5201
5198
  const inMenu = !from;
5202
5199
  const r = chosen.resource;
5203
5200
  const parts = []; const foot = [];
@@ -5377,7 +5374,10 @@
5377
5374
  edit.addEventListener('click', () => { remoteEditing = !remoteEditing; box.hidden = !remoteEditing; if (remoteEditing) focusField($('gitview-url')); });
5378
5375
  } else edit.addEventListener('click', () => { void openAssetsPlace(chosen.key, { remote: true }); });
5379
5376
  gitHolder = el('div', 'as-git');
5380
- parts.push({ title: 'Settings', extra: [edit], body }, gitHolder);
5377
+ parts.push({ title: 'Settings', extra: [edit], body });
5378
+ /* What THIS conversation's agent may send by itself: under the right it depends on (it writes), seen from it. */
5379
+ if (!inMenu && !DEVICE && model?.here?.level === 'write') parts.push(autoSending(from, chosen.name));
5380
+ parts.push(gitHolder);
5381
5381
  }
5382
5382
  if (t) {
5383
5383
  /* An MCP server's rights say themselves (Off / Read / All with approval / All / Custom): no levels' words under them. */
@@ -5423,6 +5423,8 @@
5423
5423
  }
5424
5424
  /* Its right said once: by its own row's picker when it has one (« Lire » pressed), else by the head's badge. */
5425
5425
  const card = { ...cardOf(chosen), holders: undefined, right: inMenu || model?.here ? undefined : chosen.right, chips: chipsOf(chosen) };
5426
+ /* A later paint began while this one read: it draws, not this one. */
5427
+ if (stale()) return;
5426
5428
  detail.append(KIT.AssetPanel(card, { back: back ? { onBack: () => { if (!nav.close('asset')) back(); } } : null, parts, foot }));
5427
5429
  if (gitHolder) await gitDetail(gitHolder, chosen.name, { place: inMenu, others: gits.length > 1 });
5428
5430
  }
@@ -5655,7 +5657,8 @@
5655
5657
  words, markdown: true, agentName: '',
5656
5658
  /* A mail an AI of the room cites: the same chip and viewer as in a conversation's chat, read for the conversation of the AI that cited it. */
5657
5659
  schemes: { mail: { icon: 'mail', open: (ref, label) => { void openMailRef(ref, { label, of: '', room: id }); } } },
5658
- load: (asked = {}) => fetch(`${API}/rooms/${id}/talk${asked.last ? `?last=${asked.last}` : ''}`).then((x) => x.json()).then((d) => {
5660
+ /* Its lines (a Tether call held for one of its AIs): drawn and decided as in a conversation's chat (machineLines, tetherAct). */
5661
+ load: (asked = {}) => fetch(`${API}/rooms/${id}/talk${asked.last ? `?last=${asked.last}` : ''}`).then((x) => x.json()).then(machineLines).then((d) => {
5659
5662
  /* Read for a room no longer shown: its words go nowhere (Parley drops them with its screen). */
5660
5663
  if (salonId !== id || place !== 'salon') return d;
5661
5664
  /* Read, it is seen (the server says so): its count in the list goes at once, as a conversation's. */
@@ -5698,6 +5701,7 @@
5698
5701
  diagram: drawDiagram, highlight: colourCode, touch: touchScreen,
5699
5702
  voice: chatVoice(), autoSend: chatAuto, autoSendMs: chatAutoMs, onAutoSendMs: keepAutoMs, onAutoSend: (on) => { chatAuto = on; try { localStorage.setItem('clavarium-chat-auto', on ? '1' : '0'); } catch { /* as above */ } },
5700
5703
  draft: `room:${id}`,
5704
+ act: (action) => { tetherAct(action, () => salonThread?.refresh?.()); },
5701
5705
  });
5702
5706
  salonTimer = setInterval(() => { if (salonThread && salonId === id) void salonThread.refresh(); }, 3000);
5703
5707
  }
@@ -6117,7 +6121,7 @@
6117
6121
  });
6118
6122
  $('secret-new').addEventListener('submit', async (event) => {
6119
6123
  event.preventDefault();
6120
- $('assets-error').hidden = true; $('attach-error').hidden = true;
6124
+ $('assets-error').hidden = true; $('cassets-error').hidden = true;
6121
6125
  const lines = !$('secret-lines').hidden;
6122
6126
  /* A key's last line ends with a line break: kept as it was pasted. */
6123
6127
  const value = lines ? $('secret-lines').value.replace(/\r\n?/g, '\n') : $('secret-value').value;
@@ -6127,8 +6131,8 @@
6127
6131
  await post(`${API}/secrets`, { name, value, ...(expires ? { expires } : {}) });
6128
6132
  $('secret-name').value = ''; $('secret-expires').value = ''; secretLines(false, '');
6129
6133
  await assetMade($('secret-box'), { key: `secret:${name}`, name, give: async (to) => {
6130
- await handTo(to, name, true, 'attach-error');
6131
- $('secret-note').textContent = `${name} will be handed to the tool the next time it starts (leave the tool, then "Start it again").`;
6134
+ await handTo(to, name, true, 'cassets-error');
6135
+ secretNote(name, true);
6132
6136
  } });
6133
6137
  } catch (error) { newAssetError($('secret-box'), error.message); }
6134
6138
  await paintAssets();
@@ -8383,7 +8387,7 @@
8383
8387
  $('git-said').hidden = false;
8384
8388
  }
8385
8389
  /* A new repository opens on its card, where it is attached: the next thing to do. */
8386
- await assetMade($('git-box'), { key: `git:${made.name}`, name: made.name, note: made.firstSeen ? $('git-said').textContent : '', give: (to, level) => attachTo(to, made.name, level, 'attach-error') });
8390
+ await assetMade($('git-box'), { key: `git:${made.name}`, name: made.name, note: made.firstSeen ? $('git-said').textContent : '', give: (to, level) => attachTo(to, made.name, level, 'cassets-error') });
8387
8391
  } catch (error) { $('git-error').textContent = error.message; $('git-error').hidden = false; }
8388
8392
  button.disabled = false; button.textContent = 'Create';
8389
8393
  await paintAssets();
@@ -8927,12 +8931,12 @@
8927
8931
  getJson('/accounts', null).then((d) => { if (d?.supported) { accountSupported = { ...d.supported, classic: ['mail', 'calendar', 'contacts'] }; setConnexionProvider($('connexion-new').dataset.provider || 'google'); } });
8928
8932
  $('list-new').addEventListener('submit', async (event) => {
8929
8933
  event.preventDefault();
8930
- $('assets-error').hidden = true; $('attach-error').hidden = true;
8934
+ $('assets-error').hidden = true; $('cassets-error').hidden = true;
8931
8935
  const made = await binderCall('create-list', { template: $('listnew-template').value, title: $('listnew-title').value.trim(), lang: binderLang() });
8932
8936
  if (made.ok) {
8933
8937
  $('listnew-title').value = '';
8934
8938
  const key = `list:${made.data.id}`;
8935
- const attaching = inAttach($('list-box'));
8939
+ const attaching = inGive($('list-box'));
8936
8940
  await assetMade($('list-box'), { key, name: made.data.title ?? $('listnew-template').selectedOptions[0]?.textContent ?? '', give: (to, level) => grantList(made.data.id, to, level) });
8937
8941
  if (!attaching) { listOpen = key; remember(); }
8938
8942
  } else newAssetError($('list-box'), made.text);
@@ -8941,11 +8945,11 @@
8941
8945
  $('folder-new').addEventListener('submit', async (event) => {
8942
8946
  event.preventDefault();
8943
8947
  $('assets-error').hidden = true;
8944
- $('attach-error').hidden = true;
8948
+ $('cassets-error').hidden = true;
8945
8949
  try {
8946
8950
  const made = await post(`${API}/resources`, { name: $('folder-name').value.trim() }); $('folder-name').value = '';
8947
8951
  const name = made?.name ?? '';
8948
- await assetMade($('folder-box'), { key: name ? `folder:${name}` : '', name, give: name ? (to, level) => attachTo(to, name, level, 'attach-error') : null });
8952
+ await assetMade($('folder-box'), { key: name ? `folder:${name}` : '', name, give: name ? (to, level) => attachTo(to, name, level, 'cassets-error') : null });
8949
8953
  } catch (error) { newAssetError($('folder-box'), error.message); }
8950
8954
  await paintAssets();
8951
8955
  });
package/web/index.html CHANGED
@@ -105,6 +105,7 @@
105
105
  each opened on the right (a folder: its files). -->
106
106
  <div id="cassets" class="sheet as-room" hidden>
107
107
  <p id="cassets-error" class="error" role="alert" hidden></p>
108
+ <p id="cassets-note" class="hint" role="status" hidden></p>
108
109
  <div class="as" id="cassets-as">
109
110
  <div class="as-list"><div class="as-tools"><input type="search" id="cassets-search" placeholder="Search" aria-label="Search its assets" spellcheck="false" autocomplete="off"><button type="button" id="cassets-attach" class="as-new"><span aria-hidden="true">+ </span><span data-said="set-as-give">Give…</span></button></div><div id="cassets-rows"></div></div>
110
111
  <div class="as-detail" id="cassets-detail"></div>
@@ -226,26 +227,10 @@
226
227
  </section>
227
228
  <section class="settings-group" id="panel-group-access" data-group="access" role="tabpanel" aria-label="Access">
228
229
  <details class="chapter" id="chapter-access" data-chapter="access">
229
- <summary><span class="chapter-title" data-said="set-ch-access">Access</span><span class="chapter-about">Folders, repositories, secrets, network.</span></summary>
230
+ <summary><span class="chapter-title" data-said="set-ch-access">Access</span><span class="chapter-about">Its assets, its sign-in mailbox.</span></summary>
230
231
  <div class="chapter-body">
231
- <div class="panel-top"><b data-said="set-acc-folders">Folders, repositories, mailboxes</b><button type="button" id="attach-open" class="primary" data-said="set-na-attach">Attach</button></div>
232
- <p class="hint tip" hidden>In its machine under <code>/resources/</code>. Read-only cannot be lifted by the tool.</p>
233
- <div id="panel-rows"></div>
234
- <p id="panel-empty" class="hint" hidden>Nothing is attached to this conversation.</p>
235
- <div class="panel-top"><b data-said="set-acc-secrets">Secrets handed to its tool</b></div>
236
- <div id="access-secrets"></div>
237
- <p id="access-secrets-empty" class="hint" hidden>No secret is handed to it.</p>
238
- <p id="secret-note" class="hint" role="status"></p>
239
- <!-- ATTACH: the New asset box (kit.js NewAsset) with this conversation as its context: what is
240
- there already (#attach-there, moved into it), or a new one, attached at the end. -->
241
- <div id="attach-picker" hidden>
242
- <div class="panel-top"><b id="attach-title" data-said="set-na-attach">Attach</b><button type="button" id="attach-close" data-said="set-na-done">Done</button></div>
243
- <div id="attach-box"></div>
244
- <p id="attach-error" class="error" role="alert" hidden></p>
245
- <div id="attach-park" hidden><div id="attach-there"><div id="attach-rows"></div>
246
- <p id="attach-empty" class="hint" hidden>Everything there is is attached already.</p></div></div>
247
- <button type="button" id="attach-manage" class="as-link">Manage all assets</button>
248
- </div>
232
+ <!-- ITS ASSETS (07-11, assets step 4): one line; what it was given lives in its Assets tab (Give…, its rights). -->
233
+ <div class="row access-assets"><button type="button" id="access-assets" class="as-link"></button></div>
249
234
  <div id="signin-setup" hidden>
250
235
  <div class="panel-top"><b>Sign-in mailbox</b></div>
251
236
  <p class="hint">Its sign-in links, beside the terminal</p><p class="hint tip" hidden>A mailbox for this tool's account, read only; its password never enters the machine.</p>
package/web/kit.js CHANGED
@@ -30,6 +30,8 @@
30
30
  * AccessList WHO HAS IT (06-11, assets step 3): a row per conversation or device, its RightsPicker; seen from one
31
31
  * conversation, its row first, then "Also with"; accessModel places the rows.
32
32
  * AssetPanel ONE ASSET OPENED, the same in the Assets menu and a conversation's tab: head, parts, foot (AssetDelete).
33
+ * AssetPicker GIVE… (07-11, assets step 4): the one list of what a conversation has not yet, one chosen, its right,
34
+ * then « Give »; a new asset below it (NewAsset). assetPickModel places the rows.
33
35
  * RIGHTS THE ONE TABLE of what an asset can be given at, by its kind and by who gets it (a
34
36
  * conversation, a device); "none" first. RightsBadge reads a right, RightsPicker sets it.
35
37
  *
@@ -427,7 +429,7 @@
427
429
 
428
430
  /* AN ASSET'S SIGN, by its kind: the icon set's name (Lucide, icons.js), or '' for one drawn here (a binder).
429
431
  A live link (a connexion) takes the sign of what it links (a mailbox, a calendar); `acces`, any other. */
430
- const ASSET_ICONS = { folder: 'folder', git: 'git-branch', mailbox: 'mail', agenda: 'calendar', contacts: 'user', secret: 'key-round', list: '', acces: 'link', send: 'arrow-up', demandes: 'arrow-up', box: 'mic', device: 'smartphone', conversation: 'message-square' };
432
+ const ASSET_ICONS = { folder: 'folder', git: 'git-branch', mailbox: 'mail', agenda: 'calendar', contacts: 'user', secret: 'key-round', list: '', acces: 'link', give: 'plus', send: 'arrow-up', demandes: 'arrow-up', box: 'mic', device: 'smartphone', conversation: 'message-square' };
431
433
  const ASSET_KINDS = Object.keys(ASSET_ICONS);
432
434
  /* A binder: the set has no such sign, so it is drawn here, in its style (was app.js listIcon). */
433
435
  function listSign() {
@@ -714,8 +716,8 @@
714
716
  /* The list drawn from its model. A row is an AssetCard (`rowCls` added: the page's handle on it); `pinned`,
715
717
  rows above every section (what waits: To send, Demandes). `onOpen(key)`; a group's head folds it
716
718
  (`onFold(id)`), its ⚙ opens it (`onGroup(id)`). `empty`: said when there is nothing at all; `noMatch`,
717
- when a search finds nothing. */
718
- function AssetList(model, { chosen = '', onOpen = () => {}, onFold = () => {}, onGroup = () => {}, rowCls = '', pinned = [], empty = '', noMatch = '' } = {}) {
719
+ when a search finds nothing. `after(a)`: a node drawn under a row (Give…: the chosen one's right). */
720
+ function AssetList(model, { chosen = '', onOpen = () => {}, onFold = () => {}, onGroup = () => {}, rowCls = '', pinned = [], empty = '', noMatch = '', after = null } = {}) {
719
721
  const box = node('div', 'kit-aslist'); box.dataset.view = model.view;
720
722
  box.append(...pinned.filter(Boolean));
721
723
  const hints = {
@@ -746,7 +748,7 @@
746
748
  } else head.append(node('h3', '', s.title));
747
749
  sec.append(head);
748
750
  if (!s.folded) {
749
- for (const a of s.items) sec.append(AssetCard(a, { size: 'row', cls: rowCls, current: a.key === chosen, onOpen }));
751
+ for (const a of s.items) { sec.append(AssetCard(a, { size: 'row', cls: rowCls, current: a.key === chosen, onOpen })); const more = after?.(a); if (more) sec.append(more); }
750
752
  if (s.empty) sec.append(node('p', 'hint kit-aslist-none', hints[s.empty] ?? ''));
751
753
  }
752
754
  box.append(sec);
@@ -756,6 +758,60 @@
756
758
  return box;
757
759
  }
758
760
 
761
+ /* GIVE… (07-11, assets step 4): ONE LIST of what a conversation has not been given yet (the same AssetList, by type),
762
+ searched; one chosen, its right below it (the kind's levels, without « None »: the lightest first pressed), then
763
+ « Give ». A new asset below the list (`newAsset`, the page's NewAsset): made there, it is given at the end.
764
+ Taking a right back is « None » in its own panel (AccessList): the asset stays in Assets.
765
+
766
+ assetPickModel: the rows placed, no page needed (tested alone).
767
+ items [cards] every asset given [keys] what the conversation has query, kinds as AssetList's */
768
+ function assetPickModel({ items = [], given = [], query = '', kinds = [] } = {}) {
769
+ const have = new Set(given);
770
+ return assetListModel({ items: (items ?? []).filter((i) => i && i.key && !have.has(i.key)), scope: 'conversation:', by: 'type', query, kinds });
771
+ }
772
+ /* Drawn, its state its own (the search typed, the one chosen, its level): the page repaints it only once given.
773
+ `onGive(card, level, stored)` gives it (the button waits; false or a throw: nothing changes); `onPick(key)`,
774
+ `onQuery(text)` keep the page's memory of them for its next paint. */
775
+ function AssetPicker({ items = [], given = [], kinds = [], query = '', chosen = '', newAsset = null, words: w = {}, onPick = () => {}, onQuery = () => {}, onGive = () => {} } = {}) {
776
+ const root = node('div', 'kit-aspick');
777
+ const search = node('input', 'kit-aspick-search'); search.type = 'search'; search.value = query;
778
+ search.placeholder = say('set-search', 'Search'); search.setAttribute('aria-label', search.placeholder);
779
+ search.spellcheck = false; search.autocomplete = 'off';
780
+ const list = node('div', 'kit-aspick-list');
781
+ const byKey = new Map((items ?? []).map((i) => [i.key, i]));
782
+ let q = query; let at = byKey.has(chosen) && !given.includes(chosen) ? chosen : '';
783
+ const give = (a) => {
784
+ const key = rightsKey(a);
785
+ const levels = rightsFor(key).filter((l) => l !== 'none');
786
+ if (!levels.length) return null;
787
+ let level = levels[0];
788
+ const bar = node('div', 'kit-aspick-give');
789
+ const pick = Segmented({ options: levels.map((l) => ({ value: l, label: wordOf(key, l, 'pick') })), value: level, label: say('set-acc-what', 'What {who} may do with {name}').replace('{who}', w.who ?? '').replace('{name}', a.name ?? ''), onChange: (l) => { level = l; } });
790
+ addClass(pick, 'kit-rights-pick');
791
+ for (const b of pick.children) b.title = wordOf(key, b.dataset.value, 'hint');
792
+ const go = node('button', 'primary kit-aspick-go', say('set-give-go', 'Give')); go.type = 'button';
793
+ go.addEventListener('click', async () => {
794
+ go.disabled = true;
795
+ try { if ((await onGive(a, level, storedOf(key, level))) === false) go.disabled = false; }
796
+ catch { go.disabled = false; }
797
+ });
798
+ bar.append(pick, go);
799
+ return bar;
800
+ };
801
+ const draw = () => {
802
+ const model = assetPickModel({ items, given, query: q, kinds });
803
+ if (at && !model.sections.some((s) => s.items.some((i) => i.key === at))) at = '';
804
+ list.replaceChildren(AssetList(model, { chosen: at, rowCls: 'as-row kit-aspick-row', after: (a) => (a.key === at ? give(a) : null),
805
+ onOpen: (key) => { at = at === key ? '' : key; onPick(at); draw(); },
806
+ empty: w.empty ?? say('set-give-all', 'It has every asset already.'), noMatch: say('set-as-no-match', 'Nothing by that name.') }));
807
+ };
808
+ search.addEventListener('input', () => { q = search.value.trim(); onQuery(q); draw(); });
809
+ draw();
810
+ root.append(search, list);
811
+ if (newAsset) root.append(node('h4', 'kit-na-sub kit-aspick-or', w.orNew ?? say('set-na-or-new', 'or a new one')), newAsset);
812
+ return root;
813
+ }
814
+
759
815
  /* WHO HAS IT (06-11, assets step 3): ONE LIST of who was given an asset -- each conversation and each device on a
760
816
  row, its right set in place (RightsPicker, "None" first). Seen from a conversation, its own row comes first, then
761
817
  "Also with" the others. "+ Give to…" opens the conversations that do not have it yet, each at None.
@@ -887,7 +943,7 @@
887
943
 
888
944
  window.clavariumKit = {
889
945
  collectModel,
890
- AssetIcon, AssetCard, Holders, MemberStack, memberStackModel, roomPatch, RightsBadge, RightsPicker, AssetList, assetListModel, AccessList, accessModel, AssetPanel, AssetDelete, RIGHT_SAME,
946
+ AssetIcon, AssetCard, Holders, MemberStack, memberStackModel, roomPatch, RightsBadge, RightsPicker, AssetList, assetListModel, AccessList, accessModel, AssetPanel, AssetDelete, AssetPicker, assetPickModel, RIGHT_SAME,
891
947
  ASSET_ICONS, ASSET_KINDS, RIGHTS, RIGHT_LEVELS, MCP_WORDS, rightsKey, rightsFor, rightOf, storedOf, lightest, rightWord,
892
948
  shareSlices, ShareDonut, ShareKey,
893
949
  STATES, STATE_WORDS, stateOf, hueOf, initialOf, IDLE_MS, idleFor, idleWords, tidyList,
package/web/links.js CHANGED
@@ -671,7 +671,7 @@
671
671
  rm.addEventListener('click', () => {
672
672
  const last = a.kind === 'device' && graph.links.filter((x) => x.from === a.id).length === 1;
673
673
  /* The question in words above, two short buttons side by side below. */
674
- const question = a.kind === 'device' ? `Take ${b.name} back from ${a.name}?${last ? ' It has nothing else: the device is removed too.' : ''}` : b.kind === 'secret' ? `Stop handing ${b.name} to ${a.name}?` : `Detach ${b.name} from ${a.name}?`;
674
+ const question = a.kind === 'device' ? `Take ${b.name} back from ${a.name}?${last ? ' It has nothing else: the device is removed too.' : ''}` : b.kind === 'secret' ? `Stop handing ${b.name} to ${a.name}?` : `Take ${b.name} from ${a.name}? It stays in Assets.`;
675
675
  const ask = h('p', { class: 'lk-ask', text: question });
676
676
  const sure = h('button', { type: 'button', class: 'danger', text: 'Remove' });
677
677
  const keep = h('button', { type: 'button', text: 'Cancel', onclick: () => openPop(l) });
@@ -772,7 +772,7 @@
772
772
  h('div', { class: 'lk-top' }, h('i', { style: `background:${css(KIND(l))}` }), h('b', { text: other.name }), h('span', { class: 'lk-sb', text: what }),
773
773
  l.pending ? null : h('button', { type: 'button', class: 'lk-x', 'aria-label': `Remove the link to ${other.name}`, text: '×', onclick: (ev) => {
774
774
  const btn = ev.currentTarget;
775
- btn.replaceWith(h('button', { type: 'button', class: 'danger', text: n.kind === 'device' || node(l.from).kind === 'device' ? 'Take back' : 'Detach', onclick: async () => { try { await takeAway(l); await changed(); } catch (e) { err(e.message); } } }));
775
+ btn.replaceWith(h('button', { type: 'button', class: 'danger', text: n.kind === 'device' || node(l.from).kind === 'device' ? 'Take back' : 'Remove', onclick: async () => { try { await takeAway(l); await changed(); } catch (e) { err(e.message); } } }));
776
776
  } })),
777
777
  seg(l));
778
778
  const out = graph.links.filter((l) => l.from === n.id); const inn = graph.links.filter((l) => l.to === n.id);
package/web/style.css CHANGED
@@ -148,7 +148,6 @@ footer button.wide { flex: 1 0 auto; background: transparent; border-color: var(
148
148
  .sheet { position: absolute; inset: 0; z-index: 3; display: flex; flex-direction: column; gap: 10px; padding: 14px; background: var(--panel); overflow-y: auto; }
149
149
  .panel-top { display: flex; align-items: center; justify-content: space-between; }
150
150
  .panel-actions { margin: 2px 0 12px; }
151
- #panel-rows { display: flex; flex-direction: column; }
152
151
  .folder { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid var(--line); flex-wrap: wrap; }
153
152
  .folder b { flex: 1; min-width: 100px; overflow: hidden; text-overflow: ellipsis; }
154
153
  .levels { display: flex; }
@@ -365,11 +364,10 @@ footer button.careful { border-style: dashed; color: var(--ink2); }
365
364
  @media (prefers-reduced-motion: reduce) { .asset-head b::before { transition: none; } }
366
365
  .asset-inside { flex-basis: 100%; padding: 0 0 8px 12px; display: flex; flex-direction: column; gap: 4px; }
367
366
  @media (max-width: 700px) { .asset-head { grid-template-columns: 1fr; gap: 2px; } }
368
- #assets details > summary, #access-advanced > summary { cursor: pointer; padding: 8px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--button); font-weight: 600; list-style: none; align-self: flex-start; }
369
- #assets details > summary::before, #access-advanced > summary::before { content: '+ '; }
370
- #assets details[open] > summary::before, #access-advanced[open] > summary::before { content: '- '; }
367
+ #assets details > summary, #access-advanced > summary, #cassets .auto-send > summary { cursor: pointer; padding: 8px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--button); font-weight: 600; list-style: none; align-self: flex-start; }
368
+ #assets details > summary::before, #access-advanced > summary::before, #cassets .auto-send > summary::before { content: '+ '; }
369
+ #assets details[open] > summary::before, #access-advanced[open] > summary::before, #cassets .auto-send[open] > summary::before { content: '- '; }
371
370
  #assets details form, #access-advanced > div { margin-top: 8px; }
372
- #attach-picker { border: 1px solid var(--brass); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
373
371
  #chips { cursor: pointer; }
374
372
  #settings .row { align-items: center; gap: 10px; }
375
373
  /* One kind of event a line: its name, then its level, wrapped under it on a narrow screen. */
@@ -2911,6 +2909,19 @@ button.kit-switch[aria-checked="true"]::after { left: 18px; background: var(--ok
2911
2909
  .kit-access-acts { display: flex; gap: 14px; flex-wrap: wrap; }
2912
2910
  .kit-rights-pick > button > .kit-rights-same { display: block; font-size: .6875rem; font-weight: 500; opacity: .8; }
2913
2911
  .kit-asdelete { display: inline; }
2912
+ /* GIVE… (07-11, assets step 4: kit AssetPicker): what it has not yet, searched; the one chosen opens its right and
2913
+ « Give » under it; a new asset below. Settings › Access: one line to its tab. */
2914
+ .kit-aspick { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
2915
+ .kit-aspick-search { width: 100%; padding: 9px 12px; border-radius: 9px; }
2916
+ .kit-aspick-list .kit-aslist-sec:first-child { margin-top: 0; }
2917
+ .kit-aspick-give { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin: 0 0 6px 40px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--brass) 45%, var(--line)); border-radius: 10px; background: color-mix(in srgb, var(--brass) 7%, var(--bar)); }
2918
+ .kit-aspick-give > .kit-rights-pick { margin-right: auto; }
2919
+ .kit-aspick-go { min-height: 30px; padding: 0 16px; }
2920
+ .kit-aspick-or { margin-top: 10px; }
2921
+ .kit-asset-ic[data-kind="give"] { color: var(--brass-ink); }
2922
+ .as-tools .as-new[aria-pressed="true"] { background: var(--brass); color: var(--on-brass, #fff); border-color: var(--brass); }
2923
+ .access-assets { margin: 2px 0 8px; }
2924
+ .access-assets > .as-link { font-size: .9375rem; font-weight: 600; }
2914
2925
  .as-files-menu #files.embedded > .panel-top { display: none; }
2915
2926
  /* IDLE 12 H OR MORE (07-11): « rien depuis 14 h », in grey, never bold. */
2916
2927
  .item small.kit-conv-idle { color: var(--ink2); opacity: .8; }