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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user