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
+43 -6
View File
@@ -59,11 +59,36 @@ describe("openInLayout", () => {
expect(out.focus).toBe(3);
});
it("splits the focused pane to the right on a split open", () => {
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
const out = openInLayout(l, 2, "split");
expect(out.root).toEqual(split("row", split("row", pane(0), pane(2)), pane(1)));
expect(out.focus).toBe(2);
it("fills a split open into the 2×2 grid: right, under the right, under the left", () => {
let l: PaneLayout = one;
l = openInLayout(l, 1, "split");
expect(l.root).toEqual(split("row", pane(0), pane(1)));
// The focus is on the left pane: the grid order wins, not the focus.
l = openInLayout({ ...l, focus: 0 }, 2, "split");
expect(l.root).toEqual(split("row", pane(0), split("col", pane(1), pane(2))));
l = openInLayout(l, 3, "split");
expect(l.root).toEqual(
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)))
);
expect(l.focus).toBe(3);
});
it("a split open fills the one gap of a three-pane layout, whatever its shape", () => {
const left: PaneLayout = {
root: split("row", split("col", pane(0), pane(1)), pane(2)),
focus: 0,
};
expect(openInLayout(left, 3, "split").root).toEqual(
split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3)))
);
// A dragged divider does not move the gap.
const wide: PaneLayout = {
root: split("row", pane(0), split("col", pane(1), pane(2)), 0.3),
focus: 1,
};
expect(openInLayout(wide, 3, "split").root).toEqual(
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)), 0.3)
);
});
it("replaces instead of splitting once MAX_PANES are shown", () => {
@@ -226,7 +251,7 @@ describe("pane groups (tabs of up to four panes)", () => {
for (const s of [1, 2, 3]) g = openInGroups(g, s, true);
expect(leaves(g.groups[0]!.root)).toHaveLength(MAX_PANES);
const out = openInGroups(g, 4, true);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 1, 2, 3], [4]]);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 3, 1, 2], [4]]);
expect(out.active).toBe(1);
});
@@ -384,6 +409,18 @@ describe("joinGroup", () => {
expect(g.groups[0]?.focus).toBe(3);
});
it("fills the gap under the right pane when the left one is already split", () => {
const combo: PaneLayout = {
root: split("row", split("col", pane(0), pane(1)), pane(2)),
focus: 0,
};
const g = joinGroup({ groups: [combo, solo(3)], active: 1 }, [3], 0);
expect(g.groups).toHaveLength(1);
expect(g.groups[0]?.root).toEqual(
split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3)))
);
});
it("activates the target tab and takes a pane out of a combo", () => {
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
const g = joinGroup({ groups: [solo(0), combo], active: 0 }, [2], 1);