feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)

- Changes panel: a working file opens in a CodeMirror editor with its diff
  inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the
  unchanged lines, Whole file shows them; side by side at 50/50 and full
  width; a Revert per block; original from HEAD or the branch's merge base
  (`mergeBase` on GET /git/changes?range=branch)
- Every diff: green = line added whole, red = removed whole, blue = edited
  in place (--diff-mod)
- History drawn as a commit graph (#58: `parents` on GET /git/log,
  --date-order; lib/commitGraph.ts)
- Per-workspace never-stage list, drawn orange in the Files tree (--kept);
  closed Terminal rows past three deleted
- Status bar plan quota for Claude (per account) and Codex
  (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that
  stays open, Refresh button with a spinner
- Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies;
  compact sidebar navigation and short session times
This commit is contained in:
2026-10-06 15:38:25 +07:00
parent f0500dc592
commit daf5b7be92
65 changed files with 2414 additions and 587 deletions
+4 -25
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description The install-wide settings, as four pages of the Settings nav (`SettingsHub.tsx`): `section="general"` — language —, `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, 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"` — system information — 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 (Sagi has its own page, `components/settings/PetSettings.tsx`). Appearance is its own 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, 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"` — system information — 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>
*/
/* =============================================================================
@@ -205,12 +205,12 @@ function useCountUp(end: number | null, durationMs = 1000) {
// ─── Main component ───
export function Settings({
section = "general",
section = "notifications",
}: {
/** Which Settings page this renders (`/settings/<section>`). */
section?: "general" | "data" | "about" | "notifications";
section?: "data" | "about" | "notifications";
} = {}) {
const { t, i18n } = useTranslation("settings");
const { t } = useTranslation("settings");
// Only Data and About show server info; General and Notifications never read it.
const needsInfo = section === "data" || section === "about";
const [loading, setLoading] = useState(needsInfo);
@@ -541,27 +541,6 @@ export function Settings({
</div>
)}
{section === "general" && (
<>
<SettingsSection title={t("language.title")} description={t("language.description")}>
<SettingsRow
label={t("language.label")}
control={
<SettingsSegmented
ariaLabel={t("language.label")}
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
onChange={(v) => void i18n.changeLanguage(v)}
options={[
{ value: "en" as const, label: t("language.names.en") },
{ value: "vi" as const, label: t("language.names.vi") },
]}
/>
}
/>
</SettingsSection>
</>
)}
{section === "notifications" && (
<>
<p className="text-xs text-fg-muted">{t("notifications.scopeNote")}</p>
+27 -29
View File
@@ -9,7 +9,8 @@
* element with that `id` or `data-settings-anchor` and briefly highlights it.
* Back returns to the page before Settings when there is one, else /workspace;
* an unknown section shows a skeleton until the tool list loads, then toasts
* and falls back to General. A tool's own settings live under `agents/<id>` — Claude Code and
* and falls back to Appearance (which also holds the language and the pet;
* `general` and `pet` redirect there). 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.
@@ -32,18 +33,12 @@ import {
Database,
Info,
Bot,
Boxes,
Coins,
GitBranch,
KeyRound,
Orbit,
Palette,
PawPrint,
Puzzle,
Router,
Search,
Settings as SettingsIcon,
SquareTerminal,
} from "lucide-react";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
@@ -52,7 +47,6 @@ import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { PetSettings } from "../components/settings/PetSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
@@ -64,6 +58,7 @@ import {
type SettingsEntry,
} from "../lib/settingsSearch";
import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
@@ -80,7 +75,7 @@ interface Section {
/** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
const LEGACY_TOOL: Record<string, string> = {
dashboard: "general",
dashboard: "appearance",
"claude-code": "agents/claude-code",
codex: "agents/codex",
"ai-router": "ai-router",
@@ -104,13 +99,29 @@ const historyIdx = (): number => {
export const backDelta = (current: number, entry: number): number =>
-Math.max(1, current - entry + 1);
/** An unknown section: say so, then show General. */
/** Sections folded into Appearance: old links and bookmarks still land. */
/** A tool's own mark as a nav icon, one stable component per tool id. */
const navIcons = new Map<string, ComponentType<{ className?: string }>>();
function toolNavIcon(id: string, label: string) {
let Icon = navIcons.get(id);
if (!Icon) {
Icon = ({ className }) => (
<AiToolIcon tool={{ id, label }} className={`${className ?? ""} !bg-transparent !p-0`} />
);
navIcons.set(id, Icon);
}
return Icon;
}
const MOVED: Record<string, string> = { general: "appearance", pet: "appearance#pet" };
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
const { t } = useTranslation("settings");
useEffect(() => {
toast.info(t("settingsNav.unknownSection", { section: name }));
}, [name, t]);
return <Navigate to="/settings/general" replace />;
return <Navigate to="/settings/appearance" replace />;
}
export function SettingsHub() {
@@ -191,13 +202,6 @@ export function SettingsHub() {
description: t(`settingsNav.section.${key}.description`),
});
const sections: Section[] = [
{
id: "general",
group: "general",
Icon: SettingsIcon,
...s("general"),
render: () => <Settings section="general" />,
},
{
id: "data",
group: "general",
@@ -219,13 +223,6 @@ export function SettingsHub() {
...s("appearance"),
render: () => <AppearanceSettings />,
},
{
id: "pet",
group: "general",
Icon: PawPrint,
...s("pet"),
render: () => <PetSettings />,
},
{
id: "notifications",
group: "general",
@@ -245,7 +242,7 @@ export function SettingsHub() {
{
id: "agents/claude-code",
group: "agents" as const,
Icon: Boxes,
Icon: toolNavIcon("claude", "Claude Code"),
...s("claudeCode"),
render: () => <CcConfig />,
},
@@ -256,7 +253,7 @@ export function SettingsHub() {
{
id: "agents/codex",
group: "agents" as const,
Icon: SquareTerminal,
Icon: toolNavIcon("codex", "Codex"),
...s("codex"),
render: () => <CodexConfig />,
},
@@ -269,7 +266,7 @@ export function SettingsHub() {
.map((x) => ({
id: `agents/${x.id}`,
group: "agents" as const,
Icon: x.id === "antigravity" ? Orbit : Puzzle,
Icon: toolNavIcon(x.id, x.label),
title: x.label,
description: t("settingsNav.section.tool.description", { tool: x.label }),
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
@@ -349,9 +346,10 @@ export function SettingsHub() {
? "agents"
: tool
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
: "general";
: "appearance";
return <Navigate to={`/settings/${to}`} replace />;
}
if (MOVED[rest]) return <Navigate to={`/settings/${MOVED[rest]}`} replace />;
const active = sections.find((x) => x.id === rest);
if (!active) {
// A catalog tool page exists only once the tool list has loaded.
+8 -1
View File
@@ -1275,6 +1275,7 @@ export function Workspace() {
laneId={panelAt.laneId}
session={panelAt.session}
full={changesFull}
wide={changesPreset !== "compact"}
onDirtyChange={setChangesDirty}
// A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an
@@ -1310,7 +1311,13 @@ export function Workspace() {
{tLanes("changes.title")}
</span>
</button>
{panelAt && <LaneGitRailCounts laneId={panelAt.laneId} session={panelAt.session} />}
{panelAt && (
<LaneGitRailCounts
laneId={panelAt.laneId}
session={panelAt.session}
onShowAll={() => setChangesOpen(true)}
/>
)}
</div>
))}
</div>
+2 -6
View File
@@ -66,12 +66,8 @@ const burst = () => {
};
describe("Settings", () => {
it("never reads server info on Notifications or General, even on activity", async () => {
const { unmount } = render(<Settings section="notifications" />);
burst();
await new Promise((r) => setTimeout(r, 30));
unmount();
render(<Settings section="general" />);
it("never reads server info on Notifications, even on activity", async () => {
render(<Settings section="notifications" />);
burst();
await new Promise((r) => setTimeout(r, 30));
expect(info).not.toHaveBeenCalled();
+29 -20
View File
@@ -22,6 +22,9 @@ vi.mock("../Settings", () => ({
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
),
}));
vi.mock("../../components/settings/PetSettings", () => ({
PetSettings: () => <div data-testid="pet-settings" />,
}));
vi.mock("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
vi.mock("../AiToolConfig", () => ({
@@ -114,7 +117,9 @@ describe("SettingsHub", () => {
});
it.each([
["/settings", "/settings/general"],
["/settings", "/settings/appearance"],
["/settings/general", "/settings/appearance"],
["/settings/pet", "/settings/appearance"],
["/settings?tool=codex", "/settings/agents/codex"],
["/settings?tool=claude-code", "/settings/agents/claude-code"],
["/settings?tool=gemini", "/settings/agents/gemini"],
@@ -125,10 +130,12 @@ describe("SettingsHub", () => {
expect(screen.getByTestId("where").textContent).toBe(to);
});
it("sends an unknown section to General once the lists have loaded", async () => {
it("sends an unknown section to Appearance once the lists have loaded", async () => {
renderAt("/settings/nope");
expect(await screen.findByTestId("settings-general")).toBeInTheDocument();
expect(screen.getByTestId("where").textContent).toBe("/settings/general");
expect(
await screen.findByRole("heading", { level: 1, name: "Appearance" })
).toBeInTheDocument();
expect(screen.getByTestId("where").textContent).toBe("/settings/appearance");
});
it("hides a disabled tool's page and gives an installed, enabled catalog tool one", async () => {
@@ -145,8 +152,8 @@ describe("SettingsHub", () => {
});
it("navigates from the mobile select", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
fireEvent.change(screen.getByRole("combobox", { name: "Settings sections" }), {
target: { value: "pricing" },
});
@@ -156,8 +163,8 @@ describe("SettingsHub", () => {
it("filters the nav by search, opens the first match on Enter and clears on Escape", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
const box = screen.getByRole("searchbox", { name: "Search settings" });
await user.type(box, "pricing");
const links = within(nav())
@@ -177,8 +184,8 @@ describe("SettingsHub", () => {
it("says when nothing matches, and Ctrl+F focuses the search", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
const box = screen.getByRole("searchbox", { name: "Search settings" });
expect(box).toHaveFocus();
@@ -187,8 +194,8 @@ describe("SettingsHub", () => {
});
it("Ctrl+F leaves the search alone while a modal dialog is open", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
@@ -204,8 +211,8 @@ describe("SettingsHub", () => {
it("a second Ctrl+F with the search focused selects its text instead of reaching the browser", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
const box = screen.getByRole<HTMLInputElement>("searchbox", { name: "Search settings" });
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
await user.type(box, "pet");
@@ -217,8 +224,8 @@ describe("SettingsHub", () => {
it("lists matching rows and deep-links a Claude Code sub-tab", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
await user.type(screen.getByRole("searchbox", { name: "Search settings" }), "marketplaces");
const list = screen.getByRole("list", { name: "Search results" });
await user.click(within(list).getAllByRole("link")[0]!);
@@ -227,14 +234,16 @@ describe("SettingsHub", () => {
);
});
it("puts Pricing in its own Cost group and splits General into General, Data and About", async () => {
it("puts Pricing in its own Cost group and folds language and pet into Appearance", async () => {
renderAt("/settings/data");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links.slice(0, 3)).toEqual(["General", "Data", "About"]);
expect(links.slice(0, 3)).toEqual(["Data", "About", "Appearance"]);
expect(links).not.toContain("General");
expect(links).not.toContain("Pet");
});
it("scrolls to and highlights the anchor the URL names", async () => {
@@ -258,11 +267,11 @@ describe("SettingsHub", () => {
expect(backDelta(0, 2)).toBe(-1);
});
it("toasts when an unknown section falls back to General", async () => {
it("toasts when an unknown section falls back to Appearance", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
renderAt("/settings/nope");
await screen.findByTestId("settings-general");
await screen.findByTestId("pet-settings");
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
off();
});
@@ -6832,74 +6832,6 @@ exports[`screen snapshots > Sessions 1`] = `
</div>
`;
exports[`screen snapshots > Settings 1`] = `
<div>
<div
class="animate-fade-in space-y-8"
>
<section
class="card p-5"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
Language
</h3>
<p
class="mt-0.5 text-xs text-fg-muted"
>
The language of the whole dashboard, remembered in this browser.
</p>
<div
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Interface language
</p>
</div>
<div
class="shrink-0"
>
<div
aria-label="Interface language"
class="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
role="radiogroup"
>
<button
aria-checked="true"
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors bg-accent/20 text-accent"
role="radio"
tabindex="0"
type="button"
>
English
</button>
<button
aria-checked="false"
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
role="radio"
tabindex="-1"
type="button"
>
Tiếng Việt
</button>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
`;
exports[`screen snapshots > Settings — about 1`] = `
<div>
<div
@@ -6981,7 +6913,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.2.0 (rebuild client to match)
UI build v2.2.1 (rebuild client to match)
</p>
</div>
<div
@@ -18,7 +18,7 @@ if (nodeProcess) nodeProcess.env.TZ = "UTC";
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
import type { ReactNode } from "react";
import { render, act, screen, fireEvent, within } from "@testing-library/react";
import { render, act, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import i18n from "i18next";
@@ -593,13 +593,9 @@ describe("screen snapshots", () => {
it("Run", async () => {
await snapshot(<Workspace />, "/run");
});
it("Settings", async () => {
await snapshot(<Settings />, "/settings");
});
it("Settings — notifications", async () => {
await snapshot(<Settings section="notifications" />, "/settings/notifications");
});
// General split into General / Data / About: keep the moved content covered.
it("Settings — data", async () => {
await snapshot(<Settings section="data" />, "/settings/data");
});
@@ -611,29 +607,6 @@ describe("screen snapshots", () => {
});
});
// The language picker moved off the sidebar into Settings → General.
describe("Settings — language", () => {
it("switches the interface language from General", async () => {
render(
<MemoryRouter initialEntries={["/settings/general"]}>
<Settings section="general" />
</MemoryRouter>
);
await settle();
const group = screen.getByRole("radiogroup", { name: "Interface language" });
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
"aria-checked",
"true"
);
fireEvent.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
await settle();
expect(i18n.language).toBe("vi");
await act(async () => {
await i18n.changeLanguage("en");
});
});
});
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
// other sub-tabs.