viberoom 0.5.4 → 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/launcher.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
2
  import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join, posix, win32 } from "node:path";
4
- const COMMANDS = new Set(["run", "serve", "start", "stop", "status", "open", "logs", "help"]);
4
+ const COMMANDS = new Set(["run", "serve", "start", "stop", "status", "open", "logs", "doctor", "help"]);
5
5
  export function splitCommand(argv) {
6
6
  const first = argv[0];
7
7
  if (first && !first.startsWith("-") && COMMANDS.has(first))
@@ -174,6 +174,12 @@ export function appWindowArgs(url, profileDir, freshProfile, placement = null, p
174
174
  args.push("--class=viberoom");
175
175
  return args;
176
176
  }
177
+ export function browserAdvice(chromium, platform = process.platform) {
178
+ if (chromium)
179
+ return null;
180
+ const names = platform === "darwin" ? "Chrome, Edge, Brave or Chromium" : platform === "win32" ? "Chrome, Edge or Chromium" : "google-chrome, chromium, microsoft-edge or brave-browser on PATH";
181
+ return `No Chromium-based browser found (${names}); viberoom opens in a tab of your default browser instead. Install one of them for the app window.`;
182
+ }
177
183
  export function openUrlCommand(url, platform = process.platform) {
178
184
  if (platform === "win32")
179
185
  return `start "" "${url}"`;
package/dist/main.js CHANGED
@@ -8,9 +8,10 @@ import { fileURLToPath } from "node:url";
8
8
  import { Hub } from "./hub.js";
9
9
  import { Logger } from "./log.js";
10
10
  import { startServer } from "./server.js";
11
- import { appWindowArgs, recordedWindowPlacement, savedWindowPlacement, findChromium, isProcessAlive, logFilePath, openUrlCommand, pidFilePath, readPidFile, rotateLog, splitCommand, tailFile, writePidFile, } from "./launcher.js";
11
+ import { appWindowArgs, recordedWindowPlacement, savedWindowPlacement, browserAdvice, findChromium, isProcessAlive, logFilePath, openUrlCommand, pidFilePath, readPidFile, rotateLog, splitCommand, tailFile, writePidFile, } from "./launcher.js";
12
12
  import { aumidSyncScript, installShortcuts, windowsShortcutPaths } from "./shortcuts.js";
13
- import { runMenu } from "./tui.js";
13
+ import { askEnter, renderInstalled, runMenu, unicodeSupported } from "./tui.js";
14
+ import { listRecipes } from "./recipes.js";
14
15
  function parseArgs(argv) {
15
16
  const { command, rest } = splitCommand(argv);
16
17
  const options = {
@@ -77,6 +78,7 @@ Commands
77
78
  status show whether a hub is running, its build and address
78
79
  open open the window of the running hub
79
80
  logs print the last lines of the background hub's log
81
+ doctor check Node, the browser, the coding agents and the hub; say what is missing and why
80
82
 
81
83
  Options
82
84
  --port localhost port for the web UI (default 4810)
@@ -198,9 +200,39 @@ function openWindow(url, options, log) {
198
200
  }
199
201
  return;
200
202
  }
203
+ const advice = options.browser ? null : browserAdvice(null);
204
+ if (advice) {
205
+ log.warn(advice);
206
+ process.stderr.write(`${advice}\n`);
207
+ try {
208
+ appendFileSync(logFilePath(options.dataDir), `[${new Date().toISOString()}] [launcher] ${advice}\n`);
209
+ }
210
+ catch {
211
+ }
212
+ }
201
213
  log.info("opening the default browser");
202
214
  exec(openUrlCommand(url), () => undefined);
203
215
  }
216
+ async function runDoctor(options, info) {
217
+ const lines = [];
218
+ const major = Number(process.versions.node.split(".")[0]);
219
+ lines.push(`viberoom ${info.version} (build ${info.build})`);
220
+ lines.push(`${major >= 22 ? "ok " : "FAIL"} node ${process.versions.node}${major >= 22 ? "" : " (viberoom needs Node 22 or newer: https://nodejs.org)"}`);
221
+ const chromium = findChromium();
222
+ lines.push(chromium ? `ok browser for the app window: ${chromium}` : `warn ${browserAdvice(null)}`);
223
+ const recipes = listRecipes();
224
+ const found = recipes.filter((r) => !r.unavailableReason);
225
+ lines.push(`${found.length ? "ok " : "warn"} coding agents: ${found.length ? found.map((r) => r.vendor).join(", ") : "none found"}${found.length ? "" : " (install and log in to at least one: Claude Code, Codex, Gemini CLI, Cursor, OpenCode or GitHub Copilot)"}`);
226
+ for (const r of recipes.filter((r) => r.unavailableReason))
227
+ lines.push(` ${r.vendor}: ${r.unavailableReason}`);
228
+ const running = await runningInstance(options.port);
229
+ lines.push(running ? `ok hub running at ${running.url} (build ${running.build ?? "unknown"})` : `info no hub on port ${options.port}: start one with "viberoom start" (or "viberoom start --browser" without a Chromium browser)`);
230
+ lines.push(` data: ${options.dataDir}`);
231
+ lines.push(` log: ${logFilePath(options.dataDir)}`);
232
+ process.stdout.write(lines.join("\n") + "\n");
233
+ if (major < 22)
234
+ process.exitCode = 1;
235
+ }
204
236
  async function runHub(options, log, info) {
205
237
  const background = options.command === "serve";
206
238
  if (!background) {
@@ -367,6 +399,9 @@ async function main() {
367
399
  openWindow(url, options, log);
368
400
  return;
369
401
  }
402
+ case "doctor":
403
+ await runDoctor(options, info);
404
+ return;
370
405
  case "logs": {
371
406
  const path = logFilePath(options.dataDir);
372
407
  process.stdout.write(`${path}\n${tailFile(path, 60)}\n`);
@@ -385,10 +420,12 @@ async function main() {
385
420
  if (choice === "shortcut") {
386
421
  const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
387
422
  const result = installShortcuts({ root: fileURLToPath(new URL("..", import.meta.url)), dataDir: options.dataDir, node: process.execPath, version: pkg.version, desktop: true });
388
- for (const file of result.files)
389
- process.stdout.write(`wrote: ${file}\n`);
390
- for (const note of result.notes)
391
- 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
+ }
392
429
  return;
393
430
  }
394
431
  }
package/dist/tui.js CHANGED
@@ -72,6 +72,68 @@ export function renderDone(choice, title, opts, items = MENU) {
72
72
  export function menuLineCount(items = MENU) {
73
73
  return items.length + 5;
74
74
  }
75
+ export function renderInstalled(o, opts) {
76
+ const g = opts.unicode ? GLYPHS.unicode : GLYPHS.ascii;
77
+ const dim = (t) => paint(opts.color, "2", t);
78
+ const bold = (t) => paint(opts.color, "1", t);
79
+ const green = (t) => paint(opts.color, "32", t);
80
+ const bar = paint(opts.color, "36", g.bar);
81
+ const steps = o.platform === "win32"
82
+ ? [
83
+ `Press the ${bold("Windows key")}, type ${bold("viberoom")}, press Enter.`,
84
+ `Or double-click ${bold("viberoom")} on the Desktop.`,
85
+ `Pin it: once the window is open, right-click its icon in the taskbar and choose "Pin to taskbar".`,
86
+ ]
87
+ : o.platform === "darwin"
88
+ ? [
89
+ `Press ${bold("⌘ Space")}, type ${bold("viberoom")}, press Enter (Spotlight); or open it from Launchpad.`,
90
+ `It lives in ${bold("~/Applications/viberoom.app")}; drag it to the Dock to keep it there.`,
91
+ `If macOS asks whether to open it the first time, choose Open: the app was made on this machine.`,
92
+ ]
93
+ : [
94
+ `Press the ${bold("Super key")}, type ${bold("viberoom")}, press Enter; it is in the applications menu.`,
95
+ `On the Desktop: right-click ${bold("viberoom.desktop")} and choose "Allow launching" once if your desktop asks.`,
96
+ `Pin it: right-click the running icon in the dock and choose "Add to favorites" (or your desktop's equivalent).`,
97
+ ];
98
+ const lines = [
99
+ `${dim(g.top)} ${bold("The desktop icon is installed")}`,
100
+ dim(g.bar),
101
+ `${green(g.done)} ${bold("How to start viberoom from now on")}`,
102
+ ...steps.map((t) => `${bar} ${t}`),
103
+ bar,
104
+ `${green(g.done)} ${bold("What happens")}`,
105
+ `${bar} The icon starts the hub in the background and opens the app window.`,
106
+ `${bar} Closing the window keeps the hub running; "viberoom stop" in a terminal ends it.`,
107
+ `${bar} A newer version: the app tells you with a bubble over your avatar (Settings → Updates).`,
108
+ ];
109
+ if (o.browserAdvice)
110
+ lines.push(bar, `${paint(opts.color, "33", "!")} ${o.browserAdvice}`);
111
+ if (o.files.length || o.notes.length) {
112
+ lines.push(bar, `${green(g.done)} ${bold("Written")}`);
113
+ for (const f of o.files)
114
+ lines.push(`${bar} ${dim(f)}`);
115
+ for (const n of o.notes)
116
+ lines.push(`${bar} ${dim(n)}`);
117
+ }
118
+ lines.push(bar, `${dim(g.bottom)} ${dim("Press Enter to open viberoom now, or q to leave it for later.")}`);
119
+ return lines.join("\n") + "\n";
120
+ }
121
+ export function askEnter(stdin = process.stdin, stdout = process.stdout) {
122
+ if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function")
123
+ return Promise.resolve(false);
124
+ return new Promise((resolve) => {
125
+ const onData = (data) => {
126
+ stdin.off("data", onData);
127
+ stdin.setRawMode(false);
128
+ stdin.pause();
129
+ const s = data.toString();
130
+ resolve(s === "\r" || s === "\n");
131
+ };
132
+ stdin.setRawMode(true);
133
+ stdin.resume();
134
+ stdin.on("data", onData);
135
+ });
136
+ }
75
137
  export function runMenu(title, stdin = process.stdin, stdout = process.stdout) {
76
138
  if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function")
77
139
  return Promise.resolve(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberoom",
3
- "version": "0.5.4",
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
@@ -206,6 +206,7 @@
206
206
  .day { align-self: center; color: var(--faint); font-size: 11px; font-weight: 800; padding: 3px 12px; margin: 2px 0; }
207
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); }
208
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; }
209
210
  .msg.agent { padding-right: 40px; }
210
211
  .msg.mine { align-items: flex-end; padding-left: 40px; }
211
212
  .head-av { display: inline-flex; width: 32px; height: 32px; padding: 4px; box-sizing: content-box; flex: none; }
@@ -214,6 +215,7 @@
214
215
  .msg.mine .head { flex-direction: row-reverse; }
215
216
  .msg.system { justify-content: center; }
216
217
  .msg.hidden-by-search { display: none; }
218
+ .messages.searching .msgs-page { content-visibility: visible; }
217
219
  .sys { color: var(--muted); font-size: 11px; font-weight: 700; padding: 2px 10px; max-width: 80%; text-align: center; }
218
220
  .sys.warn { color: var(--warm-ink); background: var(--warm); border-radius: var(--r-pill); padding: 6px 12px; font-weight: 800; }
219
221
  .bubble-col { display: flex; flex-direction: column; gap: 6px; min-width: 0; width: 100%; max-width: min(1040px, 100%); }
@@ -494,6 +496,7 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
494
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; }
495
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; }
496
498
  .composer textarea::placeholder { color: var(--placeholder); font-weight: 600; }
499
+ @supports (field-sizing: content) { .composer textarea { field-sizing: content; height: auto; } }
497
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); }
498
501
  .send-btn .i { width: 18px; height: 18px; display: block; }
499
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
@@ -1574,9 +1574,29 @@
1574
1574
  return el;
1575
1575
  }
1576
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
+
1577
1596
  function renderMessages() {
1578
1597
  const room = currentRoom();
1579
1598
  els.messages.innerHTML = "";
1599
+ els.messages.classList.toggle("searching", !!state.search);
1580
1600
  if (!room) return;
1581
1601
  if (!room.messages.length) {
1582
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>`;
@@ -1592,20 +1612,20 @@
1592
1612
  d.className = "day";
1593
1613
  d.textContent = day;
1594
1614
  d.title = new Date(m.ts).toLocaleDateString([], { weekday: "long", day: "numeric", month: "long", year: "numeric" });
1595
- els.messages.appendChild(d);
1615
+ placeInList(d);
1596
1616
  lastDay = day;
1597
1617
  }
1598
1618
  for (const [seq, agents] of markers) {
1599
1619
  if (placed.has(seq) || !(m.seq >= seq)) continue;
1600
1620
  if (m.seq > 0) {
1601
1621
  placed.add(seq);
1602
- els.messages.appendChild(dividerElement(agents));
1622
+ placeInList(dividerElement(agents));
1603
1623
  }
1604
1624
  }
1605
- els.messages.appendChild(messageElement(room, m));
1625
+ placeInList(messageElement(room, m));
1606
1626
  }
1607
1627
  for (const [seq, agents] of markers) {
1608
- if (!placed.has(seq)) els.messages.appendChild(dividerElement(agents));
1628
+ if (!placed.has(seq)) placeInList(dividerElement(agents));
1609
1629
  }
1610
1630
  for (const perm of room.permissions) renderPermission(room, perm);
1611
1631
  refreshSeen(room);
@@ -1638,7 +1658,7 @@
1638
1658
  else {
1639
1659
  const empty = els.messages.querySelector(".empty");
1640
1660
  if (empty) empty.remove();
1641
- els.messages.appendChild(messageElement(room, m));
1661
+ placeInList(messageElement(room, m));
1642
1662
  if (m.from === "human") refreshSeen(room);
1643
1663
  if (m.streaming && m.from !== "human") renderSideRoom();
1644
1664
  else if (!stick && m.kind === "chat") noteNew(room, m);
@@ -3450,21 +3470,33 @@
3450
3470
 
3451
3471
  let composerMin = Number(recall("composerH")) || 0;
3452
3472
  const composerCeiling = () => Math.max(120, els.app.clientHeight - 260);
3473
+ const fieldSizing = CSS.supports("field-sizing", "content");
3453
3474
  let autosizeQueued = false;
3454
3475
  function autosizeSoon() {
3455
- if (autosizeQueued) return;
3476
+ if (fieldSizing || autosizeQueued) return;
3456
3477
  autosizeQueued = true;
3457
3478
  requestAnimationFrame(() => {
3458
3479
  autosizeQueued = false;
3459
3480
  autosize();
3460
3481
  });
3461
3482
  }
3483
+ let composerBounds = "";
3462
3484
  function autosize() {
3463
3485
  const min = Math.max(36, composerMin);
3464
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
+ }
3465
3495
  els.input.style.height = "auto";
3466
3496
  els.input.style.height = Math.min(composerCeiling(), Math.max(min, Math.min(cap, els.input.scrollHeight))) + "px";
3467
3497
  }
3498
+ window.addEventListener("resize", autosize);
3499
+ autosize();
3468
3500
  {
3469
3501
  const grip = $("#composer-grip");
3470
3502
  let drag = null;
@@ -4125,7 +4157,7 @@
4125
4157
  if (!nodes.length) return;
4126
4158
  const total = els.messages.scrollHeight || 1;
4127
4159
  const h = Math.max(0, t.ticks.clientHeight - TICK_H);
4128
- const tops = nodes.map((el) => el.offsetTop);
4160
+ const tops = nodes.map(topInList);
4129
4161
  const frag = document.createDocumentFragment();
4130
4162
  nodes.forEach((el, i) => {
4131
4163
  const tick = document.createElement("div");
@@ -4148,7 +4180,7 @@
4148
4180
  t.view.style.height = `${Math.max(8, (m.clientHeight / total) * h)}px`;
4149
4181
  const top = m.scrollTop;
4150
4182
  const bottom = m.scrollTop + m.clientHeight;
4151
- 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; });
4152
4184
  inView.forEach((on, i) => {
4153
4185
  const tick = t.ticks.children[i];
4154
4186
  if (tick) tick.classList.toggle("in-view", on);
@@ -4290,8 +4322,10 @@
4290
4322
  renderPins();
4291
4323
  }
4292
4324
  function updateTimelineView() { for (const t of timelines) t.updateView(); }
4325
+ const composerFollowers = [$("#timeline"), $("#timeline-left"), els.mentionMenu, els.emojiMenu];
4293
4326
  new ResizeObserver(() => {
4294
- 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);
4295
4329
  renderTimeline();
4296
4330
  }).observe(els.composer);
4297
4331
  new ResizeObserver(() => renderTimeline()).observe(els.messages);