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
@@ -10,6 +10,8 @@
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
* A right-click on an installed row opens its Update / Set default /
* Enable-Disable (plus Copy path) as a menu; the buttons stay.
* A click on an installed row (outside its buttons) opens that tool's settings
* tab when the hub gives it one (`settingsHref`); the name is the keyboard link.
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
* working.
@@ -19,12 +21,14 @@
import { Switch } from "../Switch";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
import { useAiTools } from "../../hooks/useAiTools";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { api, type AiTool, type AiToolJob } from "../../lib/api";
import { usePermissions } from "../AuthGate";
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
@@ -115,9 +119,17 @@ function JobLog({ job }: { job: AiToolJob }) {
);
}
export function AiToolsSettings() {
export function AiToolsSettings({
settingsHref,
}: {
/** A tool's own settings page, when it has one. */
settingsHref?: (id: string) => string | undefined;
} = {}) {
const { t } = useTranslation("settings");
const navigate = useNavigate();
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
// Installing, updating and switching tools changes the host: an admin's.
const { isAdmin } = usePermissions();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [job, setJob] = useState<AiToolJob | null>(null);
@@ -218,7 +230,7 @@ export function AiToolsSettings() {
{t("aiTools.installed", { count: installed.length })}
</p>
<div className="flex items-center gap-2">
{updateAllTools.length > 0 && (
{isAdmin && updateAllTools.length > 0 && (
<button
type="button"
onClick={() => setConfirm({ kind: "update-all" })}
@@ -229,15 +241,17 @@ export function AiToolsSettings() {
{t("aiTools.updateAll", { count: updateAllTools.length })}
</button>
)}
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className={GHOST_BTN}
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
{isAdmin && (
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className={GHOST_BTN}
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
)}
</div>
</div>
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
@@ -248,12 +262,18 @@ export function AiToolsSettings() {
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
{installed.map((tool) => {
const isDefault = tool.id === defaultId;
const href = settingsHref?.(tool.id);
return (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
onContextMenu={(e) => ctx.open(e, tool)}
className="px-3 py-2.5"
onClick={(e) => {
// The row's own buttons, switch, links and log keep their click.
if (href && !(e.target as Element).closest("button, a, input, pre"))
navigate(href);
}}
className={`px-3 py-2.5 ${href ? "cursor-pointer hover:bg-surface-3" : ""}`}
>
<div className="flex items-center gap-3">
<div
@@ -262,7 +282,17 @@ export function AiToolsSettings() {
<AiToolIcon tool={tool} />
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
<span className="truncate">{tool.label}</span>
{href ? (
<Link
to={href}
title={t("aiTools.openSettings", { tool: tool.label })}
className="truncate hover:text-accent hover:underline"
>
{tool.label}
</Link>
) : (
<span className="truncate">{tool.label}</span>
)}
<Homepage tool={tool} />
{isDefault && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
@@ -279,31 +309,39 @@ export function AiToolsSettings() {
</div>
</div>
{/* Fixed slots, so the buttons line up from row to row. */}
<div className="flex min-w-20 shrink-0 justify-end">
{tool.update && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className={GHOST_BTN}
>
{t("aiTools.update")}
</button>
)}
</div>
<div className="flex min-w-28 shrink-0 justify-end">
{!isDefault && (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => setDefault(tool)}
className={GHOST_BTN}
>
{t("aiTools.setDefault")}
</button>
)}
</div>
<ToolSwitch tool={tool} disabled={busy !== null} onToggle={() => toggle(tool)} />
{isAdmin && (
<>
<div className="flex min-w-20 shrink-0 justify-end">
{tool.update && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className={GHOST_BTN}
>
{t("aiTools.update")}
</button>
)}
</div>
<div className="flex min-w-28 shrink-0 justify-end">
{!isDefault && (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => setDefault(tool)}
className={GHOST_BTN}
>
{t("aiTools.setDefault")}
</button>
)}
</div>
<ToolSwitch
tool={tool}
disabled={busy !== null}
onToggle={() => toggle(tool)}
/>
</>
)}
</div>
{logFor(tool.id)}
</li>
@@ -312,7 +350,7 @@ export function AiToolsSettings() {
</ul>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-tool-menu">
{ctx.menu.item.update && (
{isAdmin && ctx.menu.item.update && (
<MenuItem
disabled={jobBusy}
onSelect={pick((tool) => setConfirm({ kind: "update", tool }))}
@@ -320,7 +358,7 @@ export function AiToolsSettings() {
{t("aiTools.update")}
</MenuItem>
)}
{ctx.menu.item.id !== defaultId && (
{isAdmin && ctx.menu.item.id !== defaultId && (
<MenuItem
disabled={!ctx.menu.item.enabled || busy !== null}
onSelect={pick(setDefault)}
@@ -328,12 +366,14 @@ export function AiToolsSettings() {
{t("aiTools.setDefault")}
</MenuItem>
)}
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
</MenuItem>
{isAdmin && (
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
</MenuItem>
)}
{ctx.menu.item.path && (
<>
<MenuSeparator />
{isAdmin && <MenuSeparator />}
<MenuItem
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
>
@@ -376,7 +416,7 @@ export function AiToolsSettings() {
</span>
</span>
<Homepage tool={tool} />
{tool.install && (
{isAdmin && tool.install && (
<button
type="button"
disabled={jobBusy}