Merge branch 'perf/local-opt': faster local install and updates, lighter polling and ingest
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user