viberoom 0.5.3 → 0.5.5
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/hub.js +9 -0
- package/dist/launcher.js +7 -1
- package/dist/main.js +53 -6
- package/dist/server.js +23 -0
- package/dist/tui.js +62 -0
- package/dist/update.js +95 -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 +12 -1
- package/ui/app.js +126 -9
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/hub.js
CHANGED
|
@@ -113,6 +113,7 @@ export class Hub extends EventEmitter {
|
|
|
113
113
|
diagrams: { preset: "pop", primary: null },
|
|
114
114
|
editor: { ...DEFAULT_EDITOR_SETTINGS },
|
|
115
115
|
appearance: { ...DEFAULT_APPEARANCE },
|
|
116
|
+
checkForUpdates: true,
|
|
116
117
|
roomDefaults: {},
|
|
117
118
|
vendorPresets: {},
|
|
118
119
|
};
|
|
@@ -165,6 +166,8 @@ export class Hub extends EventEmitter {
|
|
|
165
166
|
next.profileCompleted = patch.profileCompleted === true || patch.profileCompleted === "true";
|
|
166
167
|
if (patch.agentSkillsNeedApproval !== undefined)
|
|
167
168
|
next.agentSkillsNeedApproval = patch.agentSkillsNeedApproval === true || patch.agentSkillsNeedApproval === "true";
|
|
169
|
+
if (patch.checkForUpdates !== undefined)
|
|
170
|
+
next.checkForUpdates = patch.checkForUpdates === true || patch.checkForUpdates === "true";
|
|
168
171
|
if (patch.diagrams !== undefined && typeof patch.diagrams === "object" && patch.diagrams) {
|
|
169
172
|
const d = patch.diagrams;
|
|
170
173
|
const preset = String(d.preset ?? next.diagrams?.preset ?? "pop");
|
|
@@ -432,9 +435,15 @@ export class Hub extends EventEmitter {
|
|
|
432
435
|
else
|
|
433
436
|
this.log.info(`removed room ${id}`);
|
|
434
437
|
}
|
|
438
|
+
update = null;
|
|
439
|
+
setUpdate(update) {
|
|
440
|
+
this.update = update;
|
|
441
|
+
this.emit("event", { type: "update", update });
|
|
442
|
+
}
|
|
435
443
|
snapshot() {
|
|
436
444
|
return {
|
|
437
445
|
settings: this.settings,
|
|
446
|
+
update: this.update,
|
|
438
447
|
recipes: listRecipes().map(({ build: _b, ...r }) => r),
|
|
439
448
|
skills: this.skills.list(),
|
|
440
449
|
roomDefaults: { ...DEFAULT_ROOM_SETTINGS, ...this.settings.roomDefaults },
|
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)
|
|
@@ -86,6 +88,7 @@ Options
|
|
|
86
88
|
--browser open the default browser instead of a Chromium app window
|
|
87
89
|
`);
|
|
88
90
|
}
|
|
91
|
+
import { checkForUpdate } from "./update.js";
|
|
89
92
|
function buildInfo() {
|
|
90
93
|
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
91
94
|
const built = statSync(fileURLToPath(import.meta.url)).mtime;
|
|
@@ -197,9 +200,39 @@ function openWindow(url, options, log) {
|
|
|
197
200
|
}
|
|
198
201
|
return;
|
|
199
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
|
+
}
|
|
200
213
|
log.info("opening the default browser");
|
|
201
214
|
exec(openUrlCommand(url), () => undefined);
|
|
202
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
|
+
}
|
|
203
236
|
async function runHub(options, log, info) {
|
|
204
237
|
const background = options.command === "serve";
|
|
205
238
|
if (!background) {
|
|
@@ -251,6 +284,15 @@ async function runHub(options, log, info) {
|
|
|
251
284
|
}
|
|
252
285
|
}
|
|
253
286
|
hub.setHubUrl(server.url);
|
|
287
|
+
if (hub.settings.checkForUpdates) {
|
|
288
|
+
void checkForUpdate(hub.dataDir, info.version).then((update) => {
|
|
289
|
+
hub.setUpdate(update);
|
|
290
|
+
if (update.available)
|
|
291
|
+
log.info(`viberoom ${update.latest} is available (this is ${update.current})`);
|
|
292
|
+
else if (update.error)
|
|
293
|
+
log.warn(`update check failed: ${update.error}`);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
254
296
|
if (background)
|
|
255
297
|
writePidFile(options.dataDir, { pid: process.pid, port: options.port, build: info.build, startedAt: Date.now() });
|
|
256
298
|
log.info(`viberoom ${info.version} (build ${info.build}) is open at ${server.url} (data: ${hub.dataDir}; rooms: ${[...hub.rooms.values()].map((r) => r.name).join(", ")})`);
|
|
@@ -357,6 +399,9 @@ async function main() {
|
|
|
357
399
|
openWindow(url, options, log);
|
|
358
400
|
return;
|
|
359
401
|
}
|
|
402
|
+
case "doctor":
|
|
403
|
+
await runDoctor(options, info);
|
|
404
|
+
return;
|
|
360
405
|
case "logs": {
|
|
361
406
|
const path = logFilePath(options.dataDir);
|
|
362
407
|
process.stdout.write(`${path}\n${tailFile(path, 60)}\n`);
|
|
@@ -375,10 +420,12 @@ async function main() {
|
|
|
375
420
|
if (choice === "shortcut") {
|
|
376
421
|
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
377
422
|
const result = installShortcuts({ root: fileURLToPath(new URL("..", import.meta.url)), dataDir: options.dataDir, node: process.execPath, version: pkg.version, desktop: true });
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
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
|
+
}
|
|
382
429
|
return;
|
|
383
430
|
}
|
|
384
431
|
}
|
package/dist/server.js
CHANGED
|
@@ -40,6 +40,7 @@ const STATIC_FILES = {
|
|
|
40
40
|
"/vendor/mermaid.min.js": { file: "mermaid/dist/mermaid.min.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
|
|
41
41
|
"/vendor/marked.umd.js": { file: "marked/lib/marked.umd.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
|
|
42
42
|
};
|
|
43
|
+
import { checkForUpdate, installUpdate, restartWithNewBuild, runsFromSourceCheckout } from "./update.js";
|
|
43
44
|
export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
44
45
|
const uiDir = fileURLToPath(new URL("../ui/", import.meta.url));
|
|
45
46
|
const assetsDir = fileURLToPath(new URL("../assets/", import.meta.url));
|
|
@@ -178,6 +179,28 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
178
179
|
sendJson(res, 200, snapshot());
|
|
179
180
|
return;
|
|
180
181
|
}
|
|
182
|
+
if (req.method === "GET" && path === "/api/update") {
|
|
183
|
+
if (url.searchParams.get("check") === "1")
|
|
184
|
+
hub.setUpdate(await checkForUpdate(hub.dataDir, info.version, { force: true }));
|
|
185
|
+
sendJson(res, 200, hub.update ?? { current: info.version, latest: null, available: false, checkedAt: null, error: null });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (req.method === "POST" && path === "/api/update/install") {
|
|
189
|
+
const mainUrl = new URL("./main.js", import.meta.url).href;
|
|
190
|
+
if (runsFromSourceCheckout(mainUrl))
|
|
191
|
+
throw new Error("this viberoom runs from a source checkout; update it with git pull and npm run update");
|
|
192
|
+
const latest = hub.update?.available ? hub.update.latest : null;
|
|
193
|
+
if (!latest)
|
|
194
|
+
throw new Error("no newer version is known; check for updates first");
|
|
195
|
+
log.info(`installing viberoom ${latest} (npm install -g)`);
|
|
196
|
+
const result = await installUpdate(latest);
|
|
197
|
+
if (!result.ok)
|
|
198
|
+
throw new Error(`npm install failed: ${result.output.slice(-600) || "no output"}`);
|
|
199
|
+
log.info(`viberoom ${latest} installed; starting the new build, which replaces this hub`);
|
|
200
|
+
sendJson(res, 200, { ok: true, version: latest });
|
|
201
|
+
setTimeout(() => restartWithNewBuild(mainUrl, port, hub.dataDir), 300);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
181
204
|
if (req.method === "GET" && path === "/api/version") {
|
|
182
205
|
sendJson(res, 200, info);
|
|
183
206
|
return;
|
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/dist/update.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
export const REGISTRY_URL = "https://registry.npmjs.org/viberoom/latest";
|
|
7
|
+
export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const CHECK_FILE = "update-check.json";
|
|
9
|
+
export function parseVersion(v) {
|
|
10
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/.exec(v.trim());
|
|
11
|
+
if (!m)
|
|
12
|
+
return null;
|
|
13
|
+
return { parts: [Number(m[1]), Number(m[2]), Number(m[3])], prerelease: !!m[4] };
|
|
14
|
+
}
|
|
15
|
+
export function compareVersions(a, b) {
|
|
16
|
+
const pa = parseVersion(a);
|
|
17
|
+
const pb = parseVersion(b);
|
|
18
|
+
if (!pa || !pb)
|
|
19
|
+
return 0;
|
|
20
|
+
for (let i = 0; i < 3; i++)
|
|
21
|
+
if (pa.parts[i] !== pb.parts[i])
|
|
22
|
+
return pa.parts[i] - pb.parts[i];
|
|
23
|
+
if (pa.prerelease !== pb.prerelease)
|
|
24
|
+
return pa.prerelease ? -1 : 1;
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
export function readCheckRecord(dataDir) {
|
|
28
|
+
const file = join(dataDir, CHECK_FILE);
|
|
29
|
+
if (!existsSync(file))
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
33
|
+
if (typeof raw.checkedAt !== "string")
|
|
34
|
+
return null;
|
|
35
|
+
return { checkedAt: raw.checkedAt, latest: typeof raw.latest === "string" ? raw.latest : null, error: typeof raw.error === "string" ? raw.error : null };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function toInfo(current, record) {
|
|
42
|
+
const latest = record?.latest ?? null;
|
|
43
|
+
return { current, latest, available: latest !== null && compareVersions(latest, current) > 0, checkedAt: record?.checkedAt ?? null, error: record?.error ?? null };
|
|
44
|
+
}
|
|
45
|
+
export async function checkForUpdate(dataDir, current, options = {}) {
|
|
46
|
+
const now = options.now ?? Date.now;
|
|
47
|
+
const previous = readCheckRecord(dataDir);
|
|
48
|
+
if (!options.force && previous && now() - Date.parse(previous.checkedAt) < CHECK_INTERVAL_MS)
|
|
49
|
+
return toInfo(current, previous);
|
|
50
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
51
|
+
let record;
|
|
52
|
+
try {
|
|
53
|
+
const res = await doFetch(REGISTRY_URL, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(options.timeoutMs ?? 6000) });
|
|
54
|
+
if (!res.ok)
|
|
55
|
+
throw new Error(`registry answered ${res.status}`);
|
|
56
|
+
const body = (await res.json());
|
|
57
|
+
if (typeof body.version !== "string" || !parseVersion(body.version))
|
|
58
|
+
throw new Error("registry answer had no version");
|
|
59
|
+
record = { checkedAt: new Date(now()).toISOString(), latest: body.version, error: null };
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
record = { checkedAt: new Date(now()).toISOString(), latest: previous?.latest ?? null, error: message.replace(/\s+/g, " ").slice(0, 200) };
|
|
64
|
+
}
|
|
65
|
+
writeFileSync(join(dataDir, CHECK_FILE), JSON.stringify(record, null, 2));
|
|
66
|
+
return toInfo(current, record);
|
|
67
|
+
}
|
|
68
|
+
export function runsFromSourceCheckout(mainModuleUrl) {
|
|
69
|
+
const path = decodeURIComponent(new URL(mainModuleUrl).pathname);
|
|
70
|
+
return !/\/node_modules\/viberoom\//.test(path);
|
|
71
|
+
}
|
|
72
|
+
export function installCommandLine(version) {
|
|
73
|
+
if (!parseVersion(version))
|
|
74
|
+
throw new Error(`not a version: ${version}`);
|
|
75
|
+
return `npm install -g viberoom@${version} --no-audit --no-fund`;
|
|
76
|
+
}
|
|
77
|
+
export function installUpdate(version) {
|
|
78
|
+
const line = installCommandLine(version);
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const child = process.platform === "win32" ? spawn(line, { shell: true, windowsHide: true }) : spawn("npm", line.split(" ").slice(1));
|
|
81
|
+
let output = "";
|
|
82
|
+
const collect = (chunk) => {
|
|
83
|
+
output = (output + chunk.toString()).slice(-4000);
|
|
84
|
+
};
|
|
85
|
+
child.stdout?.on("data", collect);
|
|
86
|
+
child.stderr?.on("data", collect);
|
|
87
|
+
child.on("error", (error) => resolve({ ok: false, output: `${output}\n${error.message}`.trim() }));
|
|
88
|
+
child.on("close", (code) => resolve({ ok: code === 0, output: output.trim() }));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export function restartWithNewBuild(mainModuleUrl, port, dataDir) {
|
|
92
|
+
const main = fileURLToPath(mainModuleUrl);
|
|
93
|
+
const child = spawn(process.execPath, [main, "start", "--port", String(port), "--data-dir", dataDir, "--no-open"], { detached: true, stdio: "ignore", windowsHide: true });
|
|
94
|
+
child.unref();
|
|
95
|
+
}
|
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
|
@@ -44,7 +44,15 @@
|
|
|
44
44
|
.rail-toggle { width: 44px; height: 24px; margin-top: 8px; border-radius: 8px; border: 0; background: transparent; color: var(--faint); display: grid; place-content: center; padding: 0; align-self: center; transition: background var(--t-fast), color var(--t-fast); }
|
|
45
45
|
.rail-toggle .i { width: 16px; height: 16px; }
|
|
46
46
|
.rail-toggle:hover { background: #fff; color: var(--primary); }
|
|
47
|
-
.rail-foot { margin-top: auto; display: flex; flex-direction: column; gap: 0; align-items: center; }
|
|
47
|
+
.rail-foot { position: relative; margin-top: auto; display: flex; flex-direction: column; gap: 0; align-items: center; }
|
|
48
|
+
.update-pop { position: absolute; left: 12px; bottom: calc(100% - 2px); z-index: 30; display: flex; gap: 6px; align-items: flex-start; width: 250px; padding: 10px 8px 10px 14px; background: #fff; border-radius: 18px 18px 18px 6px; box-shadow: 0 10px 28px -12px rgba(28, 27, 51, 0.45), var(--shadow-tile); font-size: 12.5px; font-weight: 600; color: var(--ink-2); animation: bubble-in 0.45s var(--ease-out); }
|
|
49
|
+
.update-pop::after { content: ""; position: absolute; left: 14px; bottom: -6px; width: 12px; height: 12px; background: #fff; border-radius: 0 0 3px 0; transform: rotate(45deg); box-shadow: 3px 3px 4px -3px rgba(28, 27, 51, 0.25); }
|
|
50
|
+
.update-pop .up-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
51
|
+
.update-pop .up-text { line-height: 1.4; }
|
|
52
|
+
.update-pop .up-text b { color: var(--ink); }
|
|
53
|
+
.update-pop .up-go { align-self: flex-start; }
|
|
54
|
+
.update-pop .up-side { flex: none; display: flex; flex-direction: column; gap: 2px; }
|
|
55
|
+
.update-pop .icon-btn.sm { width: 26px; height: 26px; }
|
|
48
56
|
.shell.rail-open .rail-foot { align-items: stretch; padding: 0 14px; }
|
|
49
57
|
.rail-item { position: relative; display: flex; align-items: center; justify-content: center; gap: 10px; width: 44px; height: 44px; padding: 0; border: 0; background: transparent; border-radius: 14px; color: var(--muted); font-weight: 800; font-size: 13px; white-space: nowrap; transition: background var(--t-fast), color var(--t-fast), box-shadow var(--t-fast); }
|
|
50
58
|
.shell.rail-open .rail-item { width: auto; justify-content: flex-start; padding: 0 12px 0 0; }
|
|
@@ -198,6 +206,7 @@
|
|
|
198
206
|
.day { align-self: center; color: var(--faint); font-size: 11px; font-weight: 800; padding: 3px 12px; margin: 2px 0; }
|
|
199
207
|
.msg { display: flex; flex-direction: column; gap: 4px; align-items: flex-start; max-width: 100%; animation: bubble-in var(--t-base) var(--ease-out); }
|
|
200
208
|
.messages .msg { content-visibility: auto; contain-intrinsic-size: auto 120px; flex-shrink: 0; }
|
|
209
|
+
.messages .msgs-page { display: flex; flex-direction: column; gap: 14px; flex-shrink: 0; content-visibility: auto; contain-intrinsic-size: auto 6000px; }
|
|
201
210
|
.msg.agent { padding-right: 40px; }
|
|
202
211
|
.msg.mine { align-items: flex-end; padding-left: 40px; }
|
|
203
212
|
.head-av { display: inline-flex; width: 32px; height: 32px; padding: 4px; box-sizing: content-box; flex: none; }
|
|
@@ -206,6 +215,7 @@
|
|
|
206
215
|
.msg.mine .head { flex-direction: row-reverse; }
|
|
207
216
|
.msg.system { justify-content: center; }
|
|
208
217
|
.msg.hidden-by-search { display: none; }
|
|
218
|
+
.messages.searching .msgs-page { content-visibility: visible; }
|
|
209
219
|
.sys { color: var(--muted); font-size: 11px; font-weight: 700; padding: 2px 10px; max-width: 80%; text-align: center; }
|
|
210
220
|
.sys.warn { color: var(--warm-ink); background: var(--warm); border-radius: var(--r-pill); padding: 6px 12px; font-weight: 800; }
|
|
211
221
|
.bubble-col { display: flex; flex-direction: column; gap: 6px; min-width: 0; width: 100%; max-width: min(1040px, 100%); }
|
|
@@ -486,6 +496,7 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
|
|
|
486
496
|
.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; }
|
|
487
497
|
.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; }
|
|
488
498
|
.composer textarea::placeholder { color: var(--placeholder); font-weight: 600; }
|
|
499
|
+
@supports (field-sizing: content) { .composer textarea { field-sizing: content; height: auto; } }
|
|
489
500
|
.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); }
|
|
490
501
|
.send-btn .i { width: 18px; height: 18px; display: block; }
|
|
491
502
|
.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); }
|
package/ui/app.js
CHANGED
|
@@ -823,6 +823,51 @@
|
|
|
823
823
|
maybeOfferReconnect();
|
|
824
824
|
}
|
|
825
825
|
|
|
826
|
+
function renderUpdatePop() {
|
|
827
|
+
const old = $("#update-pop");
|
|
828
|
+
const u = state.update;
|
|
829
|
+
const show = u && u.available && u.latest && recall("updateDismissed") !== u.latest;
|
|
830
|
+
if (!show) {
|
|
831
|
+
if (old && !old.dataset.busy) old.remove();
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (old && old.dataset.version === u.latest) return;
|
|
835
|
+
if (old) old.remove();
|
|
836
|
+
const pop = document.createElement("div");
|
|
837
|
+
pop.id = "update-pop";
|
|
838
|
+
pop.className = "update-pop";
|
|
839
|
+
pop.dataset.version = u.latest;
|
|
840
|
+
pop.innerHTML = `<div class="up-main"><div class="up-text"><b>viberoom ${esc(u.latest)}</b> is out. You have ${esc(u.current)}.</div><button type="button" class="btn sm primary up-go">Update now and restart</button></div>
|
|
841
|
+
<div class="up-side"><button type="button" class="icon-btn sm up-x" title="Not now">${ic("close")}</button><button type="button" class="icon-btn sm up-settings" title="Update settings">${ic("settings")}</button></div>`;
|
|
842
|
+
pop.querySelector(".up-x").addEventListener("click", () => {
|
|
843
|
+
remember("updateDismissed", u.latest);
|
|
844
|
+
pop.remove();
|
|
845
|
+
});
|
|
846
|
+
pop.querySelector(".up-settings").addEventListener("click", () => setView("settings"));
|
|
847
|
+
pop.querySelector(".up-go").addEventListener("click", () => installUpdate(pop, u.latest));
|
|
848
|
+
els.rail.querySelector(".rail-foot").appendChild(pop);
|
|
849
|
+
}
|
|
850
|
+
async function installUpdate(pop, version) {
|
|
851
|
+
const go = pop.querySelector(".up-go");
|
|
852
|
+
const text = pop.querySelector(".up-text");
|
|
853
|
+
pop.dataset.busy = "1";
|
|
854
|
+
go.disabled = true;
|
|
855
|
+
go.classList.add("loading");
|
|
856
|
+
text.innerHTML = `Installing <b>viberoom ${esc(version)}</b>… this takes a moment.`;
|
|
857
|
+
try {
|
|
858
|
+
await post("/api/update/install", {});
|
|
859
|
+
go.classList.remove("loading");
|
|
860
|
+
text.innerHTML = `<b>viberoom ${esc(version)}</b> is installed. Restarting…`;
|
|
861
|
+
go.hidden = true;
|
|
862
|
+
} catch (e) {
|
|
863
|
+
delete pop.dataset.busy;
|
|
864
|
+
go.classList.remove("loading");
|
|
865
|
+
go.disabled = false;
|
|
866
|
+
go.textContent = "Try again";
|
|
867
|
+
text.innerHTML = `<span class="error">${esc(e.message || String(e))}</span>`;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
826
871
|
function renderRail() {
|
|
827
872
|
els.rail.querySelectorAll(".rail-item[data-nav]").forEach((b) => {
|
|
828
873
|
const nav = b.dataset.nav;
|
|
@@ -1529,9 +1574,29 @@
|
|
|
1529
1574
|
return el;
|
|
1530
1575
|
}
|
|
1531
1576
|
|
|
1577
|
+
const PAGE_SIZE = 50;
|
|
1578
|
+
function placeInList(el) {
|
|
1579
|
+
let page = els.messages.lastElementChild;
|
|
1580
|
+
if (!page || !page.classList.contains("msgs-page") || page.childElementCount >= PAGE_SIZE) {
|
|
1581
|
+
page = document.createElement("div");
|
|
1582
|
+
page.className = "msgs-page";
|
|
1583
|
+
els.messages.appendChild(page);
|
|
1584
|
+
}
|
|
1585
|
+
page.appendChild(el);
|
|
1586
|
+
}
|
|
1587
|
+
function topInList(el) {
|
|
1588
|
+
const page = el.parentElement;
|
|
1589
|
+
if (!page || !page.classList.contains("msgs-page")) return el.offsetTop;
|
|
1590
|
+
if (els.messages.classList.contains("searching") || page.firstElementChild.checkVisibility({ contentVisibilityAuto: true })) return page.offsetTop + el.offsetTop;
|
|
1591
|
+
let i = 0;
|
|
1592
|
+
for (let n = el.previousElementSibling; n; n = n.previousElementSibling) i++;
|
|
1593
|
+
return page.offsetTop + (page.offsetHeight * i) / page.childElementCount;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1532
1596
|
function renderMessages() {
|
|
1533
1597
|
const room = currentRoom();
|
|
1534
1598
|
els.messages.innerHTML = "";
|
|
1599
|
+
els.messages.classList.toggle("searching", !!state.search);
|
|
1535
1600
|
if (!room) return;
|
|
1536
1601
|
if (!room.messages.length) {
|
|
1537
1602
|
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>`;
|
|
@@ -1547,20 +1612,20 @@
|
|
|
1547
1612
|
d.className = "day";
|
|
1548
1613
|
d.textContent = day;
|
|
1549
1614
|
d.title = new Date(m.ts).toLocaleDateString([], { weekday: "long", day: "numeric", month: "long", year: "numeric" });
|
|
1550
|
-
|
|
1615
|
+
placeInList(d);
|
|
1551
1616
|
lastDay = day;
|
|
1552
1617
|
}
|
|
1553
1618
|
for (const [seq, agents] of markers) {
|
|
1554
1619
|
if (placed.has(seq) || !(m.seq >= seq)) continue;
|
|
1555
1620
|
if (m.seq > 0) {
|
|
1556
1621
|
placed.add(seq);
|
|
1557
|
-
|
|
1622
|
+
placeInList(dividerElement(agents));
|
|
1558
1623
|
}
|
|
1559
1624
|
}
|
|
1560
|
-
|
|
1625
|
+
placeInList(messageElement(room, m));
|
|
1561
1626
|
}
|
|
1562
1627
|
for (const [seq, agents] of markers) {
|
|
1563
|
-
if (!placed.has(seq))
|
|
1628
|
+
if (!placed.has(seq)) placeInList(dividerElement(agents));
|
|
1564
1629
|
}
|
|
1565
1630
|
for (const perm of room.permissions) renderPermission(room, perm);
|
|
1566
1631
|
refreshSeen(room);
|
|
@@ -1593,7 +1658,7 @@
|
|
|
1593
1658
|
else {
|
|
1594
1659
|
const empty = els.messages.querySelector(".empty");
|
|
1595
1660
|
if (empty) empty.remove();
|
|
1596
|
-
|
|
1661
|
+
placeInList(messageElement(room, m));
|
|
1597
1662
|
if (m.from === "human") refreshSeen(room);
|
|
1598
1663
|
if (m.streaming && m.from !== "human") renderSideRoom();
|
|
1599
1664
|
else if (!stick && m.kind === "chat") noteNew(room, m);
|
|
@@ -2204,6 +2269,12 @@
|
|
|
2204
2269
|
</div>
|
|
2205
2270
|
</div>
|
|
2206
2271
|
<div>
|
|
2272
|
+
<div class="section" id="sp-update">
|
|
2273
|
+
${sectionTitle("refresh", "Updates")}
|
|
2274
|
+
<label class="switch"><span class="label">Check for updates once a day<span class="hint">At start, one request to the npm registry for the latest viberoom version; nothing else leaves this machine. A newer version shows as a bubble over your avatar.</span></span><input type="checkbox" id="sp-updates" ${s.checkForUpdates !== false ? "checked" : ""}></label>
|
|
2275
|
+
<p class="hint" id="sp-update-status">${updateStatusText()}</p>
|
|
2276
|
+
<button type="button" class="btn sm" id="sp-update-check">Check now</button>
|
|
2277
|
+
</div>
|
|
2207
2278
|
<div class="section">
|
|
2208
2279
|
${sectionTitle("spark", "Vibemates on this machine")}
|
|
2209
2280
|
${machine || '<p class="hint">No supported vibemate is installed yet.</p>'}
|
|
@@ -2288,6 +2359,20 @@
|
|
|
2288
2359
|
});
|
|
2289
2360
|
$("#sp-font").addEventListener("change", () => (sample.style.fontFamily = FONTS.text[$("#sp-font").value].stack));
|
|
2290
2361
|
$("#sp-mono").addEventListener("change", () => sample.querySelectorAll("code").forEach((c) => (c.style.fontFamily = FONTS.mono[$("#sp-mono").value].stack)));
|
|
2362
|
+
$("#sp-update-check").addEventListener("click", async () => {
|
|
2363
|
+
const b = $("#sp-update-check");
|
|
2364
|
+
b.disabled = true;
|
|
2365
|
+
b.classList.add("loading");
|
|
2366
|
+
try {
|
|
2367
|
+
state.update = await get("/api/update?check=1");
|
|
2368
|
+
$("#sp-update-status").textContent = updateStatusText();
|
|
2369
|
+
renderUpdatePop();
|
|
2370
|
+
} catch (e) {
|
|
2371
|
+
showError(e);
|
|
2372
|
+
}
|
|
2373
|
+
b.disabled = false;
|
|
2374
|
+
b.classList.remove("loading");
|
|
2375
|
+
});
|
|
2291
2376
|
bindSave($("#sp-form"), $("#sp-save"), async () => {
|
|
2292
2377
|
const vendorPresets = {};
|
|
2293
2378
|
els.pageInner.querySelectorAll("input[data-vendor]").forEach((inp) => {
|
|
@@ -2297,6 +2382,7 @@
|
|
|
2297
2382
|
await post("/api/settings", {
|
|
2298
2383
|
bypassPermissionsByDefault: $("#sp-bypass").checked,
|
|
2299
2384
|
agentSkillsNeedApproval: $("#sp-skill-approval").checked,
|
|
2385
|
+
checkForUpdates: $("#sp-updates").checked,
|
|
2300
2386
|
diagrams: { preset: $("#sp-diagram-preset").value, primary: $("#sp-diagram-custom").checked ? $("#sp-diagram-color").value : null },
|
|
2301
2387
|
editor: { mode: $("#sp-editor-mode").value, command: $("#sp-editor-cmd").value },
|
|
2302
2388
|
appearance: { chatFontSize: Number($("#sp-chat-fs").value), font: $("#sp-font").value, mono: $("#sp-mono").value },
|
|
@@ -2314,6 +2400,16 @@
|
|
|
2314
2400
|
});
|
|
2315
2401
|
}
|
|
2316
2402
|
|
|
2403
|
+
function updateStatusText() {
|
|
2404
|
+
const u = state.update;
|
|
2405
|
+
const v = state.version ? state.version.version : "?";
|
|
2406
|
+
if (!u || !u.checkedAt) return `This is viberoom ${v}; not checked yet.`;
|
|
2407
|
+
const when = new Date(u.checkedAt).toLocaleString();
|
|
2408
|
+
if (u.available) return `viberoom ${u.latest} is available (this is ${u.current}); checked ${when}.`;
|
|
2409
|
+
if (u.error) return `Could not reach the registry (${u.error}); checked ${when}.`;
|
|
2410
|
+
return `This is viberoom ${u.current}, the latest; checked ${when}.`;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2317
2413
|
function skillBadges(sk) {
|
|
2318
2414
|
const out = [];
|
|
2319
2415
|
if (sk.userInvocable === false) out.push('<span class="badge">vibemate only</span>');
|
|
@@ -3163,6 +3259,8 @@
|
|
|
3163
3259
|
function loadSnapshot(snapshot) {
|
|
3164
3260
|
state.settings = snapshot.settings;
|
|
3165
3261
|
applyAppearance();
|
|
3262
|
+
state.update = snapshot.update || null;
|
|
3263
|
+
renderUpdatePop();
|
|
3166
3264
|
state.version = snapshot.version || null;
|
|
3167
3265
|
state.skills = snapshot.skills || [];
|
|
3168
3266
|
state.recipes = snapshot.recipes || [];
|
|
@@ -3346,6 +3444,11 @@
|
|
|
3346
3444
|
if (state.detailsOpen && state.selection.kind === "me" && !editingInDetails()) renderDetails();
|
|
3347
3445
|
if (state.view === "room") renderSideRoom();
|
|
3348
3446
|
});
|
|
3447
|
+
es.addEventListener("update", (e) => {
|
|
3448
|
+
state.update = JSON.parse(e.data).update;
|
|
3449
|
+
renderUpdatePop();
|
|
3450
|
+
if (state.view === "settings" && !editingInDetails()) renderSettingsPage();
|
|
3451
|
+
});
|
|
3349
3452
|
es.addEventListener("reset", () => location.href = "/");
|
|
3350
3453
|
}
|
|
3351
3454
|
function releaseStream() {
|
|
@@ -3367,21 +3470,33 @@
|
|
|
3367
3470
|
|
|
3368
3471
|
let composerMin = Number(recall("composerH")) || 0;
|
|
3369
3472
|
const composerCeiling = () => Math.max(120, els.app.clientHeight - 260);
|
|
3473
|
+
const fieldSizing = CSS.supports("field-sizing", "content");
|
|
3370
3474
|
let autosizeQueued = false;
|
|
3371
3475
|
function autosizeSoon() {
|
|
3372
|
-
if (autosizeQueued) return;
|
|
3476
|
+
if (fieldSizing || autosizeQueued) return;
|
|
3373
3477
|
autosizeQueued = true;
|
|
3374
3478
|
requestAnimationFrame(() => {
|
|
3375
3479
|
autosizeQueued = false;
|
|
3376
3480
|
autosize();
|
|
3377
3481
|
});
|
|
3378
3482
|
}
|
|
3483
|
+
let composerBounds = "";
|
|
3379
3484
|
function autosize() {
|
|
3380
3485
|
const min = Math.max(36, composerMin);
|
|
3381
3486
|
const cap = Math.max(180, min);
|
|
3487
|
+
if (fieldSizing) {
|
|
3488
|
+
const max = Math.min(composerCeiling(), cap);
|
|
3489
|
+
if (composerBounds === `${min}/${max}`) return;
|
|
3490
|
+
composerBounds = `${min}/${max}`;
|
|
3491
|
+
els.input.style.minHeight = `${min}px`;
|
|
3492
|
+
els.input.style.maxHeight = `${max}px`;
|
|
3493
|
+
return;
|
|
3494
|
+
}
|
|
3382
3495
|
els.input.style.height = "auto";
|
|
3383
3496
|
els.input.style.height = Math.min(composerCeiling(), Math.max(min, Math.min(cap, els.input.scrollHeight))) + "px";
|
|
3384
3497
|
}
|
|
3498
|
+
window.addEventListener("resize", autosize);
|
|
3499
|
+
autosize();
|
|
3385
3500
|
{
|
|
3386
3501
|
const grip = $("#composer-grip");
|
|
3387
3502
|
let drag = null;
|
|
@@ -4042,7 +4157,7 @@
|
|
|
4042
4157
|
if (!nodes.length) return;
|
|
4043
4158
|
const total = els.messages.scrollHeight || 1;
|
|
4044
4159
|
const h = Math.max(0, t.ticks.clientHeight - TICK_H);
|
|
4045
|
-
const tops = nodes.map(
|
|
4160
|
+
const tops = nodes.map(topInList);
|
|
4046
4161
|
const frag = document.createDocumentFragment();
|
|
4047
4162
|
nodes.forEach((el, i) => {
|
|
4048
4163
|
const tick = document.createElement("div");
|
|
@@ -4065,7 +4180,7 @@
|
|
|
4065
4180
|
t.view.style.height = `${Math.max(8, (m.clientHeight / total) * h)}px`;
|
|
4066
4181
|
const top = m.scrollTop;
|
|
4067
4182
|
const bottom = m.scrollTop + m.clientHeight;
|
|
4068
|
-
const inView = t.items.map((el) => el
|
|
4183
|
+
const inView = t.items.map((el) => { const y = topInList(el); return y + el.offsetHeight > top && y < bottom; });
|
|
4069
4184
|
inView.forEach((on, i) => {
|
|
4070
4185
|
const tick = t.ticks.children[i];
|
|
4071
4186
|
if (tick) tick.classList.toggle("in-view", on);
|
|
@@ -4207,8 +4322,10 @@
|
|
|
4207
4322
|
renderPins();
|
|
4208
4323
|
}
|
|
4209
4324
|
function updateTimelineView() { for (const t of timelines) t.updateView(); }
|
|
4325
|
+
const composerFollowers = [$("#timeline"), $("#timeline-left"), els.mentionMenu, els.emojiMenu];
|
|
4210
4326
|
new ResizeObserver(() => {
|
|
4211
|
-
|
|
4327
|
+
const h = `${els.composer.offsetHeight}px`;
|
|
4328
|
+
for (const el of composerFollowers) el.style.setProperty("--composer-h", h);
|
|
4212
4329
|
renderTimeline();
|
|
4213
4330
|
}).observe(els.composer);
|
|
4214
4331
|
new ResizeObserver(() => renderTimeline()).observe(els.messages);
|