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:
@@ -94,8 +94,10 @@ import { SessionOverview } from "../components/SessionOverview";
|
||||
import { ConversationView } from "../components/conversation/ConversationView";
|
||||
import { SessionStatusBadge, REASON_ICONS } from "../components/StatusBadge";
|
||||
import { SessionToolBadge } from "../components/SessionToolBadge";
|
||||
import { SessionResumeButton } from "../components/lanes/SessionResumeButton";
|
||||
import { SessionResumeButton, owningLane } from "../components/lanes/SessionResumeButton";
|
||||
import { SnapshotButton } from "../components/SnapshotButton";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { EventTimeline } from "../components/EventTimeline";
|
||||
import { EventReplay, loadReplayEvents } from "../components/EventReplay";
|
||||
import { CopyButton } from "../components/event-views/primitives";
|
||||
@@ -151,6 +153,9 @@ export function SessionDetail() {
|
||||
const { t } = useTranslation("sessions");
|
||||
const { t: wfT } = useTranslation("workflows");
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
// Server mode: an analyst reads every session but acts only on their own.
|
||||
const { canEditLane } = usePermissions();
|
||||
const { lanes } = useLanes();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [workflows, setWorkflows] = useState<WorkflowRun[]>([]);
|
||||
const [events, setEvents] = useState<DashboardEvent[]>([]);
|
||||
@@ -666,16 +671,27 @@ export function SessionDetail() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const href = workspaceHref(session, runs);
|
||||
return href ? (
|
||||
<Link to={href} className="btn-ghost">
|
||||
<ExternalLink className="w-4 h-4" /> {t("openInWorkspace")}
|
||||
</Link>
|
||||
) : null;
|
||||
})()}
|
||||
<SessionResumeButton session={session} />
|
||||
<SnapshotButton sessionId={session.id} />
|
||||
{/* The owning workspace: its stamp, else the one whose folder holds it
|
||||
(a session a pane holds but no hook stamped yet). */}
|
||||
{canEditLane(
|
||||
lanes.find((l) => l.id === session.lane_id) ??
|
||||
(session.lane_id == null && session.cwd
|
||||
? owningLane(session.cwd, session.tool || "claude", lanes)
|
||||
: null)
|
||||
) && (
|
||||
<>
|
||||
{(() => {
|
||||
const href = workspaceHref(session, runs);
|
||||
return href ? (
|
||||
<Link to={href} className="btn-ghost">
|
||||
<ExternalLink className="w-4 h-4" /> {t("openInWorkspace")}
|
||||
</Link>
|
||||
) : null;
|
||||
})()}
|
||||
<SessionResumeButton session={session} />
|
||||
<SnapshotButton sessionId={session.id} />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -85,7 +85,7 @@ import {
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { fmt, fmtCost } from "../lib/format";
|
||||
import { fmt, fmtCost, formatBytes } from "../lib/format";
|
||||
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
|
||||
import {
|
||||
defaultNotifPrefs,
|
||||
@@ -102,7 +102,9 @@ import { ImportHistory } from "../components/ImportHistory";
|
||||
import { RemoteSources } from "../components/RemoteSources";
|
||||
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
|
||||
import { PrivacySettings } from "../components/settings/PrivacySettings";
|
||||
import { BackupSettings } from "../components/settings/BackupSettings";
|
||||
import { AutostartSettings } from "../components/settings/AutostartSettings";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { AlertsNotifications } from "../components/AlertsNotifications";
|
||||
import {
|
||||
@@ -142,13 +144,6 @@ const NOTIF_TYPES: [NotifType, string][] = [
|
||||
|
||||
type SystemInfo = Awaited<ReturnType<typeof api.settings.info>>;
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
@@ -201,6 +196,7 @@ export function Settings({
|
||||
section?: "data" | "about" | "notifications";
|
||||
} = {}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin } = usePermissions();
|
||||
// Only Data and About show server info; General and Notifications never read it.
|
||||
const needsInfo = section === "data" || section === "about";
|
||||
const [loading, setLoading] = useState(needsInfo);
|
||||
@@ -966,6 +962,7 @@ export function Settings({
|
||||
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
|
||||
<PushIngestSettings />
|
||||
<PrivacySettings />
|
||||
<BackupSettings />
|
||||
|
||||
{/* ─── SESSION CLEANUP ─── */}
|
||||
<section id="cleanup" className="card p-5 space-y-4 scroll-mt-24">
|
||||
@@ -1120,7 +1117,8 @@ export function Settings({
|
||||
|
||||
{section === "about" && (
|
||||
<div id="about" className="space-y-6 scroll-mt-24">
|
||||
<AutostartSettings />
|
||||
{/* Start at login, the icon, Quit and Uninstall act on the host: an admin's. */}
|
||||
{isAdmin && <AutostartSettings />}
|
||||
{sysInfo ? (
|
||||
<SystemDetails info={sysInfo} />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -25,7 +25,7 @@ import { ConfirmModal } from "../components/ConfirmModal";
|
||||
import { SettingsSection } from "../components/settings/SettingsRow";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "member"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
|
||||
type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace";
|
||||
type Dialog = { kind: DialogKind; user?: ServerUserUsage };
|
||||
@@ -61,11 +61,11 @@ export function Users() {
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
|
||||
useEffect(() => {
|
||||
if (me && me.role !== "member") void load();
|
||||
if (me && me.role !== "developer") void load();
|
||||
}, [me, load]);
|
||||
|
||||
// Local mode, or a member: there is no Users page for them.
|
||||
if (!me || me.role === "member") return <Navigate to="/" replace />;
|
||||
// Local mode, or a developer: there is no Users page for them.
|
||||
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
@@ -265,7 +265,7 @@ function AccountDialog({
|
||||
const name = user?.username ?? "";
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<Role>(user?.role ?? "member");
|
||||
const [role, setRole] = useState<Role>(user?.role ?? "developer");
|
||||
const [laneId, setLaneId] = useState<number | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||
const [current, setCurrent] = useState("");
|
||||
|
||||
@@ -69,7 +69,7 @@ import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { toast } from "../lib/toast";
|
||||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useWorkLanes } from "../hooks/useLanes";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import {
|
||||
ConsoleTabs,
|
||||
@@ -168,7 +168,7 @@ function slotOf(runId: string | null | undefined): number {
|
||||
* lane. The focused pane of the tab on screen is the URL's `?run=`: a plain
|
||||
* open (a tab, a sidebar row) shows the tab holding that run — a session in
|
||||
* no tab gets one of its own — and a split open (Ctrl/Cmd+click) brings it
|
||||
* into the tab on screen, to the right of the focused pane. Every live run
|
||||
* into the tab on screen, at the 2×2 grid's next place. Every live run
|
||||
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
|
||||
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
|
||||
* run ends closes; the last tab gone leaves for the home. */
|
||||
@@ -189,7 +189,7 @@ function ConsoleArea({
|
||||
activeRuns: RunListResponse | null;
|
||||
onHasActiveRunChange: (val: boolean) => void;
|
||||
/** The opened run (`?run=`): its pane is the focused one. `split` opens it
|
||||
* beside the focused pane instead of in it; `focusOnly` (the URL following a
|
||||
* in a split (the grid's next place) instead; `focusOnly` (the URL following a
|
||||
* focus change made here) never opens anything. */
|
||||
attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null;
|
||||
/** Back to the workspace home, where a new session is started. */
|
||||
@@ -519,7 +519,7 @@ export function Workspace() {
|
||||
// Lane state: the list is the shared store the sidebar reads too; the
|
||||
// selection is the URL's `?lane=` (the sidebar sets it, so does a
|
||||
// notification click), falling back to the last one shown, then the first.
|
||||
const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useLanes();
|
||||
const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useWorkLanes();
|
||||
const laneParam = searchParams.get("lane");
|
||||
// `?run=` — a live session picked in the sidebar; the location key makes a
|
||||
// second pick of the same session a new request.
|
||||
@@ -529,7 +529,7 @@ export function Workspace() {
|
||||
// conversation, with a prompt box whose message resumes it (SessionReplay).
|
||||
const sessionParam = runParam ? null : searchParams.get("session");
|
||||
const navigate = useNavigate();
|
||||
// A Ctrl/Cmd+click (tab or sidebar) opens the run beside the focused pane;
|
||||
// A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
|
||||
// it rides on the navigation's state, never in the URL.
|
||||
const navState = location.state as { split?: unknown; focusOnly?: unknown } | null;
|
||||
const splitOpen = navState?.split === true;
|
||||
|
||||
@@ -51,10 +51,12 @@ let mockCost: { total_cost: number; breakdown: unknown[] } = { total_cost: 0, br
|
||||
// The header's Resume reads lanes and runs; this file is about the agent tree.
|
||||
vi.mock("../../components/lanes/SessionResumeButton", () => ({
|
||||
SessionResumeButton: () => null,
|
||||
owningLane: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: { list: vi.fn(() => Promise.resolve({ lanes: [] })) },
|
||||
sessions: {
|
||||
get: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
@@ -104,6 +106,7 @@ vi.mock("../../lib/api", () => ({
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
onConnection: vi.fn(() => () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -65,6 +65,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
env_owned: { redact_secrets: false, ignore_cwd: false },
|
||||
})),
|
||||
},
|
||||
backup: {
|
||||
get: vi.fn(async () => ({
|
||||
dir: "",
|
||||
hour: 2,
|
||||
keep: 7,
|
||||
running: false,
|
||||
last: null,
|
||||
files: [],
|
||||
dir_error: null,
|
||||
env_owned: { dir: false, hour: false, keep: false },
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* @description The Settings page shell: a path picks its section, `/settings`
|
||||
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
|
||||
* unknown section falls back to Appearance, a disabled tool has no page while an
|
||||
* installed, enabled catalog tool gets one, the mobile
|
||||
* installed, enabled catalog tool gets one — as a tab on AI tools, not a nav
|
||||
* item —, the mobile
|
||||
* select navigates, and the search filters the nav, opens the first match on
|
||||
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
|
||||
* text), its result list deep-links a row's sub-tab, an anchor in the URL is
|
||||
@@ -159,13 +160,27 @@ describe("SettingsHub", () => {
|
||||
view.tools[1] = tool("codex", { launcher: "codex", enabled: false });
|
||||
renderAt("/settings/agents/gemini");
|
||||
expect(await screen.findByTestId("tool-gemini")).toBeInTheDocument();
|
||||
const tabs = within(screen.getByRole("tablist", { name: "AI tool settings" }))
|
||||
.getAllByRole("tab")
|
||||
.map((a) => a.textContent);
|
||||
expect(tabs).toEqual(["All tools", "Claude Code", "Gemini"]);
|
||||
});
|
||||
|
||||
it("shows tool pages as tabs on AI tools, not as nav items", async () => {
|
||||
renderAt("/settings/agents/codex");
|
||||
expect(await screen.findByTestId("codex-config")).toBeInTheDocument();
|
||||
const links = within(nav())
|
||||
.getAllByRole("link")
|
||||
.map((a) => a.textContent);
|
||||
expect(links).toContain("Claude Code");
|
||||
expect(links).not.toContain("Codex");
|
||||
expect(links).not.toContain("Qwen");
|
||||
expect(links).not.toContain("Aider");
|
||||
expect(within(nav()).getByRole("link", { name: "AI tools" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page"
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "Codex" })).toHaveAttribute("aria-selected", "true");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "All tools" }));
|
||||
expect(screen.getByTestId("where").textContent).toBe("/settings/agents");
|
||||
expect(screen.getByTestId("ai-tools")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates from the mobile select", async () => {
|
||||
|
||||
@@ -66,7 +66,7 @@ function renderPage() {
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(users)) fn.mockReset();
|
||||
users.list.mockResolvedValue({
|
||||
users: [row(1, "boss", "admin"), row(2, "mia", "member")],
|
||||
users: [row(1, "boss", "admin"), row(2, "mia", "developer")],
|
||||
});
|
||||
users.audit.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
@@ -76,7 +76,7 @@ describe("Users page", () => {
|
||||
authState = { mode: "local" };
|
||||
renderPage();
|
||||
expect(await screen.findByTestId("home")).toBeTruthy();
|
||||
authState = { mode: "server", user: { id: 2, username: "mia", role: "member" } };
|
||||
authState = { mode: "server", user: { id: 2, username: "mia", role: "developer" } };
|
||||
renderPage();
|
||||
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
|
||||
expect(users.list).not.toHaveBeenCalled();
|
||||
@@ -94,7 +94,7 @@ describe("Users page", () => {
|
||||
it("adds a user only with the admin's own password", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.create.mockResolvedValue({
|
||||
user: { id: 9, username: "neo", role: "member", disabled: false },
|
||||
user: { id: 9, username: "neo", role: "developer", disabled: false },
|
||||
});
|
||||
const ue = userEvent.setup();
|
||||
renderPage();
|
||||
@@ -111,7 +111,7 @@ describe("Users page", () => {
|
||||
await ue.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(users.create).toHaveBeenCalledWith(
|
||||
{ username: "neo", password: "neo-pass-123", role: "member" },
|
||||
{ username: "neo", password: "neo-pass-123", role: "developer" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -732,7 +732,7 @@ describe("split panes", () => {
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2", "pane-3"]);
|
||||
await ctrlClick(screen.getByTestId("console-tab-4"));
|
||||
expect(shownPanes()).toEqual(["pane-4"]);
|
||||
expect(screen.getByTestId("console-tab-0-1-2-3")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("console-tab-0-3-1-2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("console-tab-4")).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
|
||||
@@ -7189,6 +7189,35 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Uninstall AgentHub
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Remove AgentHub from this computer: the dashboard, its database and settings, its hooks in Claude Code, Codex and Antigravity, and the plugin. Your conversations, workspace folders and repositories are kept.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
data-testid="uninstall-agenthub"
|
||||
type="button"
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
@@ -7220,7 +7249,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.5.12 (rebuild client to match)
|
||||
UI build v2.5.13 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -9066,6 +9095,205 @@ exit 0
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="backup"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<span
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-database-backup w-4 h-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<ellipse
|
||||
cx="12"
|
||||
cy="5"
|
||||
rx="9"
|
||||
ry="3"
|
||||
/>
|
||||
<path
|
||||
d="M3 12a9 3 0 0 0 5 2.69"
|
||||
/>
|
||||
<path
|
||||
d="M21 9.3V5"
|
||||
/>
|
||||
<path
|
||||
d="M3 5v14a9 3 0 0 0 6.47 2.88"
|
||||
/>
|
||||
<path
|
||||
d="M12 12v4h4"
|
||||
/>
|
||||
<path
|
||||
d="M13 20a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L12 16"
|
||||
/>
|
||||
</svg>
|
||||
Daily backup
|
||||
</span>
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="py-3 space-y-3"
|
||||
>
|
||||
<label
|
||||
class="block text-xs text-fg-muted"
|
||||
>
|
||||
Backup folder
|
||||
<input
|
||||
class="input mt-1 w-full font-mono text-xs"
|
||||
placeholder="/mnt/hdd/agenthub-backups"
|
||||
value=""
|
||||
/>
|
||||
</label>
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
An absolute path to an existing folder the server can write to. Leave it empty to turn backups off. AgentHub only ever deletes its own agenthub-YYYYMMDD-HHMMSS.db files here, oldest first beyond the number kept.
|
||||
</p>
|
||||
<div
|
||||
class="flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<div
|
||||
class="w-28"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Time (server)"
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
title="Time (server)"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
02:00
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
Backups kept
|
||||
<input
|
||||
class="input mt-1 block w-24 text-sm"
|
||||
max="365"
|
||||
min="1"
|
||||
step="1"
|
||||
type="number"
|
||||
value="7"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<button
|
||||
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-database-backup w-4 h-4"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<ellipse
|
||||
cx="12"
|
||||
cy="5"
|
||||
rx="9"
|
||||
ry="3"
|
||||
/>
|
||||
<path
|
||||
d="M3 12a9 3 0 0 0 5 2.69"
|
||||
/>
|
||||
<path
|
||||
d="M21 9.3V5"
|
||||
/>
|
||||
<path
|
||||
d="M3 5v14a9 3 0 0 0 6.47 2.88"
|
||||
/>
|
||||
<path
|
||||
d="M12 12v4h4"
|
||||
/>
|
||||
<path
|
||||
d="M13 20a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L12 16"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
Back up now
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-secondary"
|
||||
>
|
||||
Backups are off.
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
To restore: agenthub stop, delete dashboard.db-wal and dashboard.db-shm, copy a backup over dashboard.db (its path is on the System page), then agenthub start.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="card p-5 space-y-4 scroll-mt-24"
|
||||
id="cleanup"
|
||||
|
||||
@@ -350,6 +350,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
save: r({ ok: true }),
|
||||
},
|
||||
backup: {
|
||||
get: r({
|
||||
dir: "",
|
||||
hour: 2,
|
||||
keep: 7,
|
||||
running: false,
|
||||
last: null,
|
||||
files: [],
|
||||
dir_error: null,
|
||||
env_owned: { dir: false, hour: false, keep: false },
|
||||
}),
|
||||
},
|
||||
exportData: () => "/api/settings/export",
|
||||
cleanup: r({
|
||||
ok: true,
|
||||
|
||||
Reference in New Issue
Block a user