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 +2 -0
- package/dist/launcher.js +7 -1
- package/dist/main.js +43 -6
- package/dist/tui.js +62 -0
- package/package.json +1 -1
- package/templates/design-critique/template.json +4 -2
- package/templates/explainer/template.json +2 -1
- package/templates/pair-programmer/template.json +2 -1
- package/templates/wren-and-quinn/template.json +5 -3
- package/ui/app.css +3 -0
- package/ui/app.js +43 -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/launcher.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
2
|
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, posix, win32 } from "node:path";
|
|
4
|
-
const COMMANDS = new Set(["run", "serve", "start", "stop", "status", "open", "logs", "help"]);
|
|
4
|
+
const COMMANDS = new Set(["run", "serve", "start", "stop", "status", "open", "logs", "doctor", "help"]);
|
|
5
5
|
export function splitCommand(argv) {
|
|
6
6
|
const first = argv[0];
|
|
7
7
|
if (first && !first.startsWith("-") && COMMANDS.has(first))
|
|
@@ -174,6 +174,12 @@ export function appWindowArgs(url, profileDir, freshProfile, placement = null, p
|
|
|
174
174
|
args.push("--class=viberoom");
|
|
175
175
|
return args;
|
|
176
176
|
}
|
|
177
|
+
export function browserAdvice(chromium, platform = process.platform) {
|
|
178
|
+
if (chromium)
|
|
179
|
+
return null;
|
|
180
|
+
const names = platform === "darwin" ? "Chrome, Edge, Brave or Chromium" : platform === "win32" ? "Chrome, Edge or Chromium" : "google-chrome, chromium, microsoft-edge or brave-browser on PATH";
|
|
181
|
+
return `No Chromium-based browser found (${names}); viberoom opens in a tab of your default browser instead. Install one of them for the app window.`;
|
|
182
|
+
}
|
|
177
183
|
export function openUrlCommand(url, platform = process.platform) {
|
|
178
184
|
if (platform === "win32")
|
|
179
185
|
return `start "" "${url}"`;
|
package/dist/main.js
CHANGED
|
@@ -8,9 +8,10 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { Hub } from "./hub.js";
|
|
9
9
|
import { Logger } from "./log.js";
|
|
10
10
|
import { startServer } from "./server.js";
|
|
11
|
-
import { appWindowArgs, recordedWindowPlacement, savedWindowPlacement, findChromium, isProcessAlive, logFilePath, openUrlCommand, pidFilePath, readPidFile, rotateLog, splitCommand, tailFile, writePidFile, } from "./launcher.js";
|
|
11
|
+
import { appWindowArgs, recordedWindowPlacement, savedWindowPlacement, browserAdvice, findChromium, isProcessAlive, logFilePath, openUrlCommand, pidFilePath, readPidFile, rotateLog, splitCommand, tailFile, writePidFile, } from "./launcher.js";
|
|
12
12
|
import { aumidSyncScript, installShortcuts, windowsShortcutPaths } from "./shortcuts.js";
|
|
13
|
-
import { runMenu } from "./tui.js";
|
|
13
|
+
import { askEnter, renderInstalled, runMenu, unicodeSupported } from "./tui.js";
|
|
14
|
+
import { listRecipes } from "./recipes.js";
|
|
14
15
|
function parseArgs(argv) {
|
|
15
16
|
const { command, rest } = splitCommand(argv);
|
|
16
17
|
const options = {
|
|
@@ -77,6 +78,7 @@ Commands
|
|
|
77
78
|
status show whether a hub is running, its build and address
|
|
78
79
|
open open the window of the running hub
|
|
79
80
|
logs print the last lines of the background hub's log
|
|
81
|
+
doctor check Node, the browser, the coding agents and the hub; say what is missing and why
|
|
80
82
|
|
|
81
83
|
Options
|
|
82
84
|
--port localhost port for the web UI (default 4810)
|
|
@@ -198,9 +200,39 @@ function openWindow(url, options, log) {
|
|
|
198
200
|
}
|
|
199
201
|
return;
|
|
200
202
|
}
|
|
203
|
+
const advice = options.browser ? null : browserAdvice(null);
|
|
204
|
+
if (advice) {
|
|
205
|
+
log.warn(advice);
|
|
206
|
+
process.stderr.write(`${advice}\n`);
|
|
207
|
+
try {
|
|
208
|
+
appendFileSync(logFilePath(options.dataDir), `[${new Date().toISOString()}] [launcher] ${advice}\n`);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
}
|
|
212
|
+
}
|
|
201
213
|
log.info("opening the default browser");
|
|
202
214
|
exec(openUrlCommand(url), () => undefined);
|
|
203
215
|
}
|
|
216
|
+
async function runDoctor(options, info) {
|
|
217
|
+
const lines = [];
|
|
218
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
219
|
+
lines.push(`viberoom ${info.version} (build ${info.build})`);
|
|
220
|
+
lines.push(`${major >= 22 ? "ok " : "FAIL"} node ${process.versions.node}${major >= 22 ? "" : " (viberoom needs Node 22 or newer: https://nodejs.org)"}`);
|
|
221
|
+
const chromium = findChromium();
|
|
222
|
+
lines.push(chromium ? `ok browser for the app window: ${chromium}` : `warn ${browserAdvice(null)}`);
|
|
223
|
+
const recipes = listRecipes();
|
|
224
|
+
const found = recipes.filter((r) => !r.unavailableReason);
|
|
225
|
+
lines.push(`${found.length ? "ok " : "warn"} coding agents: ${found.length ? found.map((r) => r.vendor).join(", ") : "none found"}${found.length ? "" : " (install and log in to at least one: Claude Code, Codex, Gemini CLI, Cursor, OpenCode or GitHub Copilot)"}`);
|
|
226
|
+
for (const r of recipes.filter((r) => r.unavailableReason))
|
|
227
|
+
lines.push(` ${r.vendor}: ${r.unavailableReason}`);
|
|
228
|
+
const running = await runningInstance(options.port);
|
|
229
|
+
lines.push(running ? `ok hub running at ${running.url} (build ${running.build ?? "unknown"})` : `info no hub on port ${options.port}: start one with "viberoom start" (or "viberoom start --browser" without a Chromium browser)`);
|
|
230
|
+
lines.push(` data: ${options.dataDir}`);
|
|
231
|
+
lines.push(` log: ${logFilePath(options.dataDir)}`);
|
|
232
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
233
|
+
if (major < 22)
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
}
|
|
204
236
|
async function runHub(options, log, info) {
|
|
205
237
|
const background = options.command === "serve";
|
|
206
238
|
if (!background) {
|
|
@@ -367,6 +399,9 @@ async function main() {
|
|
|
367
399
|
openWindow(url, options, log);
|
|
368
400
|
return;
|
|
369
401
|
}
|
|
402
|
+
case "doctor":
|
|
403
|
+
await runDoctor(options, info);
|
|
404
|
+
return;
|
|
370
405
|
case "logs": {
|
|
371
406
|
const path = logFilePath(options.dataDir);
|
|
372
407
|
process.stdout.write(`${path}\n${tailFile(path, 60)}\n`);
|
|
@@ -385,10 +420,12 @@ async function main() {
|
|
|
385
420
|
if (choice === "shortcut") {
|
|
386
421
|
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
387
422
|
const result = installShortcuts({ root: fileURLToPath(new URL("..", import.meta.url)), dataDir: options.dataDir, node: process.execPath, version: pkg.version, desktop: true });
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
process.stdout.write(
|
|
423
|
+
const advice = browserAdvice(findChromium());
|
|
424
|
+
process.stdout.write(renderInstalled({ files: result.files, notes: result.notes, platform: process.platform, browserAdvice: advice && advice.replace("viberoom opens", "the icon opens viberoom") }, { color: !process.env.NO_COLOR, unicode: unicodeSupported(), columns: process.stdout.columns }));
|
|
425
|
+
if (await askEnter()) {
|
|
426
|
+
process.stdout.write("\n");
|
|
427
|
+
await startBackground(options, log, info);
|
|
428
|
+
}
|
|
392
429
|
return;
|
|
393
430
|
}
|
|
394
431
|
}
|
package/dist/tui.js
CHANGED
|
@@ -72,6 +72,68 @@ export function renderDone(choice, title, opts, items = MENU) {
|
|
|
72
72
|
export function menuLineCount(items = MENU) {
|
|
73
73
|
return items.length + 5;
|
|
74
74
|
}
|
|
75
|
+
export function renderInstalled(o, opts) {
|
|
76
|
+
const g = opts.unicode ? GLYPHS.unicode : GLYPHS.ascii;
|
|
77
|
+
const dim = (t) => paint(opts.color, "2", t);
|
|
78
|
+
const bold = (t) => paint(opts.color, "1", t);
|
|
79
|
+
const green = (t) => paint(opts.color, "32", t);
|
|
80
|
+
const bar = paint(opts.color, "36", g.bar);
|
|
81
|
+
const steps = o.platform === "win32"
|
|
82
|
+
? [
|
|
83
|
+
`Press the ${bold("Windows key")}, type ${bold("viberoom")}, press Enter.`,
|
|
84
|
+
`Or double-click ${bold("viberoom")} on the Desktop.`,
|
|
85
|
+
`Pin it: once the window is open, right-click its icon in the taskbar and choose "Pin to taskbar".`,
|
|
86
|
+
]
|
|
87
|
+
: o.platform === "darwin"
|
|
88
|
+
? [
|
|
89
|
+
`Press ${bold("⌘ Space")}, type ${bold("viberoom")}, press Enter (Spotlight); or open it from Launchpad.`,
|
|
90
|
+
`It lives in ${bold("~/Applications/viberoom.app")}; drag it to the Dock to keep it there.`,
|
|
91
|
+
`If macOS asks whether to open it the first time, choose Open: the app was made on this machine.`,
|
|
92
|
+
]
|
|
93
|
+
: [
|
|
94
|
+
`Press the ${bold("Super key")}, type ${bold("viberoom")}, press Enter; it is in the applications menu.`,
|
|
95
|
+
`On the Desktop: right-click ${bold("viberoom.desktop")} and choose "Allow launching" once if your desktop asks.`,
|
|
96
|
+
`Pin it: right-click the running icon in the dock and choose "Add to favorites" (or your desktop's equivalent).`,
|
|
97
|
+
];
|
|
98
|
+
const lines = [
|
|
99
|
+
`${dim(g.top)} ${bold("The desktop icon is installed")}`,
|
|
100
|
+
dim(g.bar),
|
|
101
|
+
`${green(g.done)} ${bold("How to start viberoom from now on")}`,
|
|
102
|
+
...steps.map((t) => `${bar} ${t}`),
|
|
103
|
+
bar,
|
|
104
|
+
`${green(g.done)} ${bold("What happens")}`,
|
|
105
|
+
`${bar} The icon starts the hub in the background and opens the app window.`,
|
|
106
|
+
`${bar} Closing the window keeps the hub running; "viberoom stop" in a terminal ends it.`,
|
|
107
|
+
`${bar} A newer version: the app tells you with a bubble over your avatar (Settings → Updates).`,
|
|
108
|
+
];
|
|
109
|
+
if (o.browserAdvice)
|
|
110
|
+
lines.push(bar, `${paint(opts.color, "33", "!")} ${o.browserAdvice}`);
|
|
111
|
+
if (o.files.length || o.notes.length) {
|
|
112
|
+
lines.push(bar, `${green(g.done)} ${bold("Written")}`);
|
|
113
|
+
for (const f of o.files)
|
|
114
|
+
lines.push(`${bar} ${dim(f)}`);
|
|
115
|
+
for (const n of o.notes)
|
|
116
|
+
lines.push(`${bar} ${dim(n)}`);
|
|
117
|
+
}
|
|
118
|
+
lines.push(bar, `${dim(g.bottom)} ${dim("Press Enter to open viberoom now, or q to leave it for later.")}`);
|
|
119
|
+
return lines.join("\n") + "\n";
|
|
120
|
+
}
|
|
121
|
+
export function askEnter(stdin = process.stdin, stdout = process.stdout) {
|
|
122
|
+
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function")
|
|
123
|
+
return Promise.resolve(false);
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
const onData = (data) => {
|
|
126
|
+
stdin.off("data", onData);
|
|
127
|
+
stdin.setRawMode(false);
|
|
128
|
+
stdin.pause();
|
|
129
|
+
const s = data.toString();
|
|
130
|
+
resolve(s === "\r" || s === "\n");
|
|
131
|
+
};
|
|
132
|
+
stdin.setRawMode(true);
|
|
133
|
+
stdin.resume();
|
|
134
|
+
stdin.on("data", onData);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
75
137
|
export function runMenu(title, stdin = process.stdin, stdout = process.stdout) {
|
|
76
138
|
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function")
|
|
77
139
|
return Promise.resolve(null);
|
package/package.json
CHANGED
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
{
|
|
14
14
|
"name": "Proposer",
|
|
15
15
|
"tagline": "makes the strongest case for a design",
|
|
16
|
-
"role": "You propose designs and defend them with evidence from the code. State the approach, its cost, and what it makes easy. When the Skeptic finds a real hole, concede it and adjust the proposal rather than restating it. You read code; you never edit it."
|
|
16
|
+
"role": "You propose designs and defend them with evidence from the code. State the approach, its cost, and what it makes easy. When the Skeptic finds a real hole, concede it and adjust the proposal rather than restating it. You read code; you never edit it.",
|
|
17
|
+
"avatar": "🎨"
|
|
17
18
|
},
|
|
18
19
|
{
|
|
19
20
|
"name": "Skeptic",
|
|
20
21
|
"tagline": "finds what breaks it",
|
|
21
|
-
"role": "You look for what breaks a proposed design: the edge case, the migration cost, the thing that exists in the code and contradicts the plan. Name files and lines. Be specific and brief; a hole is worth more than a list of doubts. When a proposal survives, say so. You read code; you never edit it."
|
|
22
|
+
"role": "You look for what breaks a proposed design: the edge case, the migration cost, the thing that exists in the code and contradicts the plan. Name files and lines. Be specific and brief; a hole is worth more than a list of doubts. When a proposal survives, say so. You read code; you never edit it.",
|
|
23
|
+
"avatar": "🧐"
|
|
22
24
|
}
|
|
23
25
|
]
|
|
24
26
|
}
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
{
|
|
12
12
|
"name": "Explainer",
|
|
13
13
|
"tagline": "explains the code, never touches it",
|
|
14
|
-
"role": "You explain how this codebase works. Read whatever you need, then answer in plain language with file paths and line numbers, quoting the code when it settles a question. Structure long answers as a short walk through the flow. You never edit, create or delete files."
|
|
14
|
+
"role": "You explain how this codebase works. Read whatever you need, then answer in plain language with file paths and line numbers, quoting the code when it settles a question. Structure long answers as a short walk through the flow. You never edit, create or delete files.",
|
|
15
|
+
"avatar": "🎓"
|
|
15
16
|
}
|
|
16
17
|
]
|
|
17
18
|
}
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
{
|
|
12
12
|
"name": "Pair",
|
|
13
13
|
"tagline": "codes with you, one step at a time",
|
|
14
|
-
"role": "You are a pair programmer. The human drives: you read the code first, make one small change at a time, run or test it, and report what changed with file paths. Propose the next step; do not take it until asked. When unsure, ask one precise question rather than guessing."
|
|
14
|
+
"role": "You are a pair programmer. The human drives: you read the code first, make one small change at a time, run or test it, and report what changed with file paths. Propose the next step; do not take it until asked. When unsure, ask one precise question rather than guessing.",
|
|
15
|
+
"avatar": "🤝"
|
|
15
16
|
}
|
|
16
17
|
]
|
|
17
18
|
}
|
|
@@ -11,18 +11,20 @@
|
|
|
11
11
|
"turnTaking": "parallel",
|
|
12
12
|
"agentsWakeEachOther": true,
|
|
13
13
|
"hopLimit": 24,
|
|
14
|
-
"customRules": "Both build, both explain, both review; @ decides who does what. Without @, a task goes to the one who has worked on that part the most, or most recently; if neither has touched it, to Wren. A question without @ goes to Quinn.\nThe human decides when work is reviewed: ask the other one to review it. Unasked, the other answers questions and otherwise stays silent; it does not review, comment on or extend work it was not asked about. When asked to review, first say in one line what you will check, then check it by that criterion: a number, a live check, the risky case.
|
|
14
|
+
"customRules": "Both build, both explain, both review; @ decides who does what. Without @, a task goes to the one who has worked on that part the most, or most recently; if neither has touched it, to Wren. A question without @ goes to Quinn.\nThe human decides when work is reviewed: ask the other one to review it. Unasked, the other answers questions and otherwise stays silent; it does not review, comment on or extend work it was not asked about. When asked to review, first say in one line what you will check, then check it by that criterion: a number, a live check, the risky case. Whoever built reports to the human. Address the other vibemate too only when the report changes something it works on or relies on: a shared file, an interface, a convention, a measurement that overturns its claim; then say in one line what you want from it (\"nothing, for your context\" counts). Otherwise it reads the report later, unaddressed. A report is never a request for review; only the human asks for one. A build is reported as: what changed (the files, and the commit if the project uses version control), how to verify it (a command, a script, a screenshot), what was not verified, what is left.\nMeasure before diagnosing; say no with a reason and a cheaper alternative; keep replies short.\nWork only on your own task, never on the other's; do not touch what the other is working on.\nReply only when addressed. On a message to everyone, only the one it concerns answers; the other stays silent. Never add to, correct or comment on the other's reply unless the human asks.\nFollow the conventions of the project you work in (its instructions, notes, tests). Where it keeps decision notes, write the decision there before reporting it here.\nRead every proposal and analyse it; agree or reject only with a real argument. Never concede to be agreeable, never object to seem rigorous.\nReply in the human's language. Code identifiers, commands, file paths, protocol and tool names stay exactly as in the code; established technical terms (kernel, thread, commit, pull request, layout) stay in English."
|
|
15
15
|
},
|
|
16
16
|
"vibemates": [
|
|
17
17
|
{
|
|
18
18
|
"name": "Wren",
|
|
19
19
|
"tagline": "builds by default; explains and reviews when asked",
|
|
20
|
-
"role": "You are Wren, one of two equal vibemates, Wren and Quinn. You lean to building: a task without an address is yours when it touches what you have built, or when neither of you has; before you change anything, read what is already there, the notes, the docs and the code, and keep each change small. Everything else is in the room rules."
|
|
20
|
+
"role": "You are Wren, one of two equal vibemates, Wren and Quinn. You lean to building: a task without an address is yours when it touches what you have built, or when neither of you has; before you change anything, read what is already there, the notes, the docs and the code, and keep each change small. Everything else is in the room rules.",
|
|
21
|
+
"avatar": "🔨"
|
|
21
22
|
},
|
|
22
23
|
{
|
|
23
24
|
"name": "Quinn",
|
|
24
25
|
"tagline": "explains by default; builds and reviews when asked",
|
|
25
|
-
"role": "You are Quinn, one of two equal vibemates, Wren and Quinn. You lean to explaining: a question without an address is yours; when you review, it is by a criterion, not an impression. Everything else is in the room rules."
|
|
26
|
+
"role": "You are Quinn, one of two equal vibemates, Wren and Quinn. You lean to explaining: a question without an address is yours; when you review, it is by a criterion, not an impression. Everything else is in the room rules.",
|
|
27
|
+
"avatar": "💡"
|
|
26
28
|
}
|
|
27
29
|
]
|
|
28
30
|
}
|
package/ui/app.css
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1622
|
+
placeInList(dividerElement(agents));
|
|
1603
1623
|
}
|
|
1604
1624
|
}
|
|
1605
|
-
|
|
1625
|
+
placeInList(messageElement(room, m));
|
|
1606
1626
|
}
|
|
1607
1627
|
for (const [seq, agents] of markers) {
|
|
1608
|
-
if (!placed.has(seq))
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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);
|