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 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
- for (const file of result.files)
379
- process.stdout.write(`wrote: ${file}\n`);
380
- for (const note of result.notes)
381
- process.stdout.write(`${note}\n`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberoom",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "viberoom: group chat rooms for a human and several coding agents over the Agent Client Protocol",
5
5
  "type": "module",
6
6
  "engines": {
@@ -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. The builder reports to the human, never to the other vibemate. 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."
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
- els.messages.appendChild(d);
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
- els.messages.appendChild(dividerElement(agents));
1622
+ placeInList(dividerElement(agents));
1558
1623
  }
1559
1624
  }
1560
- els.messages.appendChild(messageElement(room, m));
1625
+ placeInList(messageElement(room, m));
1561
1626
  }
1562
1627
  for (const [seq, agents] of markers) {
1563
- if (!placed.has(seq)) els.messages.appendChild(dividerElement(agents));
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
- els.messages.appendChild(messageElement(room, m));
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((el) => el.offsetTop);
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.offsetTop + el.offsetHeight > top && el.offsetTop < bottom);
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
- els.app.style.setProperty("--composer-h", `${els.composer.offsetHeight}px`);
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);