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
+62 -26
View File
@@ -16,7 +16,9 @@
* (`snapshots`). A page's content is capped at a readable width. A tool's own settings live under `agents/<id>` — Claude Code and
* Codex have their own pages, every other installed, enabled catalog tool the
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
* disabled. A workspace's own settings live on its home, not here.
* disabled. Those pages are not in the nav: they are tabs on AI tools (the nav
* marks AI tools current, the search shows their matches under it), and a
* row in the AI tools list opens its tool's tab. A workspace's own settings live on its home, not here.
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -65,7 +67,8 @@ import {
import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
import { useAuth } from "../components/AuthGate";
import { usePermissions } from "../components/AuthGate";
import { PageTabs } from "../components/PageTabs";
const GROUPS = ["general", "agents", "data"] as const;
type Group = (typeof GROUPS)[number];
@@ -77,8 +80,13 @@ interface Section {
description: string;
Icon: ComponentType<{ className?: string }>;
render: () => ReactNode;
/** A tool's own page: a tab on AI tools, not a nav item. */
tool?: string;
}
/** The nav item a section sits under: a tool's page is a tab on AI tools. */
const navId = (id: string) => (id.startsWith("agents/") ? "agents" : id);
/** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
const LEGACY_TOOL: Record<string, string> = {
dashboard: "appearance",
@@ -131,8 +139,8 @@ const MOVED: Record<string, string> = {
worktrees: "git#worktrees",
};
/** Sections whose API a server-mode member may not reach (STAFF_ONLY_PREFIXES in server/lib/auth.js). */
const STAFF_SECTIONS = new Set(["data", "agents/claude-code", "agents/codex"]);
/** Sections that only configure the host (ADMIN_WRITE_PREFIXES in server/lib/auth.js). */
const ADMIN_SECTIONS = new Set(["data", "ai-proxy", "pricing"]);
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
@@ -215,8 +223,7 @@ export function SettingsHub() {
};
}, [location.key, location.hash]);
const auth = useAuth();
const isMember = auth.state.mode === "server" && auth.state.user?.role === "member";
const { isAdmin } = usePermissions();
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`),
@@ -249,13 +256,14 @@ export function SettingsHub() {
group: "agents",
Icon: Bot,
...s("agents"),
render: () => <AiToolsSettings />,
render: () => <AiToolsSettings settingsHref={toolHref} />,
},
...(enabled("claude")
? [
{
id: "agents/claude-code",
group: "agents" as const,
tool: "claude",
Icon: toolNavIcon("claude", "Claude Code"),
...s("claudeCode"),
render: () => <CcConfig />,
@@ -267,6 +275,7 @@ export function SettingsHub() {
{
id: "agents/codex",
group: "agents" as const,
tool: "codex",
Icon: toolNavIcon("codex", "Codex"),
...s("codex"),
render: () => <CodexConfig />,
@@ -280,6 +289,7 @@ export function SettingsHub() {
.map((x) => ({
id: `agents/${x.id}`,
group: "agents" as const,
tool: x.id,
Icon: toolNavIcon(x.id, x.label),
title: x.label,
description: t("settingsNav.section.tool.description", { tool: x.label }),
@@ -335,8 +345,8 @@ export function SettingsHub() {
<div id="snapshots" className="scroll-mt-24">
<SnapshotSettings />
</div>
{/* Scheduled reports read every user's sessions: staff only. */}
{!isMember && <ReportSettings />}
{/* Scheduled reports read every user's sessions: an admin's. */}
{isAdmin && <ReportSettings />}
</div>
),
},
@@ -347,8 +357,8 @@ export function SettingsHub() {
...s("git"),
render: () => (
<div className="space-y-6">
{/* Git logins are the host's own: staff only. */}
{!isMember && (
{/* Git logins are the host's own: an admin's. */}
{isAdmin && (
<section id="git-credentials" className="scroll-mt-24">
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
<GitCredentialsConfig />
@@ -361,18 +371,27 @@ export function SettingsHub() {
),
},
];
// Server mode: a member cannot open the host's own configuration or the
// all-users views (the server answers 403 EADMIN), so those pages are hidden.
const sections = isMember ? allSections.filter((x) => !STAFF_SECTIONS.has(x.id)) : allSections;
// Server mode: only an admin changes the host's own configuration (the
// server answers 403 EADMIN), so its pages — every tool's too — are hidden.
const sections = isAdmin
? allSections
: allSections.filter((x) => !x.tool && !ADMIN_SECTIONS.has(x.id));
const navSections = sections.filter((x) => !x.tool);
const toolTabs = sections.filter((x) => x.tool);
function toolHref(id: string) {
const page = toolTabs.find((x) => x.tool === id);
return page ? `/settings/${page.id}` : undefined;
}
const entries: SettingsEntry[] = sections.flatMap((sec) => [
{ section: sec.id, title: sec.title, description: sec.description, sectionTitle: true },
...SETTINGS_INDEX.filter(
(e) =>
e.section === sec.id ||
(e.section === "agents/*" &&
sec.id.startsWith("agents/") &&
!SETTINGS_INDEX.some((x) => x.section === sec.id))
(isAdmin || !e.adminOnly) &&
(e.section === sec.id ||
(e.section === "agents/*" &&
sec.id.startsWith("agents/") &&
!SETTINGS_INDEX.some((x) => x.section === sec.id)))
).map((e) => ({
section: sec.id,
title: t(e.titleKey),
@@ -410,8 +429,11 @@ export function SettingsHub() {
}
const ranked = query.trim() ? rankSettings(query, entries) : null;
const shown = ranked ? sections.filter((x) => ranked.some((r) => r.section === x.id)) : sections;
const first = ranked?.[0]?.section ?? null;
const shown = ranked
? navSections.filter((x) => ranked.some((r) => navId(r.section) === x.id))
: navSections;
const first = ranked?.[0] ? navId(ranked[0].section) : null;
const navActive = navId(active.id);
const results = ranked ? rankSettingsEntries(query, entries) : [];
const titleOf = (id: string) => sections.find((x) => x.id === id)?.title ?? id;
const hrefOf = (e: SettingsEntry) => `/settings/${e.section}${e.to ?? ""}`;
@@ -494,10 +516,10 @@ export function SettingsHub() {
<Link
key={id}
to={`/settings/${id}`}
aria-current={id === active.id ? "page" : undefined}
aria-current={id === navActive ? "page" : undefined}
data-first-match={id === first ? "true" : undefined}
className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
id === active.id
id === navActive
? "bg-surface-3 font-medium text-fg-primary before:absolute before:inset-y-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-accent"
: id === first
? "bg-surface-3 text-fg-primary"
@@ -505,7 +527,7 @@ export function SettingsHub() {
}`}
>
<Icon
className={`h-4 w-4 shrink-0 ${id === active.id ? "text-accent" : "text-fg-muted"}`}
className={`h-4 w-4 shrink-0 ${id === navActive ? "text-accent" : "text-fg-muted"}`}
/>
<span className="truncate">{title}</span>
</Link>
@@ -521,12 +543,12 @@ export function SettingsHub() {
<div className="mb-4 md:hidden">
<select
aria-label={t("settingsNav.label")}
value={active.id}
value={navActive}
onChange={(e) => go(e.target.value)}
className="select w-full py-1.5"
>
{GROUPS.map((g) => {
const own = sections.filter((x) => x.group === g);
const own = navSections.filter((x) => x.group === g);
return own.length === 0 ? null : (
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
{own.map((x) => (
@@ -548,7 +570,21 @@ export function SettingsHub() {
<p className="mt-0.5 text-sm text-fg-muted">{active.description}</p>
</div>
</header>
{active.render()}
{navActive === "agents" && toolTabs.length > 0 ? (
<PageTabs
ariaLabel={t("settingsNav.toolTabs")}
activeId={active.id}
onSelect={go}
tabs={[
{ id: "agents", label: t("settingsNav.toolsTab"), Icon: Bot },
...toolTabs.map(({ id, title, Icon }) => ({ id, label: title, Icon })),
]}
>
<div className="pt-4">{active.render()}</div>
</PageTabs>
) : (
active.render()
)}
</div>
</div>
</div>