viberoom 0.5.4 → 0.5.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/README.md +2 -0
- package/dist/context.js +42 -0
- package/dist/launcher.js +7 -1
- package/dist/main.js +43 -6
- package/dist/recipes.js +22 -0
- package/dist/room.js +151 -14
- package/dist/server.js +9 -3
- package/dist/tui.js +62 -0
- package/package.json +1 -1
- package/templates/design-critique/template.json +4 -2
- package/templates/explainer/template.json +2 -1
- package/templates/pair-programmer/template.json +2 -1
- package/templates/wren-and-quinn/template.json +5 -3
- package/ui/app.css +52 -6
- package/ui/app.js +236 -25
- package/ui/theme.css +3 -0
package/README.md
CHANGED
|
@@ -146,6 +146,8 @@ it in a browser tab. Later, `npm install -g viberoom@latest` and the next start
|
|
|
146
146
|
or GitHub Copilot. viberoom finds the ones you have and offers only those. It installs none of them.
|
|
147
147
|
- A browser. Chrome, Edge or Brave for the app window; anything modern for a tab.
|
|
148
148
|
|
|
149
|
+
Something does not start? `viberoom doctor` checks these four things and says which one is missing.
|
|
150
|
+
|
|
149
151
|
<br>
|
|
150
152
|
|
|
151
153
|
## Use
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
export const NOTES_THRESHOLD = 0.8;
|
|
3
|
+
export const NOTES_REQUEST = "end your reply with a <notes> block: up to 10 lines on this room, your task and where you are with it; the block is kept by the hub for a restart and is not posted";
|
|
4
|
+
export const NOTES_ONLY_PROMPT = "Reply with a <notes> block only: up to 10 lines on this room, your task and where you are with it, written for a future you that starts with an empty head. Nothing else; nothing is posted.";
|
|
5
|
+
const OPEN = /<notes>/i;
|
|
6
|
+
const BLOCK = /\s*<notes>([\s\S]*?)(?:<\/notes>|$)\s*/i;
|
|
7
|
+
export function extractNotes(text) {
|
|
8
|
+
const m = BLOCK.exec(text);
|
|
9
|
+
if (!m)
|
|
10
|
+
return { visible: text, notes: null };
|
|
11
|
+
const notes = m[1].trim();
|
|
12
|
+
const before = text.slice(0, m.index).replace(/\s+$/, "");
|
|
13
|
+
const after = text.slice(m.index + m[0].length).replace(/^\s+/, "");
|
|
14
|
+
const visible = before && after ? `${before} ${after}` : before || after;
|
|
15
|
+
return { visible, notes: notes || null };
|
|
16
|
+
}
|
|
17
|
+
export function visibleChunk(textBefore, chunk) {
|
|
18
|
+
if (OPEN.test(textBefore))
|
|
19
|
+
return "";
|
|
20
|
+
const at = chunk.search(OPEN);
|
|
21
|
+
return at < 0 ? chunk : chunk.slice(0, at);
|
|
22
|
+
}
|
|
23
|
+
export function crossedThreshold(previousUsed, used, size, threshold = NOTES_THRESHOLD) {
|
|
24
|
+
if (!(size > 0))
|
|
25
|
+
return false;
|
|
26
|
+
return used / size >= threshold && previousUsed / size < threshold;
|
|
27
|
+
}
|
|
28
|
+
export function overThreshold(used, size, threshold = NOTES_THRESHOLD) {
|
|
29
|
+
return size > 0 && used / size >= threshold;
|
|
30
|
+
}
|
|
31
|
+
const CONTEXT_FULL = /context (window )?(is )?(full|too long|exceeded|limit)|prompt is too long|maximum context length|input (length|is too long)|too many tokens|exceeds the (context|token) (window|limit)|context_length_exceeded/i;
|
|
32
|
+
export function isContextFullError(text) {
|
|
33
|
+
return !!text && CONTEXT_FULL.test(text);
|
|
34
|
+
}
|
|
35
|
+
const BARE_PREFIX = /^\s*(?:\[?(?:error|api error|request failed)\]?\s*[:\-]?\s*)?(?:\d{3}\s+)?/i;
|
|
36
|
+
export function isBareContextFullError(text) {
|
|
37
|
+
if (!text || text.length > 240 || text.includes("\n"))
|
|
38
|
+
return false;
|
|
39
|
+
const rest = text.replace(BARE_PREFIX, "");
|
|
40
|
+
const m = CONTEXT_FULL.exec(rest);
|
|
41
|
+
return !!m && m.index === 0;
|
|
42
|
+
}
|
package/dist/launcher.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
2
|
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, posix, win32 } from "node:path";
|
|
4
|
-
const COMMANDS = new Set(["run", "serve", "start", "stop", "status", "open", "logs", "help"]);
|
|
4
|
+
const COMMANDS = new Set(["run", "serve", "start", "stop", "status", "open", "logs", "doctor", "help"]);
|
|
5
5
|
export function splitCommand(argv) {
|
|
6
6
|
const first = argv[0];
|
|
7
7
|
if (first && !first.startsWith("-") && COMMANDS.has(first))
|
|
@@ -174,6 +174,12 @@ export function appWindowArgs(url, profileDir, freshProfile, placement = null, p
|
|
|
174
174
|
args.push("--class=viberoom");
|
|
175
175
|
return args;
|
|
176
176
|
}
|
|
177
|
+
export function browserAdvice(chromium, platform = process.platform) {
|
|
178
|
+
if (chromium)
|
|
179
|
+
return null;
|
|
180
|
+
const names = platform === "darwin" ? "Chrome, Edge, Brave or Chromium" : platform === "win32" ? "Chrome, Edge or Chromium" : "google-chrome, chromium, microsoft-edge or brave-browser on PATH";
|
|
181
|
+
return `No Chromium-based browser found (${names}); viberoom opens in a tab of your default browser instead. Install one of them for the app window.`;
|
|
182
|
+
}
|
|
177
183
|
export function openUrlCommand(url, platform = process.platform) {
|
|
178
184
|
if (platform === "win32")
|
|
179
185
|
return `start "" "${url}"`;
|
package/dist/main.js
CHANGED
|
@@ -8,9 +8,10 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { Hub } from "./hub.js";
|
|
9
9
|
import { Logger } from "./log.js";
|
|
10
10
|
import { startServer } from "./server.js";
|
|
11
|
-
import { appWindowArgs, recordedWindowPlacement, savedWindowPlacement, findChromium, isProcessAlive, logFilePath, openUrlCommand, pidFilePath, readPidFile, rotateLog, splitCommand, tailFile, writePidFile, } from "./launcher.js";
|
|
11
|
+
import { appWindowArgs, recordedWindowPlacement, savedWindowPlacement, browserAdvice, findChromium, isProcessAlive, logFilePath, openUrlCommand, pidFilePath, readPidFile, rotateLog, splitCommand, tailFile, writePidFile, } from "./launcher.js";
|
|
12
12
|
import { aumidSyncScript, installShortcuts, windowsShortcutPaths } from "./shortcuts.js";
|
|
13
|
-
import { runMenu } from "./tui.js";
|
|
13
|
+
import { askEnter, renderInstalled, runMenu, unicodeSupported } from "./tui.js";
|
|
14
|
+
import { listRecipes } from "./recipes.js";
|
|
14
15
|
function parseArgs(argv) {
|
|
15
16
|
const { command, rest } = splitCommand(argv);
|
|
16
17
|
const options = {
|
|
@@ -77,6 +78,7 @@ Commands
|
|
|
77
78
|
status show whether a hub is running, its build and address
|
|
78
79
|
open open the window of the running hub
|
|
79
80
|
logs print the last lines of the background hub's log
|
|
81
|
+
doctor check Node, the browser, the coding agents and the hub; say what is missing and why
|
|
80
82
|
|
|
81
83
|
Options
|
|
82
84
|
--port localhost port for the web UI (default 4810)
|
|
@@ -198,9 +200,39 @@ function openWindow(url, options, log) {
|
|
|
198
200
|
}
|
|
199
201
|
return;
|
|
200
202
|
}
|
|
203
|
+
const advice = options.browser ? null : browserAdvice(null);
|
|
204
|
+
if (advice) {
|
|
205
|
+
log.warn(advice);
|
|
206
|
+
process.stderr.write(`${advice}\n`);
|
|
207
|
+
try {
|
|
208
|
+
appendFileSync(logFilePath(options.dataDir), `[${new Date().toISOString()}] [launcher] ${advice}\n`);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
}
|
|
212
|
+
}
|
|
201
213
|
log.info("opening the default browser");
|
|
202
214
|
exec(openUrlCommand(url), () => undefined);
|
|
203
215
|
}
|
|
216
|
+
async function runDoctor(options, info) {
|
|
217
|
+
const lines = [];
|
|
218
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
219
|
+
lines.push(`viberoom ${info.version} (build ${info.build})`);
|
|
220
|
+
lines.push(`${major >= 22 ? "ok " : "FAIL"} node ${process.versions.node}${major >= 22 ? "" : " (viberoom needs Node 22 or newer: https://nodejs.org)"}`);
|
|
221
|
+
const chromium = findChromium();
|
|
222
|
+
lines.push(chromium ? `ok browser for the app window: ${chromium}` : `warn ${browserAdvice(null)}`);
|
|
223
|
+
const recipes = listRecipes();
|
|
224
|
+
const found = recipes.filter((r) => !r.unavailableReason);
|
|
225
|
+
lines.push(`${found.length ? "ok " : "warn"} coding agents: ${found.length ? found.map((r) => r.vendor).join(", ") : "none found"}${found.length ? "" : " (install and log in to at least one: Claude Code, Codex, Gemini CLI, Cursor, OpenCode or GitHub Copilot)"}`);
|
|
226
|
+
for (const r of recipes.filter((r) => r.unavailableReason))
|
|
227
|
+
lines.push(` ${r.vendor}: ${r.unavailableReason}`);
|
|
228
|
+
const running = await runningInstance(options.port);
|
|
229
|
+
lines.push(running ? `ok hub running at ${running.url} (build ${running.build ?? "unknown"})` : `info no hub on port ${options.port}: start one with "viberoom start" (or "viberoom start --browser" without a Chromium browser)`);
|
|
230
|
+
lines.push(` data: ${options.dataDir}`);
|
|
231
|
+
lines.push(` log: ${logFilePath(options.dataDir)}`);
|
|
232
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
233
|
+
if (major < 22)
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
}
|
|
204
236
|
async function runHub(options, log, info) {
|
|
205
237
|
const background = options.command === "serve";
|
|
206
238
|
if (!background) {
|
|
@@ -367,6 +399,9 @@ async function main() {
|
|
|
367
399
|
openWindow(url, options, log);
|
|
368
400
|
return;
|
|
369
401
|
}
|
|
402
|
+
case "doctor":
|
|
403
|
+
await runDoctor(options, info);
|
|
404
|
+
return;
|
|
370
405
|
case "logs": {
|
|
371
406
|
const path = logFilePath(options.dataDir);
|
|
372
407
|
process.stdout.write(`${path}\n${tailFile(path, 60)}\n`);
|
|
@@ -385,10 +420,12 @@ async function main() {
|
|
|
385
420
|
if (choice === "shortcut") {
|
|
386
421
|
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
387
422
|
const result = installShortcuts({ root: fileURLToPath(new URL("..", import.meta.url)), dataDir: options.dataDir, node: process.execPath, version: pkg.version, desktop: true });
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
process.stdout.write(
|
|
423
|
+
const advice = browserAdvice(findChromium());
|
|
424
|
+
process.stdout.write(renderInstalled({ files: result.files, notes: result.notes, platform: process.platform, browserAdvice: advice && advice.replace("viberoom opens", "the icon opens viberoom") }, { color: !process.env.NO_COLOR, unicode: unicodeSupported(), columns: process.stdout.columns }));
|
|
425
|
+
if (await askEnter()) {
|
|
426
|
+
process.stdout.write("\n");
|
|
427
|
+
await startBackground(options, log, info);
|
|
428
|
+
}
|
|
392
429
|
return;
|
|
393
430
|
}
|
|
394
431
|
}
|
package/dist/recipes.js
CHANGED
|
@@ -276,6 +276,28 @@ const recipes = [
|
|
|
276
276
|
}),
|
|
277
277
|
},
|
|
278
278
|
];
|
|
279
|
+
const fakeAgent = process.env.VIBEROOM_FAKE_AGENT;
|
|
280
|
+
if (fakeAgent) {
|
|
281
|
+
recipes.push({
|
|
282
|
+
id: "fake",
|
|
283
|
+
label: "Fake (test agent)",
|
|
284
|
+
vendor: "Fake",
|
|
285
|
+
icon: "",
|
|
286
|
+
tested: false,
|
|
287
|
+
note: "A scripted ACP agent for the hub's own probes; present only with VIBEROOM_FAKE_AGENT.",
|
|
288
|
+
modelPresets: [],
|
|
289
|
+
defaultModel: null,
|
|
290
|
+
effortPresets: [],
|
|
291
|
+
defaultEffort: null,
|
|
292
|
+
modePresets: [],
|
|
293
|
+
defaultMode: null,
|
|
294
|
+
unavailableReason: null,
|
|
295
|
+
installedAt: fakeAgent,
|
|
296
|
+
installHint: "",
|
|
297
|
+
bypassMode: null,
|
|
298
|
+
build: () => ({ command: process.execPath, args: [fakeAgent], env: {} }),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
279
301
|
export function listRecipes() {
|
|
280
302
|
return recipes;
|
|
281
303
|
}
|
package/dist/room.js
CHANGED
|
@@ -3,6 +3,7 @@ import { EventEmitter } from "node:events";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
5
5
|
import { writeFileAtomic } from "./atomic.js";
|
|
6
|
+
import { NOTES_ONLY_PROMPT, NOTES_REQUEST, crossedThreshold, extractNotes, isBareContextFullError, isContextFullError, overThreshold, visibleChunk } from "./context.js";
|
|
6
7
|
import { affectedByEdit, editNotice, partitionHistory, rewriteNotice } from "./edit.js";
|
|
7
8
|
import { saveImages } from "./files.js";
|
|
8
9
|
import { join, resolve } from "node:path";
|
|
@@ -29,6 +30,7 @@ export class Room extends EventEmitter {
|
|
|
29
30
|
hops = 0;
|
|
30
31
|
focused = false;
|
|
31
32
|
participants = new Map();
|
|
33
|
+
lastTurnHidden = false;
|
|
32
34
|
messages = [];
|
|
33
35
|
seq = 0;
|
|
34
36
|
programHumanDescription;
|
|
@@ -124,6 +126,9 @@ export class Room extends EventEmitter {
|
|
|
124
126
|
sessionId: s.sessionId,
|
|
125
127
|
supportsLoad: s.supportsLoad,
|
|
126
128
|
sawFromSeq: s.sawFromSeq,
|
|
129
|
+
notes: s.notes,
|
|
130
|
+
notesAt: s.notesAt,
|
|
131
|
+
notesSeq: s.notesSeq,
|
|
127
132
|
violations: 0,
|
|
128
133
|
briefsSent: 0,
|
|
129
134
|
failedTurns: 0,
|
|
@@ -193,6 +198,9 @@ export class Room extends EventEmitter {
|
|
|
193
198
|
supportsLoad: p.supportsLoad,
|
|
194
199
|
lastSeenSeq: this.runtimes.get(p.id)?.lastSeenSeq ?? this.restoredSeen.get(p.id),
|
|
195
200
|
sawFromSeq: p.sawFromSeq,
|
|
201
|
+
notes: p.notes,
|
|
202
|
+
notesAt: p.notesAt,
|
|
203
|
+
notesSeq: p.notesSeq,
|
|
196
204
|
})),
|
|
197
205
|
};
|
|
198
206
|
}
|
|
@@ -450,11 +458,14 @@ export class Room extends EventEmitter {
|
|
|
450
458
|
this.route(message);
|
|
451
459
|
return { restarted, removed: removed.length };
|
|
452
460
|
}
|
|
453
|
-
async respawnAgent(id) {
|
|
461
|
+
async respawnAgent(id, options = {}) {
|
|
454
462
|
const participant = this.participants.get(id);
|
|
455
463
|
if (!participant || participant.kind !== "agent")
|
|
456
464
|
throw new Error("no such agent");
|
|
457
465
|
const online = this.runtimes.has(id);
|
|
466
|
+
const memory = !!options.memory;
|
|
467
|
+
const replay = memory ? Math.max(0, options.replay ?? this.settings.replayAfterRestart) : 0;
|
|
468
|
+
const withNotes = memory && !!participant.notes;
|
|
458
469
|
this.dropScheduledTurn(id);
|
|
459
470
|
this.cancelPermissionsOf(id);
|
|
460
471
|
if (this.speaking === id)
|
|
@@ -465,16 +476,86 @@ export class Room extends EventEmitter {
|
|
|
465
476
|
this.restoredSeen.set(id, this.seq);
|
|
466
477
|
this.push({ type: "participant", participant });
|
|
467
478
|
if (!online) {
|
|
468
|
-
participant.statusDetail = "its context was cleared; a reconnect starts it with an empty head";
|
|
469
|
-
this.postSystem(`${participant.name} was respawned while offline: it comes back knowing nothing from before.`);
|
|
479
|
+
participant.statusDetail = withNotes ? "its context was cleared; a reconnect starts it with its notes" : "its context was cleared; a reconnect starts it with an empty head";
|
|
480
|
+
this.postSystem(withNotes ? `${participant.name} was respawned while offline: it comes back with its notes.` : `${participant.name} was respawned while offline: it comes back knowing nothing from before.`);
|
|
470
481
|
this.push({ type: "participant", participant });
|
|
471
482
|
this.log.info(`respawn of ${participant.name} (offline): stored session dropped`);
|
|
472
483
|
return participant;
|
|
473
484
|
}
|
|
474
|
-
await this.reconnect(id,
|
|
475
|
-
|
|
485
|
+
await this.reconnect(id, memory
|
|
486
|
+
? { mode: "replay", replay, memory: withNotes, reason: `its context was cleared; it comes back with ${withNotes ? "its notes and " : ""}the last ${replay} messages` }
|
|
487
|
+
: { mode: "replay", replay: 0, reason: "its context was cleared, it remembers nothing from before" });
|
|
488
|
+
this.log.info(`respawn of ${participant.name}: fresh session, ${memory ? `${withNotes ? "notes + " : ""}replay ${replay}` : "no replay"}`);
|
|
476
489
|
return participant;
|
|
477
490
|
}
|
|
491
|
+
updateNotes(id, notes) {
|
|
492
|
+
const participant = this.participants.get(id);
|
|
493
|
+
if (!participant || participant.kind !== "agent")
|
|
494
|
+
throw new Error("no such agent");
|
|
495
|
+
const text = notes.trim().slice(0, 4000);
|
|
496
|
+
participant.notes = text || undefined;
|
|
497
|
+
participant.notesAt = text ? this.runtimes.get(id)?.lastUsed ?? participant.notesAt : undefined;
|
|
498
|
+
participant.notesSeq = text ? this.seq : undefined;
|
|
499
|
+
this.push({ type: "participant", participant });
|
|
500
|
+
return participant;
|
|
501
|
+
}
|
|
502
|
+
async takeNotes(id) {
|
|
503
|
+
const participant = this.participants.get(id);
|
|
504
|
+
const runtime = this.runtimes.get(id);
|
|
505
|
+
if (!participant || participant.kind !== "agent")
|
|
506
|
+
throw new Error("no such agent");
|
|
507
|
+
if (!runtime || !runtime.agent.alive)
|
|
508
|
+
throw new Error(`${participant.name} is not online`);
|
|
509
|
+
if (runtime.turnActive)
|
|
510
|
+
throw new Error(`${participant.name} is in the middle of a reply; try again when it is idle`);
|
|
511
|
+
const header = buildHeader(this.effectiveSettings(), this.personaOf(participant), this.roster(), this.hops, ["hidden turn: notes only, nothing is posted"]);
|
|
512
|
+
runtime.log.info("notes: hidden turn");
|
|
513
|
+
await this.executeTurn(participant, runtime, [{ type: "text", text: `${header}\n\n${NOTES_ONLY_PROMPT}` }], null, true);
|
|
514
|
+
return participant;
|
|
515
|
+
}
|
|
516
|
+
keepNotes(participant, runtime, notes, via) {
|
|
517
|
+
if (!notes)
|
|
518
|
+
return false;
|
|
519
|
+
participant.notes = notes.slice(0, 4000);
|
|
520
|
+
participant.notesAt = runtime.lastUsed;
|
|
521
|
+
participant.notesSeq = this.seq;
|
|
522
|
+
runtime.notesDue = false;
|
|
523
|
+
runtime.notesMisses = 0;
|
|
524
|
+
this.push({ type: "participant", participant });
|
|
525
|
+
runtime.log.info(`notes: ${notes.split("\n").length} lines kept (${via}, context ${runtime.lastUsed})`);
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
contextFull(participant, runtime, detail) {
|
|
529
|
+
participant.contextEvent = { kind: "full", at: Date.now(), used: runtime.lastUsed, size: participant.contextSize };
|
|
530
|
+
const recently = participant.autoRespawnAt !== undefined && Date.now() - participant.autoRespawnAt < 10 * 60_000;
|
|
531
|
+
if (recently) {
|
|
532
|
+
participant.status = "error";
|
|
533
|
+
participant.statusDetail = "its context filled up again right after a respawn; it needs you (respawn it by hand, with fewer replayed messages)";
|
|
534
|
+
this.push({ type: "participant", participant });
|
|
535
|
+
this.postSystem(`${participant.name} ran out of context again (${detail.slice(0, 120)}); it was respawned once already and now needs you.`, "human");
|
|
536
|
+
runtime.log.warn(`context full again within 10 minutes: no automatic respawn`);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
participant.autoRespawnAt = Date.now();
|
|
540
|
+
this.push({ type: "participant", participant });
|
|
541
|
+
const memory = !!participant.notes;
|
|
542
|
+
this.postSystem(`${participant.name} ran out of context (${detail.slice(0, 120)}); it is respawned ${memory ? `with its notes and the last ${this.settings.replayAfterRestart} messages` : `with the last ${this.settings.replayAfterRestart} messages (it had no notes)`}.`, "human");
|
|
543
|
+
runtime.log.warn(`context full: ${detail}; respawn with ${memory ? "notes" : "no notes"}`);
|
|
544
|
+
setImmediate(() => {
|
|
545
|
+
void (memory ? this.respawnAgent(participant.id, { memory: true }) : this.reconnectAfterFull(participant.id)).catch((error) => this.notice(`${participant.name}: respawn after a full context failed: ${describeError(error)}`, "error"));
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
async reconnectAfterFull(id) {
|
|
549
|
+
const participant = this.participants.get(id);
|
|
550
|
+
if (!participant)
|
|
551
|
+
return;
|
|
552
|
+
this.dropScheduledTurn(id);
|
|
553
|
+
this.cancelPermissionsOf(id);
|
|
554
|
+
if (this.runtimes.has(id))
|
|
555
|
+
await this.retireRuntime(id);
|
|
556
|
+
participant.sessionId = undefined;
|
|
557
|
+
await this.reconnect(id, { mode: "replay", reason: "its context was full; it comes back with the last messages" });
|
|
558
|
+
}
|
|
478
559
|
async retireRuntime(id) {
|
|
479
560
|
const runtime = this.runtimes.get(id);
|
|
480
561
|
const participant = this.participants.get(id);
|
|
@@ -973,6 +1054,11 @@ export class Room extends EventEmitter {
|
|
|
973
1054
|
briefPending: null,
|
|
974
1055
|
headerNotes: [],
|
|
975
1056
|
briefRequestedAtSeq: -1,
|
|
1057
|
+
notesDue: false,
|
|
1058
|
+
notesMisses: 0,
|
|
1059
|
+
notesAskedThisTurn: false,
|
|
1060
|
+
lastBriefSeq: -1,
|
|
1061
|
+
notesForBrief: reconnectOptions?.memory && participant.notes ? participant.notes : null,
|
|
976
1062
|
replayOwnUntilSeq: fresh || origin === "loaded" ? -1 : this.seq,
|
|
977
1063
|
delayTimer: null,
|
|
978
1064
|
addressed: false,
|
|
@@ -1597,6 +1683,11 @@ export class Room extends EventEmitter {
|
|
|
1597
1683
|
briefReason = `${tokensSinceBrief} tokens since last brief`;
|
|
1598
1684
|
const notes = [...runtime.headerNotes];
|
|
1599
1685
|
runtime.headerNotes = [];
|
|
1686
|
+
if (briefReason && overThreshold(runtime.lastUsed, participant.contextSize ?? 0))
|
|
1687
|
+
runtime.notesDue = true;
|
|
1688
|
+
runtime.notesAskedThisTurn = runtime.notesDue;
|
|
1689
|
+
if (runtime.notesDue)
|
|
1690
|
+
notes.push(NOTES_REQUEST);
|
|
1600
1691
|
if (briefReason && runtime.firstTurnDone) {
|
|
1601
1692
|
if (briefReason.startsWith("requested"))
|
|
1602
1693
|
notes.push("full brief re-sent as requested");
|
|
@@ -1618,7 +1709,7 @@ export class Room extends EventEmitter {
|
|
|
1618
1709
|
return m.images.map((a, i) => ({ n: a.n ?? i + 1, ref: `#${m.seq}.${a.n ?? i + 1}`, name: a.name, path: this.imagePath(a), mimeType: a.mimeType, attached, forNames: m.to.length ? m.toNames : [] }));
|
|
1619
1710
|
};
|
|
1620
1711
|
const prompt = composePrompt({
|
|
1621
|
-
brief: briefReason ? buildBrief(settings, persona, roster, undefined, skillsForPrompt) : undefined,
|
|
1712
|
+
brief: briefReason ? buildBrief(settings, persona, roster, runtime.notesForBrief ?? (participant.notes && (participant.notesSeq ?? -1) >= runtime.lastBriefSeq ? participant.notes : undefined), skillsForPrompt) : undefined,
|
|
1622
1713
|
header: buildHeader(settings, persona, roster, this.hops, notes, skillsForPrompt),
|
|
1623
1714
|
skills: attached.map((s) => composeSkillBlock({ name: s.name, text: s.text, invokedBy: s.invokedBy, extraFiles: s.extraFiles })),
|
|
1624
1715
|
backlog: unread.map((m) => m.kind === "system"
|
|
@@ -1637,6 +1728,8 @@ export class Room extends EventEmitter {
|
|
|
1637
1728
|
runtime.briefPending = null;
|
|
1638
1729
|
runtime.turnsSinceBrief = 0;
|
|
1639
1730
|
runtime.briefSentThisTurn = true;
|
|
1731
|
+
runtime.lastBriefSeq = this.seq;
|
|
1732
|
+
runtime.notesForBrief = null;
|
|
1640
1733
|
participant.briefsSent = (participant.briefsSent ?? 0) + 1;
|
|
1641
1734
|
}
|
|
1642
1735
|
runtime.firstTurnDone = true;
|
|
@@ -1664,7 +1757,7 @@ export class Room extends EventEmitter {
|
|
|
1664
1757
|
}
|
|
1665
1758
|
return blocks;
|
|
1666
1759
|
}
|
|
1667
|
-
async executeTurn(participant, runtime, blocks, retry) {
|
|
1760
|
+
async executeTurn(participant, runtime, blocks, retry, hidden = false) {
|
|
1668
1761
|
const id = participant.id;
|
|
1669
1762
|
runtime.turnActive = true;
|
|
1670
1763
|
participant.status = "thinking";
|
|
@@ -1684,7 +1777,7 @@ export class Room extends EventEmitter {
|
|
|
1684
1777
|
toolCalls: [],
|
|
1685
1778
|
};
|
|
1686
1779
|
this.drafts.set(draft.id, draft);
|
|
1687
|
-
runtime.turn = { message: draft, messageId: null, sawMessageId: false, startedAt: Date.now(), published: false };
|
|
1780
|
+
runtime.turn = { message: draft, messageId: null, sawMessageId: false, startedAt: Date.now(), published: false, hidden };
|
|
1688
1781
|
let result = null;
|
|
1689
1782
|
let failure = null;
|
|
1690
1783
|
try {
|
|
@@ -1696,6 +1789,7 @@ export class Room extends EventEmitter {
|
|
|
1696
1789
|
const startedAt = runtime.turn.startedAt;
|
|
1697
1790
|
const published = runtime.turn.published;
|
|
1698
1791
|
const publishedAt = runtime.turn.publishedAt ?? null;
|
|
1792
|
+
this.lastTurnHidden = !!runtime.turn.hidden;
|
|
1699
1793
|
runtime.turn = null;
|
|
1700
1794
|
runtime.turnActive = false;
|
|
1701
1795
|
this.drafts.delete(draft.id);
|
|
@@ -1719,10 +1813,13 @@ export class Room extends EventEmitter {
|
|
|
1719
1813
|
this.push({ type: "participant", participant });
|
|
1720
1814
|
this.push({ type: "message.removed", id: draft.id });
|
|
1721
1815
|
this.notice(`${participant.name}: turn failed: ${failure ?? "no result"}`, "error");
|
|
1722
|
-
this.postSystem(`${participant.name} could not answer: ${(failure ?? "no result").slice(0, 240)}`);
|
|
1723
1816
|
runtime.log.error(`turn failed: ${failure}`);
|
|
1724
1817
|
if (retry)
|
|
1725
1818
|
this.closeRetry(retry, "the correction turn failed; nothing was posted");
|
|
1819
|
+
if (isContextFullError(failure))
|
|
1820
|
+
this.contextFull(participant, runtime, failure ?? "");
|
|
1821
|
+
else
|
|
1822
|
+
this.postSystem(`${participant.name} could not answer: ${(failure ?? "no result").slice(0, 240)}`);
|
|
1726
1823
|
return null;
|
|
1727
1824
|
}
|
|
1728
1825
|
return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry, published, publishedAt);
|
|
@@ -1730,10 +1827,36 @@ export class Room extends EventEmitter {
|
|
|
1730
1827
|
finalizeTurn(participant, runtime, draft, result, durationMs, retry, published, publishedAt = null) {
|
|
1731
1828
|
participant.status = "idle";
|
|
1732
1829
|
this.push({ type: "participant", participant });
|
|
1733
|
-
const text = draft.text.trim();
|
|
1734
1830
|
const cancelled = result.stopReason === "cancelled";
|
|
1735
1831
|
if (cancelled)
|
|
1736
1832
|
runtime.briefPending = runtime.briefPending ?? "previous turn was cancelled";
|
|
1833
|
+
const extracted = extractNotes(draft.text);
|
|
1834
|
+
const hadNotes = this.keepNotes(participant, runtime, extracted.notes, runtime.turn?.hidden || draft.streaming === undefined ? "hidden turn" : "reply");
|
|
1835
|
+
draft.text = extracted.visible;
|
|
1836
|
+
const text = draft.text.trim();
|
|
1837
|
+
if (this.lastTurnHidden) {
|
|
1838
|
+
this.lastTurnHidden = false;
|
|
1839
|
+
this.commit({
|
|
1840
|
+
id: randomUUID(),
|
|
1841
|
+
seq: ++this.seq,
|
|
1842
|
+
from: participant.id,
|
|
1843
|
+
fromName: participant.name,
|
|
1844
|
+
to: [],
|
|
1845
|
+
toNames: [],
|
|
1846
|
+
text: hadNotes ? `took notes (${(participant.notes ?? "").split("\n").length} lines)` : "was asked for notes and gave none",
|
|
1847
|
+
ts: Date.now(),
|
|
1848
|
+
kind: "hidden",
|
|
1849
|
+
details: { original: draft.text, outcome: hadNotes ? "notes kept" : "no <notes> block in the reply" },
|
|
1850
|
+
});
|
|
1851
|
+
return null;
|
|
1852
|
+
}
|
|
1853
|
+
if (runtime.notesAskedThisTurn && !hadNotes && !retry && !cancelled) {
|
|
1854
|
+
runtime.notesMisses += 1;
|
|
1855
|
+
if (runtime.notesMisses >= 2) {
|
|
1856
|
+
runtime.notesMisses = 0;
|
|
1857
|
+
setImmediate(() => void this.takeNotes(participant.id).catch((error) => runtime.log.warn(`notes: hidden turn failed: ${describeError(error)}`)));
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1737
1860
|
if (!retry && text.toLowerCase() === REQUEST_BRIEF_MARKER) {
|
|
1738
1861
|
this.push({ type: "message.removed", id: draft.id });
|
|
1739
1862
|
if (runtime.briefRequestedAtSeq === runtime.lastSeenSeq) {
|
|
@@ -1765,7 +1888,8 @@ export class Room extends EventEmitter {
|
|
|
1765
1888
|
}
|
|
1766
1889
|
return null;
|
|
1767
1890
|
}
|
|
1768
|
-
|
|
1891
|
+
const bareContextFull = isBareContextFullError(text);
|
|
1892
|
+
if (!(draft.toolCalls?.length) && (ADAPTER_ERROR_PATTERN.test(text) || bareContextFull)) {
|
|
1769
1893
|
this.push({ type: "message.removed", id: draft.id });
|
|
1770
1894
|
participant.failedTurns = (participant.failedTurns ?? 0) + 1;
|
|
1771
1895
|
participant.statusDetail = `agent error: ${text.replace(/\s+/g, " ").slice(0, 120)}${text.length > 120 ? "…" : ""}`;
|
|
@@ -1774,6 +1898,8 @@ export class Room extends EventEmitter {
|
|
|
1774
1898
|
runtime.log.warn(`adapter error text treated as failed turn: ${text.slice(0, 200)}`);
|
|
1775
1899
|
if (retry)
|
|
1776
1900
|
this.closeRetry(retry, "the agent reported an error instead of a corrected reply");
|
|
1901
|
+
if (bareContextFull)
|
|
1902
|
+
this.contextFull(participant, runtime, text);
|
|
1777
1903
|
return null;
|
|
1778
1904
|
}
|
|
1779
1905
|
const mentions = this.parseMentions(text);
|
|
@@ -1907,7 +2033,7 @@ export class Room extends EventEmitter {
|
|
|
1907
2033
|
return corrections;
|
|
1908
2034
|
}
|
|
1909
2035
|
showDraft(turn) {
|
|
1910
|
-
if (turn.published)
|
|
2036
|
+
if (turn.published || turn.hidden)
|
|
1911
2037
|
return;
|
|
1912
2038
|
turn.published = true;
|
|
1913
2039
|
turn.publishedAt = Date.now();
|
|
@@ -1944,13 +2070,15 @@ export class Room extends EventEmitter {
|
|
|
1944
2070
|
if (messageId)
|
|
1945
2071
|
turn.sawMessageId = true;
|
|
1946
2072
|
turn.messageId = messageId;
|
|
2073
|
+
const shown = visibleChunk(turn.message.text, text);
|
|
1947
2074
|
turn.message.text += text;
|
|
1948
2075
|
if (!turn.published) {
|
|
1949
|
-
if (!looksSilent(turn.message.text))
|
|
2076
|
+
if (!looksSilent(turn.message.text) && shown)
|
|
1950
2077
|
this.showDraft(turn);
|
|
1951
2078
|
return;
|
|
1952
2079
|
}
|
|
1953
|
-
|
|
2080
|
+
if (shown)
|
|
2081
|
+
this.push({ type: "chunk", id: turn.message.id, text: shown });
|
|
1954
2082
|
return;
|
|
1955
2083
|
}
|
|
1956
2084
|
case "agent_thought_chunk": {
|
|
@@ -2006,6 +2134,15 @@ export class Room extends EventEmitter {
|
|
|
2006
2134
|
if (runtime.lastUsed > 0 && u.used < runtime.lastUsed * 0.7 && !runtime.briefPending) {
|
|
2007
2135
|
runtime.briefPending = `context shrank from ${runtime.lastUsed} to ${u.used} tokens (compaction?)`;
|
|
2008
2136
|
runtime.log.info(`usage dropped ${runtime.lastUsed} -> ${u.used}; brief scheduled`);
|
|
2137
|
+
participant.contextEvent = { kind: "compacted", at: Date.now(), used: u.used, size: u.size };
|
|
2138
|
+
runtime.notesDue = overThreshold(u.used, u.size);
|
|
2139
|
+
this.postSystem(`${participant.name} compacted its context (${Math.round(runtime.lastUsed / 1000)}k → ${Math.round(u.used / 1000)}k tokens); the room rules are re-sent with its next turn${participant.notes ? ", with its notes" : ""}.`, "human");
|
|
2140
|
+
}
|
|
2141
|
+
if (crossedThreshold(runtime.lastUsed, u.used, u.size)) {
|
|
2142
|
+
runtime.notesDue = true;
|
|
2143
|
+
participant.contextEvent = { kind: "threshold", at: Date.now(), used: u.used, size: u.size };
|
|
2144
|
+
this.postSystem(`${participant.name} is at ${Math.round((100 * u.used) / u.size)}% of its context; it will leave notes with its next reply. You can respawn it with memory from its panel.`, "human");
|
|
2145
|
+
runtime.log.info(`context at ${u.used}/${u.size}: notes due`);
|
|
2009
2146
|
}
|
|
2010
2147
|
runtime.lastUsed = u.used;
|
|
2011
2148
|
this.push({ type: "participant", participant });
|
package/dist/server.js
CHANGED
|
@@ -496,15 +496,21 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
496
496
|
sendJson(res, 200, { ok: true, ...result });
|
|
497
497
|
return;
|
|
498
498
|
}
|
|
499
|
-
const participantAction = path.match(/^\/api\/rooms\/([^/]+)\/participants\/([^/]+)\/(cancel|remove|config|persona|reconnect|mute|unmute|respawn|staff)$/);
|
|
499
|
+
const participantAction = path.match(/^\/api\/rooms\/([^/]+)\/participants\/([^/]+)\/(cancel|remove|config|persona|reconnect|mute|unmute|respawn|staff|notes|take-notes)$/);
|
|
500
500
|
if (participantAction) {
|
|
501
501
|
const room = hub.getRoom(decodeURIComponent(participantAction[1]));
|
|
502
502
|
const id = decodeURIComponent(participantAction[2]);
|
|
503
503
|
const action = participantAction[3];
|
|
504
504
|
if (action === "cancel")
|
|
505
505
|
room.cancelTurn(id);
|
|
506
|
-
else if (action === "respawn")
|
|
507
|
-
|
|
506
|
+
else if (action === "respawn") {
|
|
507
|
+
const replay = body.replay === undefined || body.replay === null || body.replay === "" ? undefined : Number(body.replay);
|
|
508
|
+
await room.respawnAgent(id, { memory: body.memory === true || body.memory === "true", replay: replay !== undefined && Number.isFinite(replay) ? Math.max(0, Math.min(500, Math.round(replay))) : undefined });
|
|
509
|
+
}
|
|
510
|
+
else if (action === "notes")
|
|
511
|
+
room.updateNotes(id, String(body.notes ?? ""));
|
|
512
|
+
else if (action === "take-notes")
|
|
513
|
+
await room.takeNotes(id);
|
|
508
514
|
else if (action === "staff") {
|
|
509
515
|
const participant = await room.staff(id, {
|
|
510
516
|
agentType: String(body.agentType ?? ""),
|
package/dist/tui.js
CHANGED
|
@@ -72,6 +72,68 @@ export function renderDone(choice, title, opts, items = MENU) {
|
|
|
72
72
|
export function menuLineCount(items = MENU) {
|
|
73
73
|
return items.length + 5;
|
|
74
74
|
}
|
|
75
|
+
export function renderInstalled(o, opts) {
|
|
76
|
+
const g = opts.unicode ? GLYPHS.unicode : GLYPHS.ascii;
|
|
77
|
+
const dim = (t) => paint(opts.color, "2", t);
|
|
78
|
+
const bold = (t) => paint(opts.color, "1", t);
|
|
79
|
+
const green = (t) => paint(opts.color, "32", t);
|
|
80
|
+
const bar = paint(opts.color, "36", g.bar);
|
|
81
|
+
const steps = o.platform === "win32"
|
|
82
|
+
? [
|
|
83
|
+
`Press the ${bold("Windows key")}, type ${bold("viberoom")}, press Enter.`,
|
|
84
|
+
`Or double-click ${bold("viberoom")} on the Desktop.`,
|
|
85
|
+
`Pin it: once the window is open, right-click its icon in the taskbar and choose "Pin to taskbar".`,
|
|
86
|
+
]
|
|
87
|
+
: o.platform === "darwin"
|
|
88
|
+
? [
|
|
89
|
+
`Press ${bold("⌘ Space")}, type ${bold("viberoom")}, press Enter (Spotlight); or open it from Launchpad.`,
|
|
90
|
+
`It lives in ${bold("~/Applications/viberoom.app")}; drag it to the Dock to keep it there.`,
|
|
91
|
+
`If macOS asks whether to open it the first time, choose Open: the app was made on this machine.`,
|
|
92
|
+
]
|
|
93
|
+
: [
|
|
94
|
+
`Press the ${bold("Super key")}, type ${bold("viberoom")}, press Enter; it is in the applications menu.`,
|
|
95
|
+
`On the Desktop: right-click ${bold("viberoom.desktop")} and choose "Allow launching" once if your desktop asks.`,
|
|
96
|
+
`Pin it: right-click the running icon in the dock and choose "Add to favorites" (or your desktop's equivalent).`,
|
|
97
|
+
];
|
|
98
|
+
const lines = [
|
|
99
|
+
`${dim(g.top)} ${bold("The desktop icon is installed")}`,
|
|
100
|
+
dim(g.bar),
|
|
101
|
+
`${green(g.done)} ${bold("How to start viberoom from now on")}`,
|
|
102
|
+
...steps.map((t) => `${bar} ${t}`),
|
|
103
|
+
bar,
|
|
104
|
+
`${green(g.done)} ${bold("What happens")}`,
|
|
105
|
+
`${bar} The icon starts the hub in the background and opens the app window.`,
|
|
106
|
+
`${bar} Closing the window keeps the hub running; "viberoom stop" in a terminal ends it.`,
|
|
107
|
+
`${bar} A newer version: the app tells you with a bubble over your avatar (Settings → Updates).`,
|
|
108
|
+
];
|
|
109
|
+
if (o.browserAdvice)
|
|
110
|
+
lines.push(bar, `${paint(opts.color, "33", "!")} ${o.browserAdvice}`);
|
|
111
|
+
if (o.files.length || o.notes.length) {
|
|
112
|
+
lines.push(bar, `${green(g.done)} ${bold("Written")}`);
|
|
113
|
+
for (const f of o.files)
|
|
114
|
+
lines.push(`${bar} ${dim(f)}`);
|
|
115
|
+
for (const n of o.notes)
|
|
116
|
+
lines.push(`${bar} ${dim(n)}`);
|
|
117
|
+
}
|
|
118
|
+
lines.push(bar, `${dim(g.bottom)} ${dim("Press Enter to open viberoom now, or q to leave it for later.")}`);
|
|
119
|
+
return lines.join("\n") + "\n";
|
|
120
|
+
}
|
|
121
|
+
export function askEnter(stdin = process.stdin, stdout = process.stdout) {
|
|
122
|
+
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function")
|
|
123
|
+
return Promise.resolve(false);
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
const onData = (data) => {
|
|
126
|
+
stdin.off("data", onData);
|
|
127
|
+
stdin.setRawMode(false);
|
|
128
|
+
stdin.pause();
|
|
129
|
+
const s = data.toString();
|
|
130
|
+
resolve(s === "\r" || s === "\n");
|
|
131
|
+
};
|
|
132
|
+
stdin.setRawMode(true);
|
|
133
|
+
stdin.resume();
|
|
134
|
+
stdin.on("data", onData);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
75
137
|
export function runMenu(title, stdin = process.stdin, stdout = process.stdout) {
|
|
76
138
|
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function")
|
|
77
139
|
return Promise.resolve(null);
|
package/package.json
CHANGED
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
{
|
|
14
14
|
"name": "Proposer",
|
|
15
15
|
"tagline": "makes the strongest case for a design",
|
|
16
|
-
"role": "You propose designs and defend them with evidence from the code. State the approach, its cost, and what it makes easy. When the Skeptic finds a real hole, concede it and adjust the proposal rather than restating it. You read code; you never edit it."
|
|
16
|
+
"role": "You propose designs and defend them with evidence from the code. State the approach, its cost, and what it makes easy. When the Skeptic finds a real hole, concede it and adjust the proposal rather than restating it. You read code; you never edit it.",
|
|
17
|
+
"avatar": "🎨"
|
|
17
18
|
},
|
|
18
19
|
{
|
|
19
20
|
"name": "Skeptic",
|
|
20
21
|
"tagline": "finds what breaks it",
|
|
21
|
-
"role": "You look for what breaks a proposed design: the edge case, the migration cost, the thing that exists in the code and contradicts the plan. Name files and lines. Be specific and brief; a hole is worth more than a list of doubts. When a proposal survives, say so. You read code; you never edit it."
|
|
22
|
+
"role": "You look for what breaks a proposed design: the edge case, the migration cost, the thing that exists in the code and contradicts the plan. Name files and lines. Be specific and brief; a hole is worth more than a list of doubts. When a proposal survives, say so. You read code; you never edit it.",
|
|
23
|
+
"avatar": "🧐"
|
|
22
24
|
}
|
|
23
25
|
]
|
|
24
26
|
}
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
{
|
|
12
12
|
"name": "Explainer",
|
|
13
13
|
"tagline": "explains the code, never touches it",
|
|
14
|
-
"role": "You explain how this codebase works. Read whatever you need, then answer in plain language with file paths and line numbers, quoting the code when it settles a question. Structure long answers as a short walk through the flow. You never edit, create or delete files."
|
|
14
|
+
"role": "You explain how this codebase works. Read whatever you need, then answer in plain language with file paths and line numbers, quoting the code when it settles a question. Structure long answers as a short walk through the flow. You never edit, create or delete files.",
|
|
15
|
+
"avatar": "🎓"
|
|
15
16
|
}
|
|
16
17
|
]
|
|
17
18
|
}
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
{
|
|
12
12
|
"name": "Pair",
|
|
13
13
|
"tagline": "codes with you, one step at a time",
|
|
14
|
-
"role": "You are a pair programmer. The human drives: you read the code first, make one small change at a time, run or test it, and report what changed with file paths. Propose the next step; do not take it until asked. When unsure, ask one precise question rather than guessing."
|
|
14
|
+
"role": "You are a pair programmer. The human drives: you read the code first, make one small change at a time, run or test it, and report what changed with file paths. Propose the next step; do not take it until asked. When unsure, ask one precise question rather than guessing.",
|
|
15
|
+
"avatar": "🤝"
|
|
15
16
|
}
|
|
16
17
|
]
|
|
17
18
|
}
|
|
@@ -11,18 +11,20 @@
|
|
|
11
11
|
"turnTaking": "parallel",
|
|
12
12
|
"agentsWakeEachOther": true,
|
|
13
13
|
"hopLimit": 24,
|
|
14
|
-
"customRules": "Both build, both explain, both review; @ decides who does what. Without @, a task goes to the one who has worked on that part the most, or most recently; if neither has touched it, to Wren. A question without @ goes to Quinn.\nThe human decides when work is reviewed: ask the other one to review it. Unasked, the other answers questions and otherwise stays silent; it does not review, comment on or extend work it was not asked about. When asked to review, first say in one line what you will check, then check it by that criterion: a number, a live check, the risky case.
|
|
14
|
+
"customRules": "Both build, both explain, both review; @ decides who does what. Without @, a task goes to the one who has worked on that part the most, or most recently; if neither has touched it, to Wren. A question without @ goes to Quinn.\nThe human decides when work is reviewed: ask the other one to review it. Unasked, the other answers questions and otherwise stays silent; it does not review, comment on or extend work it was not asked about. When asked to review, first say in one line what you will check, then check it by that criterion: a number, a live check, the risky case. Whoever built reports to the human. Address the other vibemate too only when the report changes something it works on or relies on: a shared file, an interface, a convention, a measurement that overturns its claim; then say in one line what you want from it (\"nothing, for your context\" counts). Otherwise it reads the report later, unaddressed. A report is never a request for review; only the human asks for one. A build is reported as: what changed (the files, and the commit if the project uses version control), how to verify it (a command, a script, a screenshot), what was not verified, what is left.\nMeasure before diagnosing; say no with a reason and a cheaper alternative; keep replies short.\nWork only on your own task, never on the other's; do not touch what the other is working on.\nReply only when addressed. On a message to everyone, only the one it concerns answers; the other stays silent. Never add to, correct or comment on the other's reply unless the human asks.\nFollow the conventions of the project you work in (its instructions, notes, tests). Where it keeps decision notes, write the decision there before reporting it here.\nRead every proposal and analyse it; agree or reject only with a real argument. Never concede to be agreeable, never object to seem rigorous.\nReply in the human's language. Code identifiers, commands, file paths, protocol and tool names stay exactly as in the code; established technical terms (kernel, thread, commit, pull request, layout) stay in English."
|
|
15
15
|
},
|
|
16
16
|
"vibemates": [
|
|
17
17
|
{
|
|
18
18
|
"name": "Wren",
|
|
19
19
|
"tagline": "builds by default; explains and reviews when asked",
|
|
20
|
-
"role": "You are Wren, one of two equal vibemates, Wren and Quinn. You lean to building: a task without an address is yours when it touches what you have built, or when neither of you has; before you change anything, read what is already there, the notes, the docs and the code, and keep each change small. Everything else is in the room rules."
|
|
20
|
+
"role": "You are Wren, one of two equal vibemates, Wren and Quinn. You lean to building: a task without an address is yours when it touches what you have built, or when neither of you has; before you change anything, read what is already there, the notes, the docs and the code, and keep each change small. Everything else is in the room rules.",
|
|
21
|
+
"avatar": "🔨"
|
|
21
22
|
},
|
|
22
23
|
{
|
|
23
24
|
"name": "Quinn",
|
|
24
25
|
"tagline": "explains by default; builds and reviews when asked",
|
|
25
|
-
"role": "You are Quinn, one of two equal vibemates, Wren and Quinn. You lean to explaining: a question without an address is yours; when you review, it is by a criterion, not an impression. Everything else is in the room rules."
|
|
26
|
+
"role": "You are Quinn, one of two equal vibemates, Wren and Quinn. You lean to explaining: a question without an address is yours; when you review, it is by a criterion, not an impression. Everything else is in the room rules.",
|
|
27
|
+
"avatar": "💡"
|
|
26
28
|
}
|
|
27
29
|
]
|
|
28
30
|
}
|
package/ui/app.css
CHANGED
|
@@ -92,6 +92,49 @@
|
|
|
92
92
|
.side-label { padding: 12px 20px 6px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--faint); font-weight: 800; }
|
|
93
93
|
.side-list { list-style: none; margin: 0; padding: 4px 14px 10px; overflow-y: auto; flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 6px; }
|
|
94
94
|
.side-list li { display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 14px; cursor: pointer; transition: background var(--t-fast); animation: slide-in-left var(--t-base) var(--ease-out); }
|
|
95
|
+
.participants .avatar.has-life { cursor: pointer; }
|
|
96
|
+
.participants li { position: relative; }
|
|
97
|
+
.participants .p-gear { position: absolute; top: 4px; right: 6px; width: 24px; height: 24px; opacity: 0; transition: opacity var(--t-fast); }
|
|
98
|
+
.participants .p-gear .i { width: 14px; height: 14px; }
|
|
99
|
+
.participants li:hover .p-gear, .participants li.selected .p-gear { opacity: 1; }
|
|
100
|
+
.participants .life-q { position: absolute; top: -5px; right: -5px; width: 15px; height: 15px; border-radius: 50%; background: #fff; color: var(--muted); font-size: 10px; font-weight: 800; display: grid; place-items: center; box-shadow: var(--shadow-tile); }
|
|
101
|
+
.participants .avatar .life { position: absolute; inset: -4px; width: calc(100% + 8px); height: calc(100% + 8px); pointer-events: none; overflow: visible; }
|
|
102
|
+
.life path { fill: none; stroke-width: 3; }
|
|
103
|
+
.life .life-track { stroke: var(--softer); }
|
|
104
|
+
.life-unknown .life-track { stroke: var(--softer); stroke-width: 2; stroke-dasharray: 1 4; stroke-linecap: round; }
|
|
105
|
+
.life .life-arc { stroke-linecap: round; transition: stroke-dasharray var(--t-base) var(--ease-out), stroke var(--t-fast); }
|
|
106
|
+
.life-ok .life-arc, .life-fresh .life-arc { stroke: var(--st-ready-dot); }
|
|
107
|
+
.life-warm .life-arc { stroke: var(--st-thinking-dot); }
|
|
108
|
+
.life-hot .life-arc { stroke: var(--st-error-dot); }
|
|
109
|
+
.participants .avatar.life-attn .life-track { stroke: var(--attention); animation: pulse 1.2s ease-in-out infinite; }
|
|
110
|
+
.lp-event { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; color: var(--muted); }
|
|
111
|
+
.lp-event.fresh { color: var(--attention-ink); }
|
|
112
|
+
.lp-event .i { width: 14px; height: 14px; flex: none; }
|
|
113
|
+
.life-pop { position: fixed; z-index: 60; display: flex; gap: 6px; align-items: flex-start; width: 330px; padding: 12px 8px 12px 14px; background: #fff; border-radius: 6px 18px 18px 18px; box-shadow: var(--shadow-pop); font-size: 13px; animation: bubble-in var(--t-base) var(--ease-out); }
|
|
114
|
+
.life-pop::before { content: ""; position: absolute; left: -6px; top: 18px; width: 12px; height: 12px; background: #fff; border-radius: 0 0 0 3px; transform: rotate(45deg); }
|
|
115
|
+
.life-pop .lp-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
|
|
116
|
+
.life-pop .lp-side { flex: none; display: flex; flex-direction: column; gap: 2px; }
|
|
117
|
+
.life-pop .icon-btn.sm { width: 26px; height: 26px; }
|
|
118
|
+
.lp-head { display: flex; align-items: center; gap: 10px; }
|
|
119
|
+
.lp-head b { font-size: 14px; }
|
|
120
|
+
.lp-sub { color: var(--muted); font-size: 12px; font-weight: 600; margin-top: 2px; }
|
|
121
|
+
.lp-sub b { color: var(--ink); }
|
|
122
|
+
.life-bar { height: 6px; border-radius: 3px; background: var(--softer); overflow: hidden; }
|
|
123
|
+
.life-bar i { display: block; height: 100%; border-radius: 3px; transition: width var(--t-base) var(--ease-out); }
|
|
124
|
+
.life-bar.life-ok i, .life-bar.life-fresh i { background: var(--st-ready-dot); }
|
|
125
|
+
.life-bar.life-warm i { background: var(--st-thinking-dot); }
|
|
126
|
+
.life-bar.life-hot i { background: var(--st-error-dot); }
|
|
127
|
+
.life-bar.life-unknown { background: repeating-linear-gradient(90deg, var(--softer) 0 4px, transparent 4px 7px); }
|
|
128
|
+
.lp-kv { font-size: 12.5px; }
|
|
129
|
+
.lp-kv .i { width: 12px; height: 12px; vertical-align: -2px; }
|
|
130
|
+
.lp-kv .link-btn { border: 0; background: none; padding: 0; font: inherit; font-weight: 700; color: var(--primary); cursor: pointer; }
|
|
131
|
+
.lp-kv .link-btn:disabled { color: var(--muted); cursor: default; }
|
|
132
|
+
.lp-editor { display: flex; flex-direction: column; gap: 6px; }
|
|
133
|
+
.lp-editor textarea { width: 100%; box-sizing: border-box; font: 600 12px/1.45 var(--font); }
|
|
134
|
+
.lp-notes { margin: 0; padding: 8px 10px; background: var(--soft); border-radius: 10px; font: 600 12px/1.45 var(--font); white-space: pre-wrap; max-height: 140px; overflow: auto; color: var(--ink-2); }
|
|
135
|
+
.lp-respawn { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
|
|
136
|
+
.lp-with { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--muted); }
|
|
137
|
+
.lp-with input { width: 58px; padding: 4px 6px; margin: 0; }
|
|
95
138
|
.side-list li:hover { background: var(--soft); }
|
|
96
139
|
.side-list li.selected { background: var(--lav); }
|
|
97
140
|
.side-list li.offline .avatar .av-tile { filter: grayscale(0.6); opacity: 0.8; }
|
|
@@ -206,6 +249,7 @@
|
|
|
206
249
|
.day { align-self: center; color: var(--faint); font-size: 11px; font-weight: 800; padding: 3px 12px; margin: 2px 0; }
|
|
207
250
|
.msg { display: flex; flex-direction: column; gap: 4px; align-items: flex-start; max-width: 100%; animation: bubble-in var(--t-base) var(--ease-out); }
|
|
208
251
|
.messages .msg { content-visibility: auto; contain-intrinsic-size: auto 120px; flex-shrink: 0; }
|
|
252
|
+
.messages .msgs-page { display: flex; flex-direction: column; gap: 14px; flex-shrink: 0; content-visibility: auto; contain-intrinsic-size: auto 6000px; }
|
|
209
253
|
.msg.agent { padding-right: 40px; }
|
|
210
254
|
.msg.mine { align-items: flex-end; padding-left: 40px; }
|
|
211
255
|
.head-av { display: inline-flex; width: 32px; height: 32px; padding: 4px; box-sizing: content-box; flex: none; }
|
|
@@ -214,6 +258,7 @@
|
|
|
214
258
|
.msg.mine .head { flex-direction: row-reverse; }
|
|
215
259
|
.msg.system { justify-content: center; }
|
|
216
260
|
.msg.hidden-by-search { display: none; }
|
|
261
|
+
.messages.searching .msgs-page { content-visibility: visible; }
|
|
217
262
|
.sys { color: var(--muted); font-size: 11px; font-weight: 700; padding: 2px 10px; max-width: 80%; text-align: center; }
|
|
218
263
|
.sys.warn { color: var(--warm-ink); background: var(--warm); border-radius: var(--r-pill); padding: 6px 12px; font-weight: 800; }
|
|
219
264
|
.bubble-col { display: flex; flex-direction: column; gap: 6px; min-width: 0; width: 100%; max-width: min(1040px, 100%); }
|
|
@@ -272,17 +317,17 @@
|
|
|
272
317
|
.jump-latest { position: absolute; right: 30px; bottom: 96px; z-index: 5; display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px 0 10px; border: 0; border-radius: var(--r-pill); background: var(--card); color: var(--primary); font: inherit; font-size: 12px; font-weight: 800; box-shadow: var(--shadow-pop); cursor: pointer; animation: rise var(--t-base) var(--ease-out); }
|
|
273
318
|
.jump-latest:hover { background: var(--soft); }
|
|
274
319
|
.done-notes { position: absolute; left: 30px; bottom: 96px; z-index: 5; display: flex; flex-direction: column-reverse; gap: 6px; }
|
|
275
|
-
.done-note { display: inline-flex; align-items: center; gap: 8px; max-width: 230px; padding: 6px 12px 6px 8px; border: 0; border-radius: 14px; background: var(--
|
|
276
|
-
.done-note:hover { background: var(--
|
|
320
|
+
.done-note { display: inline-flex; align-items: center; gap: 8px; max-width: 230px; padding: 6px 12px 6px 8px; border: 0; border-radius: 14px; background: var(--done); color: var(--done-ink); font: inherit; text-align: left; cursor: pointer; box-shadow: var(--shadow-pop); animation: rise var(--t-base) var(--ease-out); overflow: hidden; }
|
|
321
|
+
.done-note:hover { background: var(--done-hover); }
|
|
277
322
|
.done-note .avatar { flex: none; }
|
|
278
323
|
.done-go { display: inline-flex; align-items: center; gap: 8px; min-width: 0; padding: 6px 4px 6px 8px; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
|
279
|
-
.done-go:hover { background: var(--
|
|
324
|
+
.done-go:hover { background: var(--done-hover); }
|
|
280
325
|
.done-go .avatar { flex: none; }
|
|
281
326
|
.done-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.25; }
|
|
282
327
|
.done-text b { font-size: 12px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
283
328
|
.done-text small { font-size: 11px; font-weight: 600; opacity: 0.8; white-space: nowrap; }
|
|
284
329
|
.done-x { border: 0; background: transparent; color: inherit; opacity: 0.6; font-size: 15px; line-height: 1; padding: 0 9px; cursor: pointer; }
|
|
285
|
-
.done-x:hover { opacity: 1; background: var(--
|
|
330
|
+
.done-x:hover { opacity: 1; background: var(--done-hover); }
|
|
286
331
|
.jump-latest .i { width: 16px; height: 16px; }
|
|
287
332
|
.bubble { max-width: 100%; background: #fff; border-radius: 6px 18px 18px 18px; padding: 12px 16px; min-width: 0; font-size: 14.5px; font-weight: 600; line-height: 1.55; color: var(--ink-2); box-shadow: 0 2px 0 rgba(28, 27, 51, 0.06), 0 1px 3px rgba(28, 27, 51, 0.04); }
|
|
288
333
|
.msg.mine .bubble { background: var(--grad-primary); color: #fff; border-radius: 18px 18px 6px 18px; box-shadow: 0 8px 18px -10px rgba(91, 91, 240, 0.7); padding: 11px 16px; }
|
|
@@ -494,6 +539,7 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
|
|
|
494
539
|
.lightbox img { max-width: calc(100vw - 48px); max-height: calc(100vh - 48px); border-radius: 12px; box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.6); background: #fff; }
|
|
495
540
|
.composer textarea { flex: 1; display: block; box-sizing: border-box; resize: none; padding: 8px 0; margin: 0; border: 0; border-radius: 0; background: transparent; height: 36px; min-height: 36px; line-height: 20px; font-size: 14px; font-weight: 600; outline: none; color: var(--ink); overflow-y: auto; }
|
|
496
541
|
.composer textarea::placeholder { color: var(--placeholder); font-weight: 600; }
|
|
542
|
+
@supports (field-sizing: content) { .composer textarea { field-sizing: content; height: auto; } }
|
|
497
543
|
.send-btn { width: 44px; height: 44px; border-radius: 14px; border: 0; background: var(--grad-primary); color: #fff; display: grid; place-content: center; box-shadow: var(--shadow-primary); flex: none; transition: transform var(--t-fast) var(--ease-out), filter var(--t-fast); }
|
|
498
544
|
.send-btn .i { width: 18px; height: 18px; display: block; }
|
|
499
545
|
.composer-clear { width: 28px; height: 28px; border-radius: 50%; border: 0; background: transparent; color: var(--placeholder); font-size: 20px; line-height: 1; cursor: pointer; flex: none; margin-right: 2px; transition: background var(--t-fast), color var(--t-fast); }
|
|
@@ -630,8 +676,8 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
|
|
|
630
676
|
.composer.gated textarea { cursor: not-allowed; }
|
|
631
677
|
.check-list.locked { pointer-events: none; opacity: 0.6; }
|
|
632
678
|
.msg.system.done { justify-content: center; }
|
|
633
|
-
.done-row { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px 6px 8px; border: 0; border-radius: var(--r-pill); background: var(--
|
|
634
|
-
.done-row:hover { background: var(--
|
|
679
|
+
.done-row { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px 6px 8px; border: 0; border-radius: var(--r-pill); background: var(--done); color: var(--done-ink); font: inherit; font-size: 12px; font-weight: 800; cursor: pointer; box-shadow: var(--edge); }
|
|
680
|
+
.done-row:hover { background: var(--done-hover); }
|
|
635
681
|
.done-notes { flex-direction: column; }
|
|
636
682
|
.done-note.new { background: var(--card); color: var(--ink-2); }
|
|
637
683
|
.done-note.new:hover { background: var(--soft); }
|
package/ui/app.js
CHANGED
|
@@ -555,7 +555,7 @@
|
|
|
555
555
|
}
|
|
556
556
|
function fmtTokens(n) {
|
|
557
557
|
if (n === undefined || n === null) return "";
|
|
558
|
-
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
558
|
+
return n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
559
559
|
}
|
|
560
560
|
function fmtCost(cost) {
|
|
561
561
|
return cost ? `${cost.amount.toFixed(3)} ${cost.currency}` : "";
|
|
@@ -1170,7 +1170,7 @@
|
|
|
1170
1170
|
if (!li) {
|
|
1171
1171
|
li = document.createElement("li");
|
|
1172
1172
|
li.dataset.id = p.id;
|
|
1173
|
-
li.innerHTML = avatarHtml + bodyHtml;
|
|
1173
|
+
li.innerHTML = avatarHtml + bodyHtml + (p.kind === "agent" ? `<button type="button" class="icon-btn sm p-gear" title="Open ${esc(p.name)}'s panel">${ic("settings")}</button>` : "");
|
|
1174
1174
|
li.dataset.avatar = avatarHtml;
|
|
1175
1175
|
if (statusClass) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="${statusClass}"></span>`);
|
|
1176
1176
|
li.dataset.body = bodyHtml;
|
|
@@ -1189,10 +1189,12 @@
|
|
|
1189
1189
|
}
|
|
1190
1190
|
}
|
|
1191
1191
|
if (li.className !== className) li.className = className;
|
|
1192
|
+
if (p.kind === "agent" && !unstaffed) patchLifeRing(li, p);
|
|
1192
1193
|
if (li !== els.participants.children[ordered.indexOf(p)]) els.participants.appendChild(li);
|
|
1193
1194
|
rows.delete(p.id);
|
|
1194
1195
|
}
|
|
1195
1196
|
for (const li of rows.values()) li.remove();
|
|
1197
|
+
renderLifePop();
|
|
1196
1198
|
renderHushButton(room);
|
|
1197
1199
|
els.reconnectAllBtn.hidden = offlineAgents(room).length === 0;
|
|
1198
1200
|
}
|
|
@@ -1255,7 +1257,7 @@
|
|
|
1255
1257
|
if (d.skill) {
|
|
1256
1258
|
el.innerHTML = `
|
|
1257
1259
|
<details class="hidden-turn">
|
|
1258
|
-
<summary>${ic("skills")}
|
|
1260
|
+
<summary>${ic("skills")} room ↔ ${esc(m.fromName)} · ${esc(m.text)}${d.via ? ` (${esc(d.via)})` : ""}${d.outcome ? ` · <em>${esc(d.outcome)}</em>` : ""}</summary>
|
|
1259
1261
|
<div class="hidden-body">
|
|
1260
1262
|
${d.original ? `<div class="hidden-label">Held reply (nobody in the room saw it)</div><div class="hidden-text">${esc(d.original)}</div>` : ""}
|
|
1261
1263
|
<div class="hidden-label">What happened</div>
|
|
@@ -1266,7 +1268,7 @@
|
|
|
1266
1268
|
}
|
|
1267
1269
|
el.innerHTML = `
|
|
1268
1270
|
<details class="hidden-turn">
|
|
1269
|
-
<summary>${ic("tool")}
|
|
1271
|
+
<summary>${ic("tool")} room ↔ ${esc(m.fromName)} · ${esc(m.text)}${d.outcome ? ` · <em>${esc(d.outcome)}</em>` : " · <em>waiting for the corrected reply…</em>"}</summary>
|
|
1270
1272
|
<div class="hidden-body">
|
|
1271
1273
|
<div class="hidden-label">Held reply (nobody in the room saw it)</div>
|
|
1272
1274
|
<div class="hidden-text">${esc(d.original || "")}</div>
|
|
@@ -1574,9 +1576,29 @@
|
|
|
1574
1576
|
return el;
|
|
1575
1577
|
}
|
|
1576
1578
|
|
|
1579
|
+
const PAGE_SIZE = 50;
|
|
1580
|
+
function placeInList(el) {
|
|
1581
|
+
let page = els.messages.lastElementChild;
|
|
1582
|
+
if (!page || !page.classList.contains("msgs-page") || page.childElementCount >= PAGE_SIZE) {
|
|
1583
|
+
page = document.createElement("div");
|
|
1584
|
+
page.className = "msgs-page";
|
|
1585
|
+
els.messages.appendChild(page);
|
|
1586
|
+
}
|
|
1587
|
+
page.appendChild(el);
|
|
1588
|
+
}
|
|
1589
|
+
function topInList(el) {
|
|
1590
|
+
const page = el.parentElement;
|
|
1591
|
+
if (!page || !page.classList.contains("msgs-page")) return el.offsetTop;
|
|
1592
|
+
if (els.messages.classList.contains("searching") || page.firstElementChild.checkVisibility({ contentVisibilityAuto: true })) return page.offsetTop + el.offsetTop;
|
|
1593
|
+
let i = 0;
|
|
1594
|
+
for (let n = el.previousElementSibling; n; n = n.previousElementSibling) i++;
|
|
1595
|
+
return page.offsetTop + (page.offsetHeight * i) / page.childElementCount;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1577
1598
|
function renderMessages() {
|
|
1578
1599
|
const room = currentRoom();
|
|
1579
1600
|
els.messages.innerHTML = "";
|
|
1601
|
+
els.messages.classList.toggle("searching", !!state.search);
|
|
1580
1602
|
if (!room) return;
|
|
1581
1603
|
if (!room.messages.length) {
|
|
1582
1604
|
els.messages.innerHTML = `<div class="empty"><div class="art">${ic("chat")}</div><strong>${esc(room.name)}</strong> is quiet.<br>Summon a vibemate from the left, then say hello. Use @Name to address someone; without @ every vibemate hears you.</div>`;
|
|
@@ -1592,20 +1614,20 @@
|
|
|
1592
1614
|
d.className = "day";
|
|
1593
1615
|
d.textContent = day;
|
|
1594
1616
|
d.title = new Date(m.ts).toLocaleDateString([], { weekday: "long", day: "numeric", month: "long", year: "numeric" });
|
|
1595
|
-
|
|
1617
|
+
placeInList(d);
|
|
1596
1618
|
lastDay = day;
|
|
1597
1619
|
}
|
|
1598
1620
|
for (const [seq, agents] of markers) {
|
|
1599
1621
|
if (placed.has(seq) || !(m.seq >= seq)) continue;
|
|
1600
1622
|
if (m.seq > 0) {
|
|
1601
1623
|
placed.add(seq);
|
|
1602
|
-
|
|
1624
|
+
placeInList(dividerElement(agents));
|
|
1603
1625
|
}
|
|
1604
1626
|
}
|
|
1605
|
-
|
|
1627
|
+
placeInList(messageElement(room, m));
|
|
1606
1628
|
}
|
|
1607
1629
|
for (const [seq, agents] of markers) {
|
|
1608
|
-
if (!placed.has(seq))
|
|
1630
|
+
if (!placed.has(seq)) placeInList(dividerElement(agents));
|
|
1609
1631
|
}
|
|
1610
1632
|
for (const perm of room.permissions) renderPermission(room, perm);
|
|
1611
1633
|
refreshSeen(room);
|
|
@@ -1638,7 +1660,7 @@
|
|
|
1638
1660
|
else {
|
|
1639
1661
|
const empty = els.messages.querySelector(".empty");
|
|
1640
1662
|
if (empty) empty.remove();
|
|
1641
|
-
|
|
1663
|
+
placeInList(messageElement(room, m));
|
|
1642
1664
|
if (m.from === "human") refreshSeen(room);
|
|
1643
1665
|
if (m.streaming && m.from !== "human") renderSideRoom();
|
|
1644
1666
|
else if (!stick && m.kind === "chat") noteNew(room, m);
|
|
@@ -1917,7 +1939,8 @@
|
|
|
1917
1939
|
<div class="section danger">
|
|
1918
1940
|
${sectionTitle("bolt", "Respawn")}
|
|
1919
1941
|
<p class="hint">${esc(p.name)} comes back with an empty head: it forgets this conversation entirely. The room's history stays and you still see everything.${geekTip("A session's context cannot be erased, so the vibemate's process and session are closed and it starts a new one with no replay. Its stored session is dropped too, or a later reconnect would bring the old context back. Same thing as typing /respawn @Name in the composer.")}</p>
|
|
1920
|
-
<div class="row-btns start"><button class="btn danger sm" data-act="respawn">${ic("bolt")}Respawn ${esc(p.name)}</button></div>
|
|
1942
|
+
<div class="row-btns start"><button class="btn danger sm" data-act="respawn">${ic("bolt")}Respawn ${esc(p.name)}</button><label class="lp-with"><button type="button" class="btn sm" data-act="respawn-mem">With the last</button><input type="number" id="pp-respawn-n" min="0" max="500" value="${room.settings.replayAfterRestart ?? 10}"> messages</label></div>
|
|
1943
|
+
<p class="hint">With memory: a new session that gets only ${p.notes ? "its own notes and " : ""}the last N messages of this room; the rest is gone.</p>
|
|
1921
1944
|
</div>
|
|
1922
1945
|
<div class="section">
|
|
1923
1946
|
${sectionTitle("info", "Stats")}
|
|
@@ -1937,17 +1960,9 @@
|
|
|
1937
1960
|
)}`;
|
|
1938
1961
|
wireDetailsClose();
|
|
1939
1962
|
const respawnBtn = els.detailsInner.querySelector('button[data-act="respawn"]');
|
|
1940
|
-
if (respawnBtn)
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
if (!ok) return;
|
|
1944
|
-
try {
|
|
1945
|
-
await post(roomApi(`/participants/${encodeURIComponent(p.id)}/respawn`));
|
|
1946
|
-
} catch (e) {
|
|
1947
|
-
showError(e);
|
|
1948
|
-
}
|
|
1949
|
-
});
|
|
1950
|
-
}
|
|
1963
|
+
if (respawnBtn) respawnBtn.addEventListener("click", () => respawnWith(p, 0));
|
|
1964
|
+
const respawnMem = els.detailsInner.querySelector('button[data-act="respawn-mem"]');
|
|
1965
|
+
if (respawnMem) respawnMem.addEventListener("click", () => respawnWith(p, Number($("#pp-respawn-n").value) || 0));
|
|
1951
1966
|
els.detailsInner.querySelectorAll(".action").forEach((btn) => {
|
|
1952
1967
|
btn.addEventListener("click", async () => {
|
|
1953
1968
|
const act = btn.dataset.act;
|
|
@@ -3450,21 +3465,33 @@
|
|
|
3450
3465
|
|
|
3451
3466
|
let composerMin = Number(recall("composerH")) || 0;
|
|
3452
3467
|
const composerCeiling = () => Math.max(120, els.app.clientHeight - 260);
|
|
3468
|
+
const fieldSizing = CSS.supports("field-sizing", "content");
|
|
3453
3469
|
let autosizeQueued = false;
|
|
3454
3470
|
function autosizeSoon() {
|
|
3455
|
-
if (autosizeQueued) return;
|
|
3471
|
+
if (fieldSizing || autosizeQueued) return;
|
|
3456
3472
|
autosizeQueued = true;
|
|
3457
3473
|
requestAnimationFrame(() => {
|
|
3458
3474
|
autosizeQueued = false;
|
|
3459
3475
|
autosize();
|
|
3460
3476
|
});
|
|
3461
3477
|
}
|
|
3478
|
+
let composerBounds = "";
|
|
3462
3479
|
function autosize() {
|
|
3463
3480
|
const min = Math.max(36, composerMin);
|
|
3464
3481
|
const cap = Math.max(180, min);
|
|
3482
|
+
if (fieldSizing) {
|
|
3483
|
+
const max = Math.min(composerCeiling(), cap);
|
|
3484
|
+
if (composerBounds === `${min}/${max}`) return;
|
|
3485
|
+
composerBounds = `${min}/${max}`;
|
|
3486
|
+
els.input.style.minHeight = `${min}px`;
|
|
3487
|
+
els.input.style.maxHeight = `${max}px`;
|
|
3488
|
+
return;
|
|
3489
|
+
}
|
|
3465
3490
|
els.input.style.height = "auto";
|
|
3466
3491
|
els.input.style.height = Math.min(composerCeiling(), Math.max(min, Math.min(cap, els.input.scrollHeight))) + "px";
|
|
3467
3492
|
}
|
|
3493
|
+
window.addEventListener("resize", autosize);
|
|
3494
|
+
autosize();
|
|
3468
3495
|
{
|
|
3469
3496
|
const grip = $("#composer-grip");
|
|
3470
3497
|
let drag = null;
|
|
@@ -3625,6 +3652,13 @@
|
|
|
3625
3652
|
if (!li || !room) return;
|
|
3626
3653
|
const p = findById(room, li.dataset.id);
|
|
3627
3654
|
if (!p) return;
|
|
3655
|
+
if (p.kind === "agent" && p.status !== "unstaffed" && e.target.closest(".avatar")) {
|
|
3656
|
+
if (!lifePop || lifePop.id !== p.id) openLifePop(p, li);
|
|
3657
|
+
else if (lifePop.hover) lifePop.hover = false;
|
|
3658
|
+
else closeLifePop();
|
|
3659
|
+
return;
|
|
3660
|
+
}
|
|
3661
|
+
if (e.target.closest(".p-gear")) return openDetails({ kind: "participant", id: p.id });
|
|
3628
3662
|
if (e.target.closest(".stop-btn")) return void post(roomApi(`/participants/${encodeURIComponent(p.id)}/cancel`)).catch(showError);
|
|
3629
3663
|
if (e.target.closest(".reconnect-btn")) return openReconnectDialog(room);
|
|
3630
3664
|
if (e.target.closest("button")) return;
|
|
@@ -4125,7 +4159,7 @@
|
|
|
4125
4159
|
if (!nodes.length) return;
|
|
4126
4160
|
const total = els.messages.scrollHeight || 1;
|
|
4127
4161
|
const h = Math.max(0, t.ticks.clientHeight - TICK_H);
|
|
4128
|
-
const tops = nodes.map(
|
|
4162
|
+
const tops = nodes.map(topInList);
|
|
4129
4163
|
const frag = document.createDocumentFragment();
|
|
4130
4164
|
nodes.forEach((el, i) => {
|
|
4131
4165
|
const tick = document.createElement("div");
|
|
@@ -4148,7 +4182,7 @@
|
|
|
4148
4182
|
t.view.style.height = `${Math.max(8, (m.clientHeight / total) * h)}px`;
|
|
4149
4183
|
const top = m.scrollTop;
|
|
4150
4184
|
const bottom = m.scrollTop + m.clientHeight;
|
|
4151
|
-
const inView = t.items.map((el) => el
|
|
4185
|
+
const inView = t.items.map((el) => { const y = topInList(el); return y + el.offsetHeight > top && y < bottom; });
|
|
4152
4186
|
inView.forEach((on, i) => {
|
|
4153
4187
|
const tick = t.ticks.children[i];
|
|
4154
4188
|
if (tick) tick.classList.toggle("in-view", on);
|
|
@@ -4290,8 +4324,10 @@
|
|
|
4290
4324
|
renderPins();
|
|
4291
4325
|
}
|
|
4292
4326
|
function updateTimelineView() { for (const t of timelines) t.updateView(); }
|
|
4327
|
+
const composerFollowers = [$("#timeline"), $("#timeline-left"), els.mentionMenu, els.emojiMenu];
|
|
4293
4328
|
new ResizeObserver(() => {
|
|
4294
|
-
|
|
4329
|
+
const h = `${els.composer.offsetHeight}px`;
|
|
4330
|
+
for (const el of composerFollowers) el.style.setProperty("--composer-h", h);
|
|
4295
4331
|
renderTimeline();
|
|
4296
4332
|
}).observe(els.composer);
|
|
4297
4333
|
new ResizeObserver(() => renderTimeline()).observe(els.messages);
|
|
@@ -4332,6 +4368,181 @@
|
|
|
4332
4368
|
else scrollToBottom();
|
|
4333
4369
|
});
|
|
4334
4370
|
|
|
4371
|
+
function lifeOf(p) {
|
|
4372
|
+
if (!p.contextSize) return (p.turns || 0) === 0 && !p.contextUsed ? { left: 100, tone: "fresh" } : { left: null, tone: "unknown" };
|
|
4373
|
+
const left = Math.max(0, Math.min(100, 100 - Math.round((100 * (p.contextUsed || 0)) / p.contextSize)));
|
|
4374
|
+
return { left, tone: left <= 20 ? "hot" : left <= 50 ? "warm" : "ok" };
|
|
4375
|
+
}
|
|
4376
|
+
const LIFE_PATH = "M26 2.5H32.5A17 17 0 0 1 49.5 19.5V32.5A17 17 0 0 1 32.5 49.5H19.5A17 17 0 0 1 2.5 32.5V19.5A17 17 0 0 1 19.5 2.5H26";
|
|
4377
|
+
const LIFE_RING = `<svg class="life" viewBox="0 0 52 52" aria-hidden="true"><path class="life-track" d="${LIFE_PATH}" pathLength="100"/><path class="life-arc" d="${LIFE_PATH}" pathLength="100"/></svg>`;
|
|
4378
|
+
function patchLifeRing(li, p) {
|
|
4379
|
+
const av = li.querySelector(".avatar");
|
|
4380
|
+
if (!av) return;
|
|
4381
|
+
let ring = av.querySelector(".life");
|
|
4382
|
+
if (!ring) {
|
|
4383
|
+
av.insertAdjacentHTML("afterbegin", LIFE_RING);
|
|
4384
|
+
ring = av.querySelector(".life");
|
|
4385
|
+
av.classList.add("has-life");
|
|
4386
|
+
}
|
|
4387
|
+
const { left, tone } = lifeOf(p);
|
|
4388
|
+
const cls = `life life-${tone}`;
|
|
4389
|
+
if (ring.getAttribute("class") !== cls) ring.setAttribute("class", cls);
|
|
4390
|
+
ring.querySelector(".life-arc").style.strokeDasharray = left === null ? "0 100" : `${left} 100`;
|
|
4391
|
+
const q = av.querySelector(".life-q");
|
|
4392
|
+
if (tone === "unknown" && !q) av.insertAdjacentHTML("beforeend", '<span class="life-q" title="This agent does not report its context">?</span>');
|
|
4393
|
+
else if (tone !== "unknown" && q) q.remove();
|
|
4394
|
+
const ev = recentContextEvent(p);
|
|
4395
|
+
av.classList.toggle("life-attn", !!ev && ev.kind !== "threshold");
|
|
4396
|
+
av.title = (tone === "fresh" ? "Context: fresh, nothing used yet." : tone === "unknown" ? "Context: not reported by this agent." : `Context ${fmtTokens(p.contextUsed)} of ${fmtTokens(p.contextSize)} used · ${left} % left.`) + (ev ? ` ${contextEventText(p, ev)}.` : "") + " Click for details.";
|
|
4397
|
+
}
|
|
4398
|
+
const CONTEXT_EVENT_FRESH_MS = 15 * 60 * 1000;
|
|
4399
|
+
function recentContextEvent(p) {
|
|
4400
|
+
const ev = p.contextEvent;
|
|
4401
|
+
return ev && Date.now() - ev.at < CONTEXT_EVENT_FRESH_MS ? ev : null;
|
|
4402
|
+
}
|
|
4403
|
+
function contextEventText(p, ev) {
|
|
4404
|
+
const when = new Date(ev.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
4405
|
+
if (ev.kind === "compacted") return `Compacted its context at ${when} (now ${fmtTokens(ev.used)})`;
|
|
4406
|
+
if (ev.kind === "full") return `Ran out of context at ${when}; respawned with memory`;
|
|
4407
|
+
return `Over 80 % since ${when}; leaves notes with its replies`;
|
|
4408
|
+
}
|
|
4409
|
+
let lifePop = null;
|
|
4410
|
+
let hoverTimer = null;
|
|
4411
|
+
function openLifePop(p, li, hover) {
|
|
4412
|
+
closeLifePop();
|
|
4413
|
+
const el = document.createElement("div");
|
|
4414
|
+
el.className = "life-pop";
|
|
4415
|
+
document.body.appendChild(el);
|
|
4416
|
+
lifePop = { id: p.id, anchor: li.querySelector(".avatar"), el, n: null, hover: !!hover };
|
|
4417
|
+
el.addEventListener("mouseenter", () => clearTimeout(hoverTimer));
|
|
4418
|
+
el.addEventListener("mouseleave", () => lifePop && lifePop.hover && scheduleHoverClose());
|
|
4419
|
+
renderLifePop();
|
|
4420
|
+
}
|
|
4421
|
+
function scheduleHoverClose() {
|
|
4422
|
+
clearTimeout(hoverTimer);
|
|
4423
|
+
hoverTimer = setTimeout(() => {
|
|
4424
|
+
if (lifePop && lifePop.hover && !lifePop.el.matches(":hover") && !lifePop.anchor.matches(":hover")) closeLifePop();
|
|
4425
|
+
}, 180);
|
|
4426
|
+
}
|
|
4427
|
+
els.participants.addEventListener("mouseover", (e) => {
|
|
4428
|
+
const av = e.target.closest("li[data-id] .avatar");
|
|
4429
|
+
const li = av && av.closest("li[data-id]");
|
|
4430
|
+
if (!av || (e.relatedTarget && av.contains(e.relatedTarget))) return;
|
|
4431
|
+
const room = currentRoom();
|
|
4432
|
+
const p = room && findById(room, li.dataset.id);
|
|
4433
|
+
if (!p || p.kind !== "agent" || p.status === "unstaffed") return;
|
|
4434
|
+
clearTimeout(hoverTimer);
|
|
4435
|
+
if (lifePop && lifePop.id === p.id) return;
|
|
4436
|
+
hoverTimer = setTimeout(() => {
|
|
4437
|
+
if (!lifePop || lifePop.hover) openLifePop(p, li, true);
|
|
4438
|
+
}, 220);
|
|
4439
|
+
});
|
|
4440
|
+
els.participants.addEventListener("mouseout", (e) => {
|
|
4441
|
+
const av = e.target.closest("li[data-id] .avatar");
|
|
4442
|
+
const li = av && av.closest("li[data-id]");
|
|
4443
|
+
if (!av || (e.relatedTarget && av.contains(e.relatedTarget))) return;
|
|
4444
|
+
clearTimeout(hoverTimer);
|
|
4445
|
+
if (lifePop && lifePop.hover && lifePop.id === li.dataset.id) scheduleHoverClose();
|
|
4446
|
+
});
|
|
4447
|
+
function closeLifePop() {
|
|
4448
|
+
if (!lifePop) return;
|
|
4449
|
+
lifePop.el.remove();
|
|
4450
|
+
lifePop = null;
|
|
4451
|
+
}
|
|
4452
|
+
function renderLifePop() {
|
|
4453
|
+
if (!lifePop) return;
|
|
4454
|
+
const room = currentRoom();
|
|
4455
|
+
const p = room && findById(room, lifePop.id);
|
|
4456
|
+
if (!p || state.view !== "room" || !lifePop.anchor.isConnected) return closeLifePop();
|
|
4457
|
+
const el = lifePop.el;
|
|
4458
|
+
if (el.contains(document.activeElement)) return;
|
|
4459
|
+
const { left, tone } = lifeOf(p);
|
|
4460
|
+
const last = [...room.messages].reverse().find((m) => m.from === p.id && m.usage);
|
|
4461
|
+
const n = lifePop.n ?? room.settings.replayAfterRestart ?? 10;
|
|
4462
|
+
el.innerHTML = `<div class="lp-main">
|
|
4463
|
+
<div class="lp-head">${avatar(p, 28, {})}<div><b>${esc(p.name)}</b><div class="lp-sub">${tone === "fresh" ? "fresh session: nothing used yet" : tone === "unknown" ? "context not reported by this agent" : `context ${fmtTokens(p.contextUsed)} of ${fmtTokens(p.contextSize)} used · <b>${left} % left</b>`}</div></div></div>
|
|
4464
|
+
<div class="life-bar life-${tone}"><i style="width:${left ?? 0}%"></i></div>
|
|
4465
|
+
${p.contextEvent ? `<div class="lp-event${recentContextEvent(p) && p.contextEvent.kind !== "threshold" ? " fresh" : ""}">${ic("info")} ${esc(contextEventText(p, p.contextEvent))}</div>` : ""}
|
|
4466
|
+
<div class="kv lp-kv">
|
|
4467
|
+
<span>Turns</span><span>${p.turns}</span>
|
|
4468
|
+
<span>Last reply</span><span>${last ? `<span title="tokens in">${ic("arrow-down")} ${fmtTokens(last.usage.inputTokens)}</span> <span title="tokens out">${ic("arrow-up")} ${fmtTokens(last.usage.outputTokens)}</span>` : "—"}</span>
|
|
4469
|
+
<span>Cost (estimate)</span><span>${fmtCost(p.cost) || "—"}</span>
|
|
4470
|
+
<span>Briefs sent</span><span>${p.briefsSent ?? 0}</span>
|
|
4471
|
+
<span>Notes</span><span>${p.notes ? `taken at ${fmtTokens(p.notesAt || 0)} tokens` : "none yet"}${p.status === "offline" || p.status === "unstaffed" ? "" : ` · <button type="button" class="link-btn lp-take" title="A hidden turn: the vibemate writes 10 lines for a future restart; nothing is posted">${p.notes ? "refresh" : "take now"}</button>`}${p.notes ? ` · <button type="button" class="link-btn lp-edit">edit</button>` : ""}</span>
|
|
4472
|
+
</div>
|
|
4473
|
+
${p.notes ? `<pre class="lp-notes">${esc(p.notes)}</pre><div class="lp-editor" hidden><textarea class="lp-notes-area" rows="6" maxlength="4000">${esc(p.notes)}</textarea><div class="row-btns"><button type="button" class="btn sm ghost lp-notes-clear">Clear</button><button type="button" class="btn sm primary lp-notes-save">Save</button></div></div>` : ""}
|
|
4474
|
+
<div class="lp-respawn">
|
|
4475
|
+
<button type="button" class="btn sm danger lp-empty" title="A new session that knows nothing of this conversation">${ic("bolt")}Respawn, empty head</button>
|
|
4476
|
+
<label class="lp-with"><button type="button" class="btn sm lp-mem" title="A new session that re-reads only the last N messages${p.notes ? " and its own notes" : ""}">Respawn with the last</button><input type="number" class="lp-n" min="0" max="500" value="${n}"> messages</label>
|
|
4477
|
+
</div>
|
|
4478
|
+
</div>
|
|
4479
|
+
<div class="lp-side"><button type="button" class="icon-btn sm lp-x" title="Close">${ic("close")}</button><button type="button" class="icon-btn sm lp-more" title="Open this vibemate's panel">${ic("settings")}</button></div>`;
|
|
4480
|
+
el.querySelector(".lp-x").addEventListener("click", closeLifePop);
|
|
4481
|
+
el.querySelector(".lp-more").addEventListener("click", () => {
|
|
4482
|
+
closeLifePop();
|
|
4483
|
+
openDetails({ kind: "participant", id: p.id });
|
|
4484
|
+
});
|
|
4485
|
+
el.querySelector(".lp-empty").addEventListener("click", () => respawnWith(p, 0));
|
|
4486
|
+
el.querySelector(".lp-mem").addEventListener("click", () => respawnWith(p, Number(el.querySelector(".lp-n").value) || 0));
|
|
4487
|
+
el.querySelector(".lp-n").addEventListener("input", (e) => (lifePop.n = Number(e.target.value) || 0));
|
|
4488
|
+
const take = el.querySelector(".lp-take");
|
|
4489
|
+
if (take)
|
|
4490
|
+
take.addEventListener("click", async () => {
|
|
4491
|
+
take.disabled = true;
|
|
4492
|
+
take.textContent = "asking…";
|
|
4493
|
+
try {
|
|
4494
|
+
await post(roomApi(`/participants/${encodeURIComponent(p.id)}/take-notes`));
|
|
4495
|
+
} catch (e) {
|
|
4496
|
+
showError(e);
|
|
4497
|
+
renderLifePop();
|
|
4498
|
+
}
|
|
4499
|
+
});
|
|
4500
|
+
const edit = el.querySelector(".lp-edit");
|
|
4501
|
+
if (edit)
|
|
4502
|
+
edit.addEventListener("click", () => {
|
|
4503
|
+
el.querySelector(".lp-notes").hidden = true;
|
|
4504
|
+
el.querySelector(".lp-editor").hidden = false;
|
|
4505
|
+
el.querySelector(".lp-notes-area").focus();
|
|
4506
|
+
});
|
|
4507
|
+
const saveNotes = async (text) => {
|
|
4508
|
+
try {
|
|
4509
|
+
await post(roomApi(`/participants/${encodeURIComponent(p.id)}/notes`), { notes: text });
|
|
4510
|
+
} catch (e) {
|
|
4511
|
+
showError(e);
|
|
4512
|
+
}
|
|
4513
|
+
el.querySelector(".lp-notes-area").blur();
|
|
4514
|
+
renderLifePop();
|
|
4515
|
+
};
|
|
4516
|
+
const save = el.querySelector(".lp-notes-save");
|
|
4517
|
+
if (save) save.addEventListener("click", () => saveNotes(el.querySelector(".lp-notes-area").value));
|
|
4518
|
+
const clear = el.querySelector(".lp-notes-clear");
|
|
4519
|
+
if (clear) clear.addEventListener("click", () => saveNotes(""));
|
|
4520
|
+
const z = zoomFactor();
|
|
4521
|
+
const r = lifePop.anchor.getBoundingClientRect();
|
|
4522
|
+
el.style.left = `${Math.round((r.right + 12) / z)}px`;
|
|
4523
|
+
el.style.top = `${Math.round(Math.max(8, Math.min(r.top / z - 10, window.innerHeight / z - el.offsetHeight - 8)))}px`;
|
|
4524
|
+
}
|
|
4525
|
+
async function respawnWith(p, n) {
|
|
4526
|
+
const text =
|
|
4527
|
+
n > 0
|
|
4528
|
+
? `${p.name} starts over with a new session that gets only ${p.notes ? "its own notes and " : ""}the last ${n} messages of this room. You keep the history; the rest of its memory is gone.`
|
|
4529
|
+
: `${p.name} forgets this whole conversation and starts over. You keep the history; it does not.`;
|
|
4530
|
+
const ok = await confirmDialog(text, { title: `Respawn ${p.name}?`, okLabel: "Respawn", danger: true });
|
|
4531
|
+
if (!ok) return;
|
|
4532
|
+
closeLifePop();
|
|
4533
|
+
try {
|
|
4534
|
+
await post(roomApi(`/participants/${encodeURIComponent(p.id)}/respawn`), { memory: n > 0, replay: n });
|
|
4535
|
+
} catch (e) {
|
|
4536
|
+
showError(e);
|
|
4537
|
+
}
|
|
4538
|
+
}
|
|
4539
|
+
document.addEventListener("click", (e) => {
|
|
4540
|
+
if (lifePop && !e.target.closest(".life-pop") && !e.target.closest("#participants .avatar")) closeLifePop();
|
|
4541
|
+
});
|
|
4542
|
+
document.addEventListener("keydown", (e) => {
|
|
4543
|
+
if (e.key === "Escape" && lifePop) closeLifePop();
|
|
4544
|
+
});
|
|
4545
|
+
|
|
4335
4546
|
const pinsBtn = $("#pins-btn");
|
|
4336
4547
|
const pinsPanel = $("#pins-panel");
|
|
4337
4548
|
function pinnedMessages(room) {
|
package/ui/theme.css
CHANGED
|
@@ -67,6 +67,9 @@
|
|
|
67
67
|
--attention-shadow: 0 8px 18px -10px rgba(232, 100, 42, 0.7);
|
|
68
68
|
--unread-grad: linear-gradient(135deg, #ff7a45, #f0452c);
|
|
69
69
|
--note-hover: #ffeaa8;
|
|
70
|
+
--done: var(--st-ready);
|
|
71
|
+
--done-ink: var(--st-ready-ink);
|
|
72
|
+
--done-hover: #c4efdb;
|
|
70
73
|
--tick-mine: #cdcdf9;
|
|
71
74
|
--tick-mine-view: #a9a9f5;
|
|
72
75
|
--tick-fallback: #9ca3af;
|