feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall

- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
2026-10-08 23:04:57 +07:00
parent a9c71cd905
commit b7f3735b29
147 changed files with 5644 additions and 2561 deletions
+53 -6
View File
@@ -6,8 +6,12 @@
* both directions; text WS frames carry small JSON control messages
* (`resize`, and an outbound `exit` sent once when the pane process/tmux
* session ends). Multiple browser tabs each get their own PTY attach
* process — tmux itself is what keeps them all in sync, this module does no
* cross-connection coordination.
* process, and ONE of them sizes the window: the DRIVER — the first to attach,
* then whichever sent `{type:"resize", drive:true}` last (the client does on a
* click or a keystroke). Every other attach is a follower: its PTY is held at
* the driver's size and told so (`{type:"size", cols, rows}`) so its xterm
* renders the same grid, scaled to fit, instead of each browser resizing tmux
* under the others. A driver that leaves sends `{type:"lead"}` to the rest.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -61,6 +65,28 @@ function attachBinary(platform = process.platform) {
}
}
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }> }
const groups = new Map();
const tell = (v, msg) => {
try {
v.ws.send(JSON.stringify(msg), { binary: false });
} catch {
/* client gone — its close handler cleans up */
}
};
/** `v` sizes the window now: every other attach is held at its size and told. */
function drive(group, v) {
group.driver = v;
v.pty.resize(v.cols, v.rows);
for (const o of group.viewers) {
if (o === v) continue;
o.pty.resize(v.cols, v.rows);
tell(o, { type: "size", cols: v.cols, rows: v.rows });
}
}
/**
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
* `tmux attach-session -t <runId>` per call.
@@ -75,11 +101,20 @@ function attach(ws, runId, { cols, rows }) {
} catch {
/* session gone or tmux unavailable — attach below will surface that */
}
let group = groups.get(runId);
if (!group) groups.set(runId, (group = { driver: null, viewers: new Set() }));
const lead = group.driver;
// A follower attaches at the driver's size: tmux sizes the window to the
// newest client, so its own size would resize the driver's pane first.
const pty = spawnImpl(attachBinary(), ["attach-session", "-t", tmux.session(runId)], {
name: "xterm-256color",
cols: cols || 80,
rows: rows || 24,
cols: lead ? lead.cols : cols || 80,
rows: lead ? lead.rows : rows || 24,
});
const me = { ws, pty, cols: cols || 80, rows: rows || 24 };
group.viewers.add(me);
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
else group.driver = me;
// Backpressure: a slow viewer (a remote browser on a thin link) must not
// queue a chatty pane's output in this process without bound. Past 1 MB
@@ -143,7 +178,11 @@ function attach(ws, runId, { cols, rows }) {
return;
}
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
pty.resize(msg.cols, msg.rows);
me.cols = msg.cols;
me.rows = msg.rows;
// A follower's own size is remembered, not applied: its PTY stays at
// the driver's until it takes the lead.
if (msg.drive || group.driver === me || !group.driver) drive(group, me);
return;
}
}
@@ -151,6 +190,12 @@ function attach(ws, runId, { cols, rows }) {
});
ws.on("close", () => {
group.viewers.delete(me);
if (!group.viewers.size) groups.delete(runId);
else if (group.driver === me) {
group.driver = null;
for (const o of group.viewers) tell(o, { type: "lead" });
}
try {
pty.kill();
} catch {
@@ -173,4 +218,6 @@ function attach(ws, runId, { cols, rows }) {
// resolves a working native binding with no compiler needed.
__setSpawnImpl((...args) => require("@lydell/node-pty").spawn(...args));
module.exports = { attach, attachBinary, validateRunId, __setSpawnImpl };
const __resetGroups = () => groups.clear();
module.exports = { attach, attachBinary, validateRunId, __setSpawnImpl, __resetGroups };