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:
@@ -31,7 +31,10 @@
|
||||
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
|
||||
* READ the user's clipboard). While the socket is down a "Reconnecting…"
|
||||
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
|
||||
* most every 150 ms.
|
||||
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
||||
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
||||
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
|
||||
* here takes the lead back (`resize` with `drive: true`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
@@ -328,6 +331,25 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let ws: WebSocket | null = null;
|
||||
// Another browser drives this pane's size: the grid it set, or null.
|
||||
let follow: { cols: number; rows: number } | null = null;
|
||||
// A follower's grid may not fit this container: shrink it (never grow).
|
||||
const scaleToFit = () => {
|
||||
const el = term.element;
|
||||
const box = containerRef.current;
|
||||
if (!el || !box) return;
|
||||
el.style.transformOrigin = "0 0";
|
||||
el.style.transform = "";
|
||||
// `.xterm` is a block as wide as the box; the grid's size is the screen's.
|
||||
const grid = el.querySelector<HTMLElement>(".xterm-screen");
|
||||
if (!follow || !grid?.offsetWidth || !grid.offsetHeight) return;
|
||||
const s = Math.min(
|
||||
1,
|
||||
box.clientWidth / grid.offsetWidth,
|
||||
box.clientHeight / grid.offsetHeight
|
||||
);
|
||||
if (s < 1) el.style.transform = `scale(${s})`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tell the server this terminal's size. tmux draws the pane at whatever
|
||||
@@ -342,10 +364,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
* (`display:none` leaves the container 0×0), and fitting to that would
|
||||
* resize the real tmux pane down to nothing.
|
||||
*/
|
||||
const sendSizeNow = () => {
|
||||
const sendSizeNow = (drive = false) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (term.cols < 2 || term.rows < 2) return;
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
|
||||
};
|
||||
// This browser is the one in use now: back to its own size, and the
|
||||
// pane (and every other viewer) follows it.
|
||||
const takeLead = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||
follow = null;
|
||||
scaleToFit();
|
||||
fit.fit();
|
||||
sendSizeNow(true);
|
||||
};
|
||||
// A divider drag or a window resize fires a resize per frame; tmux and the
|
||||
// program in the pane redraw on each, so the LAST size is sent once things
|
||||
@@ -400,7 +432,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
if (data.startsWith('{"type"')) {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === "exit") {
|
||||
if (msg.type === "size") {
|
||||
follow = { cols: msg.cols, rows: msg.rows };
|
||||
term.resize(msg.cols, msg.rows);
|
||||
requestAnimationFrame(scaleToFit);
|
||||
} else if (msg.type === "lead") {
|
||||
takeLead();
|
||||
} else if (msg.type === "exit") {
|
||||
ended = true;
|
||||
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
||||
onExitRef.current?.();
|
||||
@@ -429,6 +467,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
connect();
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
// A keystroke takes the lead; a mouse report (wheel, hover) does not.
|
||||
if (follow && !/^\x1b\[(<|M)/.test(data)) takeLead();
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||
});
|
||||
|
||||
@@ -438,16 +478,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
const resizeDisposable = term.onResize(sendSize);
|
||||
|
||||
// Re-assert on focus. Several attach clients can share one tmux session
|
||||
// (a second tab, another lane pane), and tmux's `window-size latest` sizes
|
||||
// the window to whichever client was last active — resizing this pane out
|
||||
// from under a terminal that is never told. Coming back to it says so.
|
||||
const reassert = () => sendSizeNow();
|
||||
// (a second tab, another browser): coming back to this one makes it the
|
||||
// driver again, and the others follow its size.
|
||||
const reassert = takeLead;
|
||||
term.textarea?.addEventListener("focus", reassert);
|
||||
|
||||
const refit = () => {
|
||||
// Never fit a collapsed console — see sendSize.
|
||||
const el = containerRef.current;
|
||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||
if (follow) return scaleToFit(); // the driver's grid, not ours
|
||||
fit.fit(); // a real change fires onResize, which reports it
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(refit);
|
||||
@@ -502,9 +542,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
() =>
|
||||
registerPane(
|
||||
runId,
|
||||
(text) => {
|
||||
(text, submit) => {
|
||||
termRef.current?.paste(text);
|
||||
termRef.current?.focus();
|
||||
// Enter a beat after the paste, so a TUI that times its paste
|
||||
// detection never reads it as a newline inside the paste. A submit
|
||||
// comes from the pane's Chat view, which keeps the keyboard.
|
||||
if (submit)
|
||||
setTimeout(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send("\r");
|
||||
}, 100);
|
||||
else termRef.current?.focus();
|
||||
},
|
||||
// On screen (a console hidden with CSS measures 0×0) AND attached:
|
||||
// `onData` drops input while the socket is connecting or reconnecting,
|
||||
|
||||
Reference in New Issue
Block a user