Merge branch 'perf/local-opt': faster local install and updates, lighter polling and ingest

This commit is contained in:
2026-10-16 10:27:26 +07:00
79 changed files with 5395 additions and 1796 deletions
+14 -3
View File
@@ -76,7 +76,7 @@
*
* ----------------------------------------------------------------------------- */
import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { lazy, Suspense, useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -85,9 +85,15 @@ import { UpdateNotifier } from "./UpdateNotifier";
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar";
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
import { useOnboarding } from "../lib/userPrefs";
import { OnboardingTour } from "./onboarding/OnboardingTour";
// Loaded only for an account that has not finished onboarding: the wizard
// carries the appearance, pet and notification settings screens.
const OnboardingWizard = lazy(() =>
import("./onboarding/OnboardingWizard").then((m) => ({ default: m.OnboardingWizard }))
);
const WIDTH_KEY = "agenthub.sidebar.width";
/** The default width, and the narrowest the edge drags to. */
export const SIDEBAR_MIN = 240;
@@ -136,6 +142,7 @@ export function Layout({ wsConnected }: LayoutProps) {
const { t } = useTranslation("nav");
const [collapsed, setCollapsed] = useState(loadCollapsed);
const [connectionOpen, setConnectionOpen] = useState(false);
const onboarding = useOnboarding();
const [updateAvailable, setUpdateAvailable] = useState(false);
const drawer = useMedia(DRAWER_QUERY);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -177,7 +184,11 @@ export function Layout({ wsConnected }: LayoutProps) {
<UpdateNotifier />
<InterruptedSessionsPrompt />
<SagiMount />
<OnboardingWizard />
{onboarding.loaded && !onboarding.onboardedAt && (
<Suspense fallback={null}>
<OnboardingWizard />
</Suspense>
)}
<OnboardingTour />
{drawer && drawerOpen && (
<div
+1 -17
View File
@@ -81,6 +81,7 @@ import {
} from "react";
import { useNavigate } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { useTranslation } from "react-i18next";
import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
@@ -130,23 +131,6 @@ interface Anchor {
dragging: boolean;
}
/** The OS "reduce motion" preference, followed live. */
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
);
useEffect(() => {
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!mq) return;
const onChange = () => setReduced(mq.matches);
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
return reduced;
}
/**
* Fixed-position wrapper that places its content next to the avatar and clamps
* it inside the viewport. It measures itself (and re-measures on content/size
+56 -40
View File
@@ -9,8 +9,12 @@
* Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll,
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
* whenever the URL names another workspace or session. Each figure carries its
* `LIVE_RUNS_EVENT`), all re-read at once whenever the URL names another
* workspace or session, then at most every 30 s while events or session
* updates arrive (spend only moves with work) and every 2 min otherwise, while
* the tab is visible. The session counts re-read only when a session is
* created or its status / waiting / archived state changes (every 60 s
* otherwise). Each figure carries its
* token total, and hovering it lists tokens and cost per model. At the right
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
@@ -31,9 +35,15 @@ import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { sessionChangeMatcher } from "../lib/sessionChange";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
/** Spend moves only with work: at most this often while events arrive… */
const COST_POLL_MS = 30_000;
/** …and this often regardless (date rollover, budget period, a missed frame). */
const COST_FALLBACK_MS = 120_000;
/** The counts' safety poll; session frames that change a count drive the rest. */
const COUNTS_FALLBACK_MS = 60_000;
const QUOTA_POLL_MS = 5 * 60_000;
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
claude: "Claude",
@@ -384,51 +394,57 @@ export function StatusBar({
.catch(() => undefined);
}, []);
useEffect(loadCounts, [loadCounts]);
useLiveRefresh(loadCounts, {
intervalMs: 15_000,
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
});
// Only a frame that moves a session between the counted states (a new
// session, a status / waiting / archived change) re-reads: a session row is
// re-broadcast on almost every hook.
const [countsChanged] = useState(() => sessionChangeMatcher());
useLiveRefresh(loadCounts, { intervalMs: COUNTS_FALLBACK_MS, match: countsChanged });
// Bumped on every lane/session switch: an answer for the previous one is dropped.
const costGen = useRef(0);
const loadCosts = useCallback(() => {
const gen = costGen.current;
const live = () => gen === costGen.current;
api.pricing
.totalCost({ date: localDate() })
.then((r) => live() && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live()) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live() && setLaneCost(r))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live() && setSessionCost(r))
.catch(() => undefined);
}
}, [lane, sessionId]);
useEffect(() => {
let live = true;
costGen.current++;
// Another workspace or session: never show the previous one's figure.
setLaneCost(null);
setSessionCost(null);
const tick = () => {
if (document.visibilityState !== "visible") return;
api.pricing
.totalCost({ date: localDate() })
.then((r) => live && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live && setLaneCost(r))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live && setSessionCost(r))
.catch(() => undefined);
}
};
tick();
const id = setInterval(tick, COST_POLL_MS);
document.addEventListener("visibilitychange", tick);
if (document.visibilityState === "visible") loadCosts();
return () => {
live = false;
clearInterval(id);
document.removeEventListener("visibilitychange", tick);
costGen.current++;
};
}, [lane, sessionId]);
}, [loadCosts]);
useLiveRefresh(loadCosts, {
intervalMs: COST_FALLBACK_MS,
throttleMs: COST_POLL_MS,
match: (msg) =>
msg.type === "new_event" || msg.type === "session_updated" || msg.type === "session_created",
});
return (
<footer
@@ -14,6 +14,10 @@
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
* whether its session has started (`onNotStarted`: no row yet — the CLI may
* be asking in the terminal, e.g. to trust the folder, which this then says).
* A caller that knows the session has ended passes `ended`: nothing more is
* being written, so the catch-up poll drops from 3 s to 60 s and the
* sub-agent list stops being rescanned — a `new_event` frame, a reconnect or
* the view being shown again still fetch the tail at once.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -79,6 +83,9 @@ import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/typ
// hook event. A short visibility-gated poll closes that gap and also rescues
// the conversation from missed/late WebSocket frames.
const POLL_INTERVAL_MS = 3000;
// An ended session writes nothing more: a slow safety poll only, in case it was
// resumed somewhere whose frames this view missed.
const ENDED_POLL_INTERVAL_MS = 60000;
// Rescan the transcripts list periodically so new subagents that spawn
// mid-session appear in the dropdown without a page reload.
const TRANSCRIPTS_REFRESH_MS = 15000;
@@ -110,6 +117,8 @@ interface ConversationViewProps {
onContext?: (tokens: number, model: string | null) => void;
/** A live pane's session has no row yet: the CLI has not started it. */
onNotStarted?: (notStarted: boolean) => void;
/** The session has ended (and no pane holds it): poll slowly, if at all. */
ended?: boolean;
}
function ConversationViewPane({
@@ -124,6 +133,7 @@ function ConversationViewPane({
stopped = 0,
onContext,
onNotStarted,
ended = false,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
@@ -188,14 +198,18 @@ function ConversationViewPane({
}
}
loadTranscripts();
const interval = window.setInterval(() => {
if (isShown(onScreen)) loadTranscripts();
}, TRANSCRIPTS_REFRESH_MS);
// An ended session spawns no new sub-agents: the load above (re-run when
// it ends, for the last ones) is enough.
const interval = ended
? undefined
: window.setInterval(() => {
if (isShown(onScreen)) loadTranscripts();
}, TRANSCRIPTS_REFRESH_MS);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [sessionId]);
}, [sessionId, ended]);
// Sync external initialTranscriptId to internal state
useEffect(() => {
@@ -355,9 +369,12 @@ function ConversationViewPane({
let interval: number | null = null;
function start() {
if (interval !== null) return;
interval = window.setInterval(() => {
if (isShown(onScreen)) fetchNewMessages();
}, POLL_INTERVAL_MS);
interval = window.setInterval(
() => {
if (isShown(onScreen)) fetchNewMessages();
},
ended ? ENDED_POLL_INTERVAL_MS : POLL_INTERVAL_MS
);
}
function stop() {
if (interval !== null) {
@@ -382,7 +399,7 @@ function ConversationViewPane({
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [fetchNewMessages, onScreen]);
}, [fetchNewMessages, onScreen, ended]);
// Manual refresh - surfaces a control in the toolbar so users can force
// a sync without reloading the page.
@@ -132,7 +132,11 @@ export function SessionReplay({
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} fill />
<ConversationView
sessionId={sessionId}
fill
ended={!livePane && !!session && session.status !== "active"}
/>
</div>
{livePane ? (
<div
+11 -4
View File
@@ -59,7 +59,13 @@ import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import { isSubmitEnter } from "../../lib/keys";
import { ConversationView } from "../conversation/ConversationView";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import {
ModelMenu,
PromptEditor,
promptForPane,
promptWithImages,
type PromptImage,
} from "./RunSetup";
import { ChatAddMenu } from "./ChatAddMenu";
import { addAttachments, dragAttachments } from "../../lib/promptAttach";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
@@ -491,7 +497,7 @@ export const PaneChat = memo(function PaneChat({
const [notStarted, setNotStarted] = useState(false);
// A control is a command typed into the pane, like the user would.
const command = async (line: string) => {
const command = async (line: string, files: string[] = []) => {
if (
notStarted &&
!(await confirmDialog({
@@ -501,14 +507,15 @@ export const PaneChat = memo(function PaneChat({
}))
)
return false;
const ok = submitToRun(runId, line);
const ok = submitToRun(runId, line, files);
if (!ok) toast.error(t("chat.notSent"));
return ok;
};
const send = async () => {
if (!text || uploading) return;
if (!(await command(text))) return;
const out = promptForPane(prompt, images);
if (!(await command(out.text, out.files))) return;
setFollow((n) => n + 1);
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
+3 -3
View File
@@ -73,7 +73,7 @@ import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
import { promptWithImages } from "./RunSetup";
import { promptForPane } from "./RunSetup";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
@@ -246,8 +246,8 @@ const ITEM =
* the CLI's input box, without Enter. */
export function showTerminal(runId: string, sessionId: string) {
const draft = readChatDraft(sessionId);
const text = promptWithImages(draft.prompt, draft.images);
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []);
const { text, files } = promptForPane(draft.prompt, draft.images);
if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
setSessionChat(sessionId, false);
}
+17
View File
@@ -208,6 +208,23 @@ export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
}
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
/** The prompt as pasted into a live pane: the text with every non-image file
* in it (as `promptWithImages`), and the images' paths apart — each goes in
* a paste of its own, the one shape `claude` turns into `[Image #n]` on every
* platform (a Windows path inside a paste of text stayed a plain path). */
export function promptForPane(
text: string,
images: PromptImage[]
): { text: string; files: string[] } {
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
return {
text: promptWithImages(text, images.filter((i) => !isImage(i))),
files: images.filter(isImage).map((i) => i.path),
};
}
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
export const PASTE_AS_FILE_CHARS = 5000;
+6 -2
View File
@@ -666,8 +666,12 @@ export const TerminalView = memo(function TerminalView({
() =>
registerPane(
runId,
(text, submit) => {
termRef.current?.paste(text);
(text, submit, files = []) => {
const term = termRef.current;
// The text, then each image's path in a paste of its own — the one
// shape `claude` turns into `[Image #n]` on every platform.
if (text) term?.paste(files.length ? `${text} ` : text);
files.forEach((f, i) => term?.paste(i < files.length - 1 ? `${f} ` : f));
// 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.
@@ -267,7 +267,7 @@ describe("PaneChat — model, effort and permission mode", () => {
renderChat();
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet");
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet", []);
});
it("shows the mode the pane shows, and asks the server to step it", async () => {
@@ -430,7 +430,7 @@ describe("PaneChat — the unsent prompt", () => {
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
expect(screen.getByText("a.ts")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts", []);
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
});
@@ -462,7 +462,8 @@ describe("PaneChat — the unsent prompt", () => {
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith(
"agenthub-lane-1",
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts'
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts',
[]
);
});
@@ -490,7 +491,7 @@ describe("PaneChat — the unsent prompt", () => {
expect(box).toHaveValue("hello");
confirmDialog.mockResolvedValueOnce(true);
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello", []));
});
it("sends without asking once the session has started", async () => {
@@ -499,7 +500,7 @@ describe("PaneChat — the unsent prompt", () => {
renderChat();
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi", []));
expect(confirmDialog).not.toHaveBeenCalled();
});
});
@@ -24,6 +24,7 @@ import {
ModelPicker,
PASTE_AS_FILE_CHARS,
PromptEditor,
promptForPane,
promptWithImages,
type PromptImage,
} from "../RunSetup";
@@ -586,3 +587,23 @@ describe("ModelMenu", () => {
expect(onChange).toHaveBeenCalledWith("claude-sonnet-5-5");
});
});
describe("promptForPane — images apart, every other file in the text", () => {
it("keeps @-mentions and other files in the text, and takes images out", () => {
// A Windows path inside a paste of text stayed a plain path in claude; a
// paste that is only the path becomes [Image #n].
const images: PromptImage[] = [
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
{ path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" },
];
expect(promptForPane(" look ", images)).toEqual({
text: 'look /data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG"',
files: [
"C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
"/data/drops/0123456789abcdef.JPG",
],
});
});
});
@@ -28,7 +28,7 @@ import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
import { SagiAvatar } from "../Sagi/SagiAvatar";
import { PetSprite } from "../Sagi/PetSprite";
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
import { usePrefersReducedMotion } from "../Sagi/Sagi";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets";
@@ -0,0 +1,180 @@
/**
* @file RetentionSettings.tsx
* @description Settings → Data → Session cleanup, under the manual purge: the
* OPT-IN automatic cleanup (`AGENTHUB_RETENTION_DAYS`, 0 = off, the default)
* that the hourly maintenance applies, the database's size and free space, and
* **Compact now** — a full VACUUM behind a confirm, polled through
* `GET /api/settings/retention` while it runs, then toasted. Backed by
* `server/lib/db-retention.js`; a value the environment set is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Minimize2, RefreshCw } from "lucide-react";
import { api } from "../../lib/api";
import type { RetentionState } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { formatBytes, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
export function RetentionSettings() {
const { t } = useTranslation("settings");
const [state, setState] = useState<RetentionState | null>(null);
const [days, setDays] = useState("0");
const [saving, setSaving] = useState(false);
useEffect(() => {
api.settings.retention
.get()
.then((s) => {
setState(s);
setDays(String(s.days));
})
.catch(() => undefined);
}, []);
// A Compact takes a while on a large database: poll until it ends.
const compacting = !!state?.compacting;
useEffect(() => {
if (!compacting) return;
const id = window.setInterval(() => {
api.settings.retention
.get()
.then((s) => {
setState(s); // the draft keeps its value
if (s.compacting || !s.last_compact) return;
if (s.last_compact.ok) {
toast.success(
t("retention.compactDone", {
before: formatBytes(s.last_compact.size_before ?? 0),
after: formatBytes(s.last_compact.size_after ?? 0),
})
);
} else {
toast.error(t("retention.compactFailed", { error: s.last_compact.error }));
}
})
.catch(() => undefined);
}, 2000);
return () => window.clearInterval(id);
}, [compacting, t]);
const max = state?.max_days ?? 3650;
const n = Number(days);
const valid = days.trim() !== "" && Number.isInteger(n) && n >= 0 && n <= max;
const changed = state !== null && valid && n !== state.days;
const owned = !!state?.env_owned.days;
async function save() {
setSaving(true);
try {
const s = await api.settings.retention.save(n);
setState(s);
setDays(String(s.days));
toast.success(t("retention.saved"));
} catch (err) {
toast.error(err);
} finally {
setSaving(false);
}
}
async function compact() {
const ok = await confirmDialog({
title: t("retention.compactConfirmTitle"),
message: t("retention.compactConfirmBody"),
confirmLabel: t("retention.compact"),
});
if (!ok) return;
try {
setState(await api.settings.retention.compact());
} catch (err) {
toast.error(err);
}
}
const purged = state?.last_run?.retention?.purged_sessions;
return (
<div className="border-t border-border pt-4 space-y-3">
<div>
<h4 className="text-xs font-medium text-fg-secondary">{t("retention.title")}</h4>
<p className="text-xs text-fg-muted">{t("retention.description")}</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="bg-surface-2 rounded-lg px-4 py-3">
<label htmlFor="retention-days" className="text-xs text-fg-secondary block mb-2">
{t("retention.days")}
</label>
<div className="flex items-center gap-2">
<input
id="retention-days"
type="number"
min="0"
max={max}
step="1"
inputMode="numeric"
value={days}
disabled={!state || owned}
onChange={(e) => setDays(e.target.value)}
aria-invalid={!valid || undefined}
aria-describedby={!valid ? "retention-days-error" : undefined}
className="input w-20 text-sm text-right font-mono"
/>
<span className="text-xs text-fg-muted">{t("common:days")}</span>
</div>
{!valid && (
<p id="retention-days-error" className="mt-1 text-[11px] text-status-danger">
{t("retention.invalid", { max })}
</p>
)}
</div>
<button
type="button"
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving || !changed || owned}
onClick={() => void save()}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
</div>
<p className="text-xs text-fg-secondary">
{state && state.days > 0 ? t("retention.on", { days: state.days }) : t("retention.off")}
{state?.last_run && purged !== undefined && (
<> {t("retention.lastRun", { time: formatDateTime(state.last_run.at), n: purged })}</>
)}
</p>
{owned && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("privacy.envOwned")}
</p>
)}
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => void compact()}
disabled={!state || compacting}
className="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
>
{compacting ? (
<RefreshCw className="w-3 h-3 animate-spin inline mr-1" />
) : (
<Minimize2 className="w-3 h-3 inline mr-1" />
)}
{compacting ? t("retention.compacting") : t("retention.compact")}
</button>
{state?.space && (
<span className="text-xs text-fg-muted">
{t("retention.space", {
size: formatBytes(state.db_size),
free: formatBytes(state.space.free_bytes),
mode: state.space.auto_vacuum,
})}
</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,27 @@
/**
* @file usePrefersReducedMotion.ts
* @description The OS "reduce motion" preference, followed live. Its own
* module so a settings screen can read it without importing the pet overlay
* (`components/Sagi/Sagi.tsx`), whose chat and pane UI would otherwise be
* pulled into every page's first bundle.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
/** The OS "reduce motion" preference, followed live. */
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
);
useEffect(() => {
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!mq) return;
const onChange = () => setReduced(mq.matches);
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
return reduced;
}
+17
View File
@@ -1216,6 +1216,23 @@
"ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.",
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
},
"retention": {
"title": "Automatic cleanup",
"description": "Off by default. When on, the hourly maintenance deletes finished sessions (completed, error, abandoned) that started more than this many days ago — with their events, agents and token usage — a little at a time.",
"days": "Delete sessions older than",
"off": "Automatic cleanup is off.",
"on": "Sessions older than {{days}} days are deleted every hour.",
"invalid": "A whole number from 0 (off) to {{max}}",
"saved": "Automatic cleanup saved",
"lastRun": "Last run {{time}}: {{n}} session(s) deleted",
"space": "Database {{size}} · {{free}} free inside it · auto-vacuum: {{mode}}",
"compact": "Compact now",
"compacting": "Compacting…",
"compactConfirmTitle": "Compact the database?",
"compactConfirmBody": "This rewrites the whole database file to give free space back to the disk and turns on incremental auto-vacuum. On a large database it takes a while, and new hook events wait until it finishes.",
"compactDone": "Database compacted: {{before}} → {{after}}",
"compactFailed": "Compact failed: {{error}}"
},
"backup": {
"title": "Daily backup",
"description": "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.",
+17
View File
@@ -1216,6 +1216,23 @@
"ignoreHint": "Mỗi dòng một đường dẫn tuyệt đối. Hook từ các thư mục này, và mọi thứ bên trong, bị bỏ hoàn toàn; việc nhập lịch sử và bộ đọc Codex cũng bỏ qua phiên của chúng. AGENTHUB_IGNORE_CWD đặt cùng danh sách này.",
"envOwned": "Một giá trị ở đây do môi trường khởi động dashboard đặt, và không thể đổi từ trang này."
},
"retention": {
"title": "Tự động dọn dẹp",
"description": "Mặc định tắt. Khi bật, bảo trì hằng giờ xóa các phiên đã kết thúc (hoàn tất, lỗi, bỏ dở) bắt đầu cách đây hơn số ngày này — cùng sự kiện, agent và token của chúng — từng ít một.",
"days": "Xóa phiên cũ hơn",
"off": "Tự động dọn dẹp đang tắt.",
"on": "Phiên cũ hơn {{days}} ngày được xóa mỗi giờ.",
"invalid": "Số nguyên từ 0 (tắt) đến {{max}}",
"saved": "Đã lưu tự động dọn dẹp",
"lastRun": "Lần chạy gần nhất {{time}}: đã xóa {{n}} phiên",
"space": "Cơ sở dữ liệu {{size}} · {{free}} trống bên trong · auto-vacuum: {{mode}}",
"compact": "Nén ngay",
"compacting": "Đang nén…",
"compactConfirmTitle": "Nén cơ sở dữ liệu?",
"compactConfirmBody": "Thao tác này ghi lại toàn bộ tệp cơ sở dữ liệu để trả dung lượng trống cho ổ đĩa và bật auto-vacuum tăng dần. Với cơ sở dữ liệu lớn sẽ mất một lúc, và sự kiện hook mới phải chờ đến khi xong.",
"compactDone": "Đã nén cơ sở dữ liệu: {{before}} → {{after}}",
"compactFailed": "Nén thất bại: {{error}}"
},
"backup": {
"title": "Backup hằng ngày",
"description": "Mỗi ngày một bản sao đầy đủ của cơ sở dữ liệu vào thư mục bạn chọn — tốt nhất là trên một ổ đĩa khác, để hỏng một ổ không mất luôn bản backup.",
@@ -0,0 +1,34 @@
/**
* @file sessionChange.test.ts
* @description Unit tests for `sessionChangeMatcher`: fires on a new session
* and on a change of the keyed columns, never on a re-broadcast that moved none.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { sessionChangeMatcher } from "../sessionChange";
import type { WSMessage } from "../types";
const msg = (type: string, data: unknown) => ({ type, data, timestamp: "" }) as WSMessage;
describe("sessionChangeMatcher", () => {
it("fires on a new session and on a state change, not on a same-state re-broadcast", () => {
const m = sessionChangeMatcher();
expect(m(msg("session_created", { id: "a", status: "active" }))).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "active", name: "x" }))).toBe(false);
expect(
m(msg("session_updated", { id: "a", status: "active", awaiting_input_since: "t" }))
).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "completed" }))).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "completed", archived_at: "t" }))).toBe(
true
);
});
it("takes an unseen or unkeyable session as a change, and ignores other types", () => {
const m = sessionChangeMatcher();
expect(m(msg("session_updated", { id: "b", status: "active" }))).toBe(true);
expect(m(msg("session_updated", null))).toBe(true);
expect(m(msg("new_event", { session_id: "b" }))).toBe(false);
});
});
+47
View File
@@ -1649,6 +1649,23 @@ export const api = {
/** POST /api/settings/backup/run — starts one; poll `get` while `running` */
run: () => request<BackupState & { ok: boolean }>("/settings/backup/run", { method: "POST" }),
},
/** Opt-in automatic retention and Compact (`server/lib/db-retention.js`). */
retention: {
/** GET /api/settings/retention */
get: () => request<RetentionState>("/settings/retention"),
/** PUT /api/settings/retention — `days` 0 turns it off */
save: (days: number) =>
request<RetentionState & { ok: boolean }>("/settings/retention", {
method: "PUT",
body: JSON.stringify({ days }),
}),
/** POST /api/settings/compact — a full VACUUM; poll `get` while `compacting` */
compact: () =>
request<RetentionState & { ok: boolean }>("/settings/compact", {
method: "POST",
body: JSON.stringify({ confirm: true }),
}),
},
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
* stored: `rotate` returns the new token this once, `get` never does. */
pushToken: {
@@ -5461,6 +5478,36 @@ export interface BackupState {
env_owned: { dir: boolean; hour: boolean; keep: boolean };
}
/** GET /api/settings/retention. `days` 0 = automatic retention off;
* `last_run`/`last_compact` are this server process's own. */
export interface RetentionState {
days: number;
max_days: number;
env_owned: { days: boolean };
last_run: {
at: string;
retention?: { purged_sessions: number; purged_events: number; error?: string };
reclaim?: { reclaimed_pages: number; error?: string };
} | null;
space: {
auto_vacuum: "none" | "full" | "incremental";
page_size: number;
page_count: number;
freelist_pages: number;
free_bytes: number;
} | null;
db_size: number;
compacting: boolean;
last_compact: {
at: string;
ok: boolean;
size_before?: number;
size_after?: number;
ms?: number;
error?: string;
} | null;
}
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
export interface PushTokenStatus {
enabled: boolean;
+9 -5
View File
@@ -14,6 +14,9 @@
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
* input box off the screen and empties it; the chat's draft is pasted back
* without Enter), so the half-typed text is never left behind on the other side.
* Attached images' paths go after the text, each in a paste of its own: `claude`
* turns a paste that is only an image's path into `[Image #n]`, but left a
* Windows path (`C:\…\drops\…png`) inside a paste of text as a plain path.
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
* arrows and Enter, or type into its "Type something" row).
@@ -24,7 +27,8 @@
export type PaneScreen = { rows: string[]; cols: number };
interface Pane {
paste: (text: string, submit?: boolean) => void;
/** `files`: image paths pasted after `text`, one paste each. */
paste: (text: string, submit?: boolean, files?: string[]) => void;
visible: () => boolean;
key?: (data: string) => void;
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
@@ -62,19 +66,19 @@ function ofLane(runId: string, laneId: number): boolean {
/** Pastes `text` into the pane of `runId` when it is mounted and on screen;
* false otherwise (not mounted yet, or hidden). */
export function pasteToRun(runId: string, text: string): boolean {
export function pasteToRun(runId: string, text: string, files: string[] = []): boolean {
const p = panes.get(runId);
if (!p?.visible()) return false;
p.paste(text);
p.paste(text, false, files);
return true;
}
/** Sends `text` as a message to the pane of `runId`: a paste, then Enter.
* False when the pane is not mounted or not attached. */
export function submitToRun(runId: string, text: string): boolean {
export function submitToRun(runId: string, text: string, files: string[] = []): boolean {
const p = panes.get(runId);
if (!p?.visible()) return false;
p.paste(text, true);
p.paste(text, true, files);
return true;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* @file sessionChange.ts
* @description `session_created` / `session_updated` matcher for live-refresh
* callers that care about only a few session columns. A session row is
* re-broadcast on nearly every hook (last activity, name, tokens…), so
* matching the bare message types made every such frame refetch; this one
* fires on a new session, or when the chosen key of a session actually
* changed. Each call keeps its own memory of the keys it has seen.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { Session, WSMessage } from "./types";
/** The columns that move a session between the active / waiting / archived counts. */
export const sessionStateKeyOf = (s: Partial<Session>): string =>
`${s.status}:${s.awaiting_input_since ? 1 : 0}:${s.archived_at ? 1 : 0}`;
export function sessionChangeMatcher(
keyOf: (s: Partial<Session>) => string = sessionStateKeyOf
): (msg: WSMessage) => boolean {
const seen = new Map<string, string>();
return (msg) => {
if (msg.type !== "session_created" && msg.type !== "session_updated") return false;
const s = msg.data as Partial<Session> | undefined;
// A frame we cannot key is taken as a change: refetching is the safe side.
if (!s || typeof s.id !== "string") return true;
const key = keyOf(s);
if (msg.type === "session_updated" && seen.get(s.id) === key) return false;
seen.set(s.id, key);
return true;
};
}
+5 -2
View File
@@ -2790,9 +2790,12 @@ function RecentActivityPanel({ sessionsById }: { sessionsById: Map<string, Sessi
useEffect(() => {
load();
}, [load]);
useLiveRefresh(load, { intervalMs: 10000 });
// New events arrive over the WebSocket (prepended below); a reload is only
// for what the socket cannot carry: a remote source's import, a gap while it
// was down (reconnect) or the tab hidden (shown again), and a slow safety poll.
useLiveRefresh(load, { intervalMs: 60000, match: isRemoteDataRefreshMessage });
// A new event is prepended at once (no reload); the poll reconciles.
// A new event is prepended at once (no reload); the reloads reconcile.
useEffect(
() =>
eventBus.subscribe((msg: WSMessage) => {
+5 -1
View File
@@ -1129,7 +1129,11 @@ export function SessionDetail() {
{visitedTabs.has("conversation") && (
<div hidden={activeTab !== "conversation"}>
<ConversationView sessionId={session.id} initialTranscriptId={pendingTranscriptId} />
<ConversationView
sessionId={session.id}
initialTranscriptId={pendingTranscriptId}
ended={session.status !== "active"}
/>
</div>
)}
+4 -1
View File
@@ -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`), 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>`.
* @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; under it the opt-in automatic cleanup and Compact, `components/settings/RetentionSettings.tsx`) 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>
*/
/* =============================================================================
@@ -103,6 +103,7 @@ import { RemoteSources } from "../components/RemoteSources";
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
import { PrivacySettings } from "../components/settings/PrivacySettings";
import { BackupSettings } from "../components/settings/BackupSettings";
import { RetentionSettings } from "../components/settings/RetentionSettings";
import { AutostartSettings } from "../components/settings/AutostartSettings";
import { usePermissions } from "../components/AuthGate";
import { Skeleton } from "../components/Skeleton";
@@ -1054,6 +1055,8 @@ export function Settings({
</button>
{actionBanner(["cleanup"])}
<RetentionSettings />
</section>
{/* ─── THIS BROWSER'S PREFERENCES ─── */}
+25 -24
View File
@@ -85,6 +85,9 @@ import { endRuns } from "../lib/stopSession";
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
import { confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { sessionChangeMatcher } from "../lib/sessionChange";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useWorkLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import {
@@ -1044,9 +1047,10 @@ export function Workspace() {
.catch(() => undefined);
}, []);
// Goes through `keepIfSame`: this fires every 5 s below, and a
// fresh-but-identical payload would otherwise re-render the lane strip, the
// lane card and the whole console area on every tick for nothing.
// Goes through `keepIfSame`: this re-reads on run-moving messages and a
// fallback poll below, and a fresh-but-identical payload would otherwise
// re-render the lane strip, the lane card and the whole console area for
// nothing.
const refreshList = useCallback(() => {
api.run
.list()
@@ -1061,33 +1065,30 @@ export function Workspace() {
window.dispatchEvent(new CustomEvent(LIVE_RUNS_EVENT, { detail: activeRuns.items }));
}, [activeRuns]);
// Background poll so the run list and history reflect external changes
// (server-boot reconciliation, sibling tabs, direct DB edits) even when
// no WS event fires. Lighter than typical WS gaps; aggressive enough that
// status flips appear within seconds without needing a manual refresh.
// The run list follows the WebSocket instead of a blind 5 s poll: a pane
// starting, ending or learning its session in slot 0 moves the lane's run
// columns (`lane_update`); an agent starting in a free tab (slots 1–9)
// shows as a new session, one ending as a session status change, and a pane
// dying under its agent as `runs_interrupted`. This page's own starts, stops
// and a shown terminal's exit call `refreshList` directly
// (`onRunsChanged`). What none of those cover — a shell tab opened or closed
// from another browser, server-boot reconciliation — is caught by the
// 30 s fallback poll, on reconnect, and when the tab is shown or focused.
const [laneRunChanged] = useState(() => laneChangeMatcher(runKeyOf));
const [sessionRunChanged] = useState(() => sessionChangeMatcher((s) => `${s.status}`));
useEffect(() => {
refreshList();
const tick = setInterval(() => {
// Hidden tab: skip; the visibility handler below re-reads on show.
if (document.visibilityState === "visible") refreshList();
}, 5000);
return () => clearInterval(tick);
}, [refreshList]);
// Refresh whenever the tab regains focus / visibility - typical when the
// user comes back from running `claude` in a terminal and wants to see the
// current state of every run without waiting for the next poll.
useLiveRefresh(refreshList, {
intervalMs: 30_000,
throttleMs: 1500,
match: (msg) =>
msg.type === "runs_interrupted" || laneRunChanged(msg) || sessionRunChanged(msg),
});
useEffect(() => {
const onFocus = () => refreshList();
const onVis = () => {
if (document.visibilityState === "visible") refreshList();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVis);
return () => {
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVis);
};
return () => window.removeEventListener("focus", onFocus);
}, [refreshList]);
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
@@ -77,6 +77,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
env_owned: { dir: false, hour: false, keep: false },
})),
},
retention: {
get: vi.fn(async () => ({
days: 0,
max_days: 3650,
env_owned: { days: false },
last_run: null,
space: null,
db_size: 0,
compacting: false,
last_compact: null,
})),
},
},
},
};
@@ -9357,6 +9357,108 @@ exit 0
</svg>
Run Cleanup
</button>
<div
class="border-t border-border pt-4 space-y-3"
>
<div>
<h4
class="text-xs font-medium text-fg-secondary"
>
Automatic cleanup
</h4>
<p
class="text-xs text-fg-muted"
>
Off by default. When on, the hourly maintenance deletes finished sessions (completed, error, abandoned) that started more than this many days ago — with their events, agents and token usage — a little at a time.
</p>
</div>
<div
class="flex flex-wrap items-end gap-3"
>
<div
class="bg-surface-2 rounded-lg px-4 py-3"
>
<label
class="text-xs text-fg-secondary block mb-2"
for="retention-days"
>
Delete sessions older than
</label>
<div
class="flex items-center gap-2"
>
<input
class="input w-20 text-sm text-right font-mono"
id="retention-days"
inputmode="numeric"
max="3650"
min="0"
step="1"
type="number"
value="0"
/>
<span
class="text-xs text-fg-muted"
>
days
</span>
</div>
</div>
<button
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
type="button"
>
Save
</button>
</div>
<p
class="text-xs text-fg-secondary"
>
Automatic cleanup is off.
</p>
<div
class="flex flex-wrap items-center gap-3"
>
<button
class="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
type="button"
>
<svg
class="lucide lucide-minimize2 w-3 h-3 inline mr-1"
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"
>
<polyline
points="4 14 10 14 10 20"
/>
<polyline
points="20 10 14 10 14 4"
/>
<line
x1="14"
x2="21"
y1="10"
y2="3"
/>
<line
x1="3"
x2="10"
y1="21"
y2="14"
/>
</svg>
Compact now
</button>
</div>
</div>
</section>
<section
class="scroll-mt-24"
@@ -362,6 +362,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
env_owned: { dir: false, hour: false, keep: false },
}),
},
retention: {
get: r({
days: 0,
max_days: 3650,
env_owned: { days: false },
last_run: null,
space: null,
db_size: 0,
compacting: false,
last_compact: null,
}),
},
exportData: () => "/api/settings/export",
cleanup: r({
ok: true,