feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
@@ -93,6 +93,7 @@ import { fmtCost, timeAgo } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { usePermissions } from "./AuthGate";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
@@ -284,7 +285,9 @@ export function AlertsNotifications() {
|
||||
const { t } = useTranslation("alerts");
|
||||
const { t: ts } = useTranslation("settings");
|
||||
|
||||
const [tab, setTab] = useState<TabKey>("rules");
|
||||
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
|
||||
|
||||
// Rules
|
||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||
@@ -361,8 +364,8 @@ export function AlertsNotifications() {
|
||||
}, [unackedOnly, alerts.length, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
}, [loadRules]);
|
||||
if (isAdmin) loadRules();
|
||||
}, [loadRules, isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAlerts();
|
||||
@@ -504,7 +507,7 @@ export function AlertsNotifications() {
|
||||
(form.rule_type !== "budget" ||
|
||||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
||||
const ALL_TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
||||
{
|
||||
key: "rules",
|
||||
label: ts("alertsHub.tabRules"),
|
||||
@@ -519,6 +522,7 @@ export function AlertsNotifications() {
|
||||
badge: unacked || undefined,
|
||||
},
|
||||
];
|
||||
const TABS = isAdmin ? ALL_TABS : ALL_TABS.filter((tb) => tb.key === "activity");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -1086,7 +1090,7 @@ export function AlertsNotifications() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!alert.acknowledged_at && (
|
||||
{!alert.acknowledged_at && alert.can_ack !== false && (
|
||||
<button
|
||||
onClick={() => onAck(alert.id)}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0"
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
|
||||
* someone logs in, and again whenever a request answers 401 `EAUTH`
|
||||
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
||||
* previous user's data. `useAuth()` gives the sidebar the user and `logout`.
|
||||
* previous user's data. `useAuth()` gives the sidebar the user and `logout`;
|
||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -28,6 +29,29 @@ const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout:
|
||||
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
/**
|
||||
* What the logged-in user may do — the server's rules (server/lib/auth.js,
|
||||
* scope.js) mirrored so the UI hides what would only be refused. Local mode:
|
||||
* everything. `isAdmin`: the host's own setup (Settings → System, updates,
|
||||
* AI tools, proxies, users, alert rules…). `seesAll`: every user's rows
|
||||
* (admin, analyst). `canEditLane`: an admin any workspace, everyone else
|
||||
* only their own — the one that matters is an analyst looking at another
|
||||
* user's.
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const { state } = useAuth();
|
||||
const user = state.mode === "server" ? state.user : null;
|
||||
const local = state.mode !== "server";
|
||||
const isAdmin = local || user?.role === "admin";
|
||||
return {
|
||||
isAdmin,
|
||||
seesAll: local || (!!user && user.role !== "developer"),
|
||||
// A developer only ever sees their own rows, so everything shown is theirs.
|
||||
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
||||
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** The mode this browser last saw, so a local dashboard paints at once. */
|
||||
const MODE_KEY = "agenthub.authMode";
|
||||
const RETRY_MS = 2000;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
|
||||
import { usePets } from "./usePets";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const ITEM =
|
||||
@@ -50,6 +51,8 @@ export function PetMenu({
|
||||
const bundleInput = useRef<HTMLInputElement>(null);
|
||||
const [choosing, setChoosing] = useState(false);
|
||||
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
|
||||
// Adding or deleting a pet changes the whole install: an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
|
||||
// Closes on a press outside the menu, or Escape.
|
||||
useEffect(() => {
|
||||
@@ -133,6 +136,7 @@ export function PetMenu({
|
||||
</button>
|
||||
{list?.featured.map((f) => {
|
||||
const have = installed.get(f.id);
|
||||
if (!have && !isAdmin) return null;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
@@ -173,48 +177,54 @@ export function PetMenu({
|
||||
<span className="min-w-0 truncate">{p.displayName}</span>
|
||||
{mark(p.id)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pet-delete-${p.id}`}
|
||||
aria-label={t("sagi.pet.delete", { name: p.displayName })}
|
||||
title={t("sagi.pet.delete", { name: p.displayName })}
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pet-delete-${p.id}`}
|
||||
aria-label={t("sagi.pet.delete", { name: p.displayName })}
|
||||
title={t("sagi.pet.delete", { name: p.displayName })}
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<a
|
||||
href="https://codex-pets.net/"
|
||||
target="_blank"
|
||||
|
||||
@@ -74,7 +74,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { useAuth } from "./AuthGate";
|
||||
import { useAuth, usePermissions } from "./AuthGate";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { WorkspaceTree } from "./WorkspaceTree";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
@@ -192,6 +192,8 @@ export function Sidebar({
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuth();
|
||||
// Checking for (and applying) an update acts on the host: an admin's.
|
||||
const canUpdate = usePermissions().isAdmin;
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
const drawerOpen = Boolean(drawer?.open);
|
||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||
@@ -468,7 +470,9 @@ export function Sidebar({
|
||||
{[
|
||||
...NAV_KEYS,
|
||||
// Server mode: the Users page, for an admin or analyst.
|
||||
...(auth.state.mode === "server" && auth.state.user && auth.state.user.role !== "member"
|
||||
...(auth.state.mode === "server" &&
|
||||
auth.state.user &&
|
||||
auth.state.user.role !== "developer"
|
||||
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
|
||||
: []),
|
||||
].map(({ to, icon: Icon, key }) => {
|
||||
@@ -591,7 +595,7 @@ export function Sidebar({
|
||||
checking,
|
||||
available: updateAvailable,
|
||||
failed: checkError,
|
||||
onCheck: onCheckUpdates,
|
||||
onCheck: canUpdate ? onCheckUpdates : undefined,
|
||||
}}
|
||||
onResetStats={() => {
|
||||
eventCountRef.current = 0;
|
||||
@@ -633,7 +637,8 @@ interface ConnectionStatusModalProps {
|
||||
checking: boolean;
|
||||
available: boolean;
|
||||
failed: boolean;
|
||||
onCheck: () => void;
|
||||
/** Absent for a user who may not update (server mode, not an admin). */
|
||||
onCheck?: () => void;
|
||||
};
|
||||
onResetStats: () => void;
|
||||
}
|
||||
@@ -775,18 +780,20 @@ function ConnectionStatusModal({
|
||||
{update.title}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={update.onCheck}
|
||||
disabled={update.checking}
|
||||
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("nav:checkForUpdates")}
|
||||
</button>
|
||||
{update.onCheck && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={update.onCheck}
|
||||
disabled={update.checking}
|
||||
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("nav:checkForUpdates")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
* ## Public surface
|
||||
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TextSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TableRowSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
@@ -52,11 +51,6 @@
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TextSkeleton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TableRowSkeleton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
@@ -112,19 +106,6 @@ export function StatValueSkeleton({ className = "" }: { className?: string }) {
|
||||
return <Skeleton className={`h-7 w-20 align-middle ${className}`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-line text placeholder. Good for trend/sub labels.
|
||||
*/
|
||||
export function TextSkeleton({
|
||||
className = "",
|
||||
width = "w-16",
|
||||
}: {
|
||||
className?: string;
|
||||
width?: string;
|
||||
}) {
|
||||
return <Skeleton className={`h-3 ${width} align-middle ${className}`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full table-row skeleton for tabular pages. Caller passes the column count so
|
||||
* the skeleton lines up under existing headers.
|
||||
|
||||
@@ -88,6 +88,7 @@ import { copyText } from "../lib/clipboard";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
import { usePermissions } from "./AuthGate";
|
||||
|
||||
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
||||
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
||||
@@ -125,6 +126,7 @@ function loadDismissedSha(): string | null {
|
||||
* @returns `null` when no update is available or this release was dismissed.
|
||||
*/
|
||||
export function UpdateNotifier() {
|
||||
const { isAdmin } = usePermissions();
|
||||
const { t } = useTranslation("updates");
|
||||
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
||||
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
||||
@@ -190,7 +192,9 @@ export function UpdateNotifier() {
|
||||
status?.latest_release ||
|
||||
status?.remote_sha ||
|
||||
(installed ? `installed:${installed.root}` : null);
|
||||
// Updating replaces the host's install: only an admin is offered it.
|
||||
const show = Boolean(
|
||||
isAdmin &&
|
||||
status?.update_available &&
|
||||
dismissKey &&
|
||||
dismissedSha !== dismissKey &&
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
|
||||
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
|
||||
* live tab is opened from its session row. Live status comes from the shared
|
||||
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
|
||||
* lane list (`useWorkLanes`), live panes from `GET /api/run`, re-read whenever a
|
||||
* lane's status or session moves (at most every 3 s) and handed over by the
|
||||
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
|
||||
@@ -28,7 +28,7 @@ import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useWorkLanes } from "../hooks/useLanes";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { useThrottledValue } from "../hooks/useThrottledValue";
|
||||
import {
|
||||
@@ -70,7 +70,7 @@ export function WorkspaceTree() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const selected = selectedFrom(location.pathname, location.search);
|
||||
const { lanes, refresh: refreshLanes } = useLanes();
|
||||
const { lanes, refresh: refreshLanes } = useWorkLanes();
|
||||
const prefs = useProjectTreePrefs();
|
||||
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @file AlertsNotifications.roles.test.tsx
|
||||
* @description Server mode, not an admin: alert rules and channels fire for
|
||||
* every user, so neither tab is shown nor loaded, and an analyst's feed offers
|
||||
* Acknowledge only on alerts the server marks theirs (`can_ack`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const rulesList = vi.fn(async () => ({ rules: [] }));
|
||||
const alert = (id: number, can_ack: boolean) => ({
|
||||
id,
|
||||
rule_id: "r1",
|
||||
rule_name: `rule ${id}`,
|
||||
rule_type: "error_rate",
|
||||
session_id: null,
|
||||
agent_id: null,
|
||||
message: `boom ${id}`,
|
||||
details: null,
|
||||
triggered_at: "2026-10-08T00:00:00.000Z",
|
||||
acknowledged_at: null,
|
||||
can_ack,
|
||||
});
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
alerts: {
|
||||
rules: { list: () => rulesList() },
|
||||
list: vi.fn(async () => ({
|
||||
alerts: [alert(1, true), alert(2, false)],
|
||||
total: 2,
|
||||
unacked: 2,
|
||||
})),
|
||||
ack: vi.fn(),
|
||||
ackAll: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../AuthGate", () => ({
|
||||
usePermissions: () => ({ isAdmin: false, seesAll: true, canEditLane: () => false }),
|
||||
}));
|
||||
|
||||
import { AlertsNotifications } from "../AlertsNotifications";
|
||||
|
||||
describe("AlertsNotifications for a non-admin", () => {
|
||||
it("shows only the feed, and Acknowledge only where the server allows it", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertsNotifications />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("boom 2")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: /^acknowledge$/i })).toHaveLength(1);
|
||||
expect(screen.queryByRole("button", { name: /rules/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /channels/i })).toBeNull();
|
||||
expect(rulesList).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,25 +2,52 @@
|
||||
* @file AuthGate.test.tsx
|
||||
* @description The login gate: local mode renders the app untouched, server
|
||||
* mode shows the login page until a login succeeds, a wrong password says so,
|
||||
* and a later 401 `EAUTH` brings the login page back.
|
||||
* and a later 401 `EAUTH` brings the login page back. `usePermissions` and
|
||||
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
|
||||
* analyst reads everything but works only in their own workspaces, a
|
||||
* developer only ever sees (and so changes) their own.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../i18n";
|
||||
|
||||
const me = vi.fn();
|
||||
const login = vi.fn();
|
||||
const LANES = [
|
||||
{ id: 1, owner_id: 5 },
|
||||
{ id: 2, owner_id: 6 },
|
||||
];
|
||||
vi.mock("../../lib/api", () => ({
|
||||
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
|
||||
api: { auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() } },
|
||||
api: {
|
||||
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { AuthGate } from "../AuthGate";
|
||||
import { AuthGate, usePermissions } from "../AuthGate";
|
||||
import { useWorkLanes } from "../../hooks/useLanes";
|
||||
|
||||
const user = { id: 1, username: "mia", role: "member" };
|
||||
function Probe() {
|
||||
const p = usePermissions();
|
||||
const { lanes } = useWorkLanes();
|
||||
return (
|
||||
<p>
|
||||
{[
|
||||
`admin:${p.isAdmin}`,
|
||||
`all:${p.seesAll}`,
|
||||
`mine:${p.canEditLane({ owner_id: 5 })}`,
|
||||
`theirs:${p.canEditLane({ owner_id: 6 })}`,
|
||||
`work:${lanes.map((l) => l.id).join(",")}`,
|
||||
].join(" ")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const user = { id: 1, username: "mia", role: "developer" };
|
||||
|
||||
beforeEach(() => {
|
||||
me.mockReset();
|
||||
@@ -98,3 +125,37 @@ describe("AuthGate", () => {
|
||||
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePermissions / useWorkLanes", () => {
|
||||
const as = async (state: unknown) => {
|
||||
cleanup();
|
||||
me.mockResolvedValue(state);
|
||||
render(
|
||||
<AuthGate>
|
||||
<Probe />
|
||||
</AuthGate>
|
||||
);
|
||||
return (await screen.findByText(/work:\d/)).textContent;
|
||||
};
|
||||
|
||||
it("lets an admin and local mode do everything", async () => {
|
||||
for (const state of [
|
||||
{ mode: "local" },
|
||||
{ mode: "server", user: { id: 9, username: "boss", role: "admin" } },
|
||||
]) {
|
||||
expect(await as(state)).toBe("admin:true all:true mine:true theirs:true work:1,2");
|
||||
}
|
||||
});
|
||||
|
||||
it("lets an analyst read everything but work only in their own workspaces", async () => {
|
||||
expect(await as({ mode: "server", user: { id: 5, username: "ana", role: "analyst" } })).toBe(
|
||||
"admin:false all:true mine:true theirs:false work:1"
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a developer act on everything they can see, and nothing host-wide", async () => {
|
||||
expect(await as({ mode: "server", user: { id: 5, username: "mia", role: "developer" } })).toBe(
|
||||
"admin:false all:false mine:true theirs:true work:1,2"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,6 +178,10 @@ describe("WorkspaceTree", () => {
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
|
||||
await screen.findByText("Session 8");
|
||||
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
|
||||
await user.click(screen.getByTestId("workspace-sessions-less-1"));
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 6 });
|
||||
expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("renames a session on double-click + Enter, and archives one from its row", async () => {
|
||||
|
||||
@@ -76,9 +76,16 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
interface ConversationViewProps {
|
||||
sessionId: string;
|
||||
initialTranscriptId?: string | null;
|
||||
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
|
||||
* page-height cap — the parent sizes it and puts a composer under it. */
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
|
||||
export function ConversationView({
|
||||
sessionId,
|
||||
initialTranscriptId,
|
||||
fill = false,
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -91,6 +98,8 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
||||
const [showNewMsg, setShowNewMsg] = useState(false);
|
||||
// Scrolled up from the end: a chat (`fill`) offers the way back down.
|
||||
const [awayFromEnd, setAwayFromEnd] = useState(false);
|
||||
|
||||
// Track JSONL line numbers for incremental requests and history loading
|
||||
const lastLineRef = useRef(0);
|
||||
@@ -370,6 +379,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
// Detect if at bottom
|
||||
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
|
||||
isAtBottomRef.current = atBottom;
|
||||
setAwayFromEnd(!atBottom);
|
||||
|
||||
// Hide "new messages" indicator when scrolled to bottom
|
||||
if (atBottom) {
|
||||
@@ -390,10 +400,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col" style={{ minHeight: 0 }}>
|
||||
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
|
||||
{/* Toolbar - always rendered after the initial load so users can
|
||||
refresh even when no messages have streamed yet. */}
|
||||
{!loading && (
|
||||
{!loading && !fill && (
|
||||
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
|
||||
{transcripts.length > 1 && (
|
||||
<div className="relative">
|
||||
@@ -441,7 +451,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
>
|
||||
{/* History loading indicator */}
|
||||
{loadingHistory && (
|
||||
@@ -474,8 +484,28 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claude Desktop's round "back to the end" button */}
|
||||
{fill && awayFromEnd && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="conversation-to-end"
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
setShowNewMsg(false);
|
||||
}}
|
||||
aria-label={t("conversation.newMessages")}
|
||||
title={t("conversation.newMessages")}
|
||||
className="absolute bottom-3 left-1/2 z-10 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-surface-1 text-fg-secondary shadow-md transition-colors hover:text-fg-primary"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
{showNewMsg && (
|
||||
<span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-accent-solid" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* New messages indicator */}
|
||||
{showNewMsg && (
|
||||
{showNewMsg && !fill && (
|
||||
<button
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
|
||||
@@ -381,6 +381,8 @@ interface MarkdownContentProps {
|
||||
text: string;
|
||||
/** Tighter spacing for nested contexts (list items, quotes). */
|
||||
dense?: boolean;
|
||||
/** The assistant's reply in a chat: larger serif body text, as Claude Desktop. */
|
||||
prose?: boolean;
|
||||
}
|
||||
|
||||
const HEADING_STYLES = [
|
||||
@@ -392,12 +394,16 @@ const HEADING_STYLES = [
|
||||
"text-xs font-medium text-fg-secondary uppercase tracking-wider",
|
||||
];
|
||||
|
||||
export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
export function MarkdownContent({ text, dense = false, prose = false }: MarkdownContentProps) {
|
||||
const blocks = parseBlocks(text);
|
||||
const gap = dense ? "space-y-1.5" : "space-y-2.5";
|
||||
// A paragraph or list item takes the root's size and colour in prose.
|
||||
const bodyText = prose ? "" : "text-sm text-fg-secondary";
|
||||
|
||||
return (
|
||||
<div className={`text-sm text-fg-secondary leading-relaxed ${gap}`}>
|
||||
<div
|
||||
className={`${prose ? "font-serif text-[15px] leading-7 text-fg-primary" : "text-sm text-fg-secondary leading-relaxed"} ${gap}`}
|
||||
>
|
||||
{blocks.map((b, idx) => {
|
||||
switch (b.kind) {
|
||||
case "code":
|
||||
@@ -420,7 +426,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-fg-secondary">
|
||||
<li key={i} className={bodyText}>
|
||||
{renderListItem(item, `li${idx}-${i}`)}
|
||||
</li>
|
||||
))}
|
||||
@@ -433,7 +439,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-fg-secondary">
|
||||
<li key={i} className={bodyText}>
|
||||
{renderListItem(item, `li${idx}-${i}`)}
|
||||
</li>
|
||||
))}
|
||||
@@ -503,7 +509,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
|
||||
case "para":
|
||||
return (
|
||||
<p key={idx} className="text-sm text-fg-secondary whitespace-pre-wrap break-words">
|
||||
<p key={idx} className={`${bodyText} whitespace-pre-wrap break-words`}>
|
||||
{renderInline(b.text, `p${idx}`)}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -111,7 +111,7 @@ const SENDER_STYLES: Record<
|
||||
headerText: "text-status-warning",
|
||||
},
|
||||
};
|
||||
import { ToolCallBlock } from "./ToolCallBlock";
|
||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
import { fmt, formatModelName } from "../../lib/format";
|
||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||
@@ -264,6 +264,60 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
/** A text block of any sender: a task notification or skill body collapsed,
|
||||
* TUI markup as segments, anything else as markdown. */
|
||||
function renderTextBlock(text: string, key: number): React.ReactNode {
|
||||
// Detect task notifications, collapsed by default
|
||||
if (isTaskNotification(text)) {
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={key}
|
||||
text={text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />}
|
||||
title={i18n.t("sessions:conversation.taskNotification")}
|
||||
borderClass="border-syntax-property/20"
|
||||
bgClass="bg-syntax-property/5"
|
||||
textClass="text-syntax-property"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Detect skill content, collapsed by default
|
||||
if (isSkillContent(text)) {
|
||||
const pathMatch = text.match(/^Base directory for this skill:\s*(\S+)/);
|
||||
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={key}
|
||||
text={text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
|
||||
title={skillPath}
|
||||
borderClass="border-syntax-builtin/20"
|
||||
bgClass="bg-syntax-builtin/5"
|
||||
textClass="text-syntax-builtin"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
|
||||
// can appear inline (sometimes interleaved with prose). Parse the text
|
||||
// into segments and render each with the appropriate visual treatment.
|
||||
if (hasTuiTags(text)) {
|
||||
const segments = parseTuiSegments(text);
|
||||
return (
|
||||
<div key={key} className="space-y-2 min-w-0">
|
||||
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="min-w-0">
|
||||
<MarkdownContent text={stripAnsi(text)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Generic collapsible content block */
|
||||
function CollapsibleBlock({
|
||||
text,
|
||||
@@ -326,10 +380,46 @@ export function messageKeys(messages: TranscriptMessage[]): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** One thing on screen, in the Claude Desktop layout: a human message as a
|
||||
* bubble, the assistant's prose bare, and everything it did between two
|
||||
* pieces of prose — tool calls and thinking, across messages (Claude Code
|
||||
* writes each as its own line) — as ONE collapsed line; any other sender
|
||||
* keeps its attributed row. */
|
||||
type Item =
|
||||
| { kind: "event"; key: string; msg: TranscriptMessage }
|
||||
| { kind: "row"; key: string; msg: TranscriptMessage }
|
||||
| { kind: "user"; key: string; msg: TranscriptMessage }
|
||||
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
|
||||
| { kind: "work"; key: string; blocks: TranscriptContent[] };
|
||||
|
||||
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
|
||||
const items: Item[] = [];
|
||||
messages.forEach((msg, idx) => {
|
||||
const key = keys[idx]!;
|
||||
if (msg.type === "session_event") return items.push({ kind: "event", key, msg });
|
||||
// Purely tool_result: shown inside its call's block.
|
||||
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return;
|
||||
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||
if (sender === "user") return items.push({ kind: "user", key, msg });
|
||||
if (sender !== "assistant") return items.push({ kind: "row", key, msg });
|
||||
msg.content.forEach((block, b) => {
|
||||
const last = items[items.length - 1];
|
||||
if (block.type === "tool_use" || (block.type === "thinking" && block.text)) {
|
||||
if (last?.kind === "work") last.blocks.push(block);
|
||||
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
|
||||
} else if (block.type === "text" && block.text?.trim()) {
|
||||
items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text });
|
||||
}
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
export function MessageList({ messages, loading }: MessageListProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
const keys = useMemo(() => messageKeys(messages), [messages]);
|
||||
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -344,23 +434,280 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{messages.map((msg, idx) => {
|
||||
// Session lifecycle markers (e.g. /rename) render as a centered chip,
|
||||
// not as a user/assistant row.
|
||||
if (msg.type === "session_event") {
|
||||
return <SessionEventRow key={keys[idx]} title={msg.title} timestamp={msg.timestamp} />;
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
|
||||
{items.map((item) => {
|
||||
switch (item.kind) {
|
||||
case "event":
|
||||
return (
|
||||
<SessionEventRow
|
||||
key={item.key}
|
||||
title={item.msg.title}
|
||||
timestamp={item.msg.timestamp}
|
||||
/>
|
||||
);
|
||||
case "row":
|
||||
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
|
||||
case "user":
|
||||
return <UserBubble key={item.key} msg={item.msg} />;
|
||||
case "text":
|
||||
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
||||
case "work":
|
||||
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
|
||||
}
|
||||
// Skip user messages that are purely tool_result - they're rendered inside ToolCallBlock
|
||||
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) {
|
||||
return null;
|
||||
}
|
||||
return <MessageRow key={keys[idx]} msg={msg} toolResultMap={toolResultMap} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = "text-[10px] font-mono text-fg-muted";
|
||||
|
||||
/** The human's turn: a bubble on the right, its time on hover. A long one (a
|
||||
* paste, a compaction summary) shows its first lines until opened. */
|
||||
const LONG_BUBBLE = 800;
|
||||
const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE;
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-user"
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
|
||||
className="group flex flex-col items-end gap-1"
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
|
||||
long && !open
|
||||
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{msg.content.map((block, b) => {
|
||||
if (block.type !== "text" || !block.text) return null;
|
||||
// Plain typing reads as typed; anything structured keeps its rendering.
|
||||
const plain =
|
||||
!hasTuiTags(block.text) &&
|
||||
!isSkillContent(block.text) &&
|
||||
!isTaskNotification(block.text);
|
||||
return plain ? (
|
||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||
{stripAnsi(block.text).trim()}
|
||||
</p>
|
||||
) : (
|
||||
renderTextBlock(block.text, b)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{long && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="text-xs text-fg-muted hover:text-fg-secondary"
|
||||
>
|
||||
{open ? t("conversation.showLess") : t("conversation.showMore")}
|
||||
</button>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
<span className={`${meta} opacity-0 transition-opacity group-hover:opacity-100`}>
|
||||
{formatLocalTime(msg.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** The assistant's prose, bare on the page; model, tokens and time on hover. */
|
||||
const AssistantText = memo(function AssistantText({
|
||||
msg,
|
||||
text,
|
||||
}: {
|
||||
msg: TranscriptMessage;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-assistant"
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 120px" }}
|
||||
className="group min-w-0"
|
||||
>
|
||||
{hasTuiTags(text) || isSkillContent(text) || isTaskNotification(text) ? (
|
||||
renderTextBlock(text, 0)
|
||||
) : (
|
||||
<MarkdownContent text={stripAnsi(text)} prose />
|
||||
)}
|
||||
<div
|
||||
className={`${meta} mt-1 flex gap-2 opacity-0 transition-opacity group-hover:opacity-100`}
|
||||
>
|
||||
{msg.model && <span>{formatModelName(msg.model)}</span>}
|
||||
{msg.usage && (
|
||||
<span>
|
||||
↓ {fmt(msg.usage.input_tokens)} · ↑ {fmt(msg.usage.output_tokens)}
|
||||
</span>
|
||||
)}
|
||||
{msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** A muted line that opens to show what it stands for. */
|
||||
function Disclosure({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="inline-flex items-center gap-1 text-sm text-fg-muted transition-colors hover:text-fg-secondary"
|
||||
>
|
||||
{label}
|
||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
{open && <div className="mt-2 space-y-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The kind of work a tool call is, for the group's label (Claude Code and
|
||||
* Codex names); anything else counts as a tool. */
|
||||
const WORK_KIND: Record<string, string> = {
|
||||
bash: "commands",
|
||||
shell: "commands",
|
||||
exec_command: "commands",
|
||||
powershell: "commands",
|
||||
edit: "edits",
|
||||
multiedit: "edits",
|
||||
write: "edits",
|
||||
notebookedit: "edits",
|
||||
apply_patch: "edits",
|
||||
read: "reads",
|
||||
grep: "searches",
|
||||
glob: "searches",
|
||||
ls: "searches",
|
||||
websearch: "web",
|
||||
webfetch: "web",
|
||||
task: "agents",
|
||||
agent: "agents",
|
||||
};
|
||||
|
||||
/** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */
|
||||
export function workLabel(blocks: TranscriptContent[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
for (const b of blocks) {
|
||||
if (b.type !== "tool_use") continue;
|
||||
const kind = WORK_KIND[(b.name ?? "").toLowerCase()] ?? "tools";
|
||||
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
||||
}
|
||||
if (!counts.size) return i18n.t("sessions:conversation.thinking");
|
||||
const text = [...counts]
|
||||
.map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count }))
|
||||
.join(", ");
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
/** One step inside an opened group, as Claude Desktop lists them: its own
|
||||
* description (Claude Code's Bash `description`) or the tool and target as
|
||||
* the title, the call itself under it in a capped scroll box, the result one
|
||||
* click further. Thinking is a step too, closed. */
|
||||
function WorkStep({
|
||||
block,
|
||||
result,
|
||||
}: {
|
||||
block: TranscriptContent;
|
||||
result: TranscriptContent | null;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const thinking = block.type === "thinking";
|
||||
const [open, setOpen] = useState(!thinking);
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const input = block.input as Record<string, unknown> | undefined;
|
||||
const title = thinking
|
||||
? t("conversation.thinking")
|
||||
: typeof input?.description === "string" && input.description
|
||||
? input.description
|
||||
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`flex w-full min-w-0 items-center gap-1 text-left text-sm transition-colors hover:text-fg-secondary ${
|
||||
result?.is_error ? "text-status-danger" : "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 shrink-0 transition-transform ${open ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{thinking ? (
|
||||
<div className="max-h-60 overflow-auto text-fg-muted">
|
||||
<MarkdownContent text={block.text ?? ""} dense />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{/* The description is the title already. */}
|
||||
{renderInput(
|
||||
input ? { ...block, input: { ...input, description: undefined } } : block
|
||||
)}
|
||||
</div>
|
||||
{result && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showResult}
|
||||
onClick={() => setShowResult(!showResult)}
|
||||
className="inline-flex items-center gap-1 text-xs text-fg-muted hover:text-fg-secondary"
|
||||
>
|
||||
{result.is_error ? t("conversation.tool.error") : t("conversation.tool.output")}
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 transition-transform ${showResult ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{result && showResult && (
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{renderResult(result, block.name ?? "")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the assistant did between two replies as one line, opening onto
|
||||
* one framed list of its steps. */
|
||||
const WorkGroup = memo(function WorkGroup({
|
||||
blocks,
|
||||
toolResultMap,
|
||||
}: {
|
||||
blocks: TranscriptContent[];
|
||||
toolResultMap: ReturnType<typeof buildToolResultMap>;
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="chat-tools">
|
||||
<Disclosure label={workLabel(blocks)}>
|
||||
<div className="divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||
{blocks.map((block, i) => (
|
||||
<WorkStep
|
||||
key={block.id ?? i}
|
||||
block={block}
|
||||
result={block.id ? (toolResultMap.get(block.id) ?? null) : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** One user/assistant row. Memoized: a live tail append re-renders only the
|
||||
* new rows; its thinking-block expansion is its own state, kept by key. */
|
||||
const MessageRow = memo(function MessageRow({
|
||||
@@ -419,59 +766,7 @@ const MessageRow = memo(function MessageRow({
|
||||
|
||||
{/* Content blocks */}
|
||||
{msg.content.map((block, bIdx) => {
|
||||
if (block.type === "text" && block.text) {
|
||||
// Detect task notifications, collapsed by default
|
||||
if (isTaskNotification(block.text)) {
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />
|
||||
}
|
||||
title={t("conversation.taskNotification")}
|
||||
borderClass="border-syntax-property/20"
|
||||
bgClass="bg-syntax-property/5"
|
||||
textClass="text-syntax-property"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Detect skill content, collapsed by default
|
||||
if (isSkillContent(block.text)) {
|
||||
const pathMatch = block.text.match(/^Base directory for this skill:\s*(\S+)/);
|
||||
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
|
||||
title={skillPath}
|
||||
borderClass="border-syntax-builtin/20"
|
||||
bgClass="bg-syntax-builtin/5"
|
||||
textClass="text-syntax-builtin"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
|
||||
// can appear inline (sometimes interleaved with prose). Parse the text
|
||||
// into segments and render each with the appropriate visual treatment.
|
||||
if (hasTuiTags(block.text)) {
|
||||
const segments = parseTuiSegments(block.text);
|
||||
return (
|
||||
<div key={bIdx} className="space-y-2 min-w-0">
|
||||
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={bIdx} className="min-w-0">
|
||||
<MarkdownContent text={stripAnsi(block.text)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (block.type === "text" && block.text) return renderTextBlock(block.text, bIdx);
|
||||
|
||||
if (block.type === "thinking" && block.text) {
|
||||
const thinkKey = bIdx;
|
||||
|
||||
@@ -94,7 +94,7 @@ function langFromPath(path: string): string {
|
||||
}
|
||||
|
||||
/** Build a one-line summary of the tool call to show in the collapsed header. */
|
||||
function buildSummary(toolUse: TranscriptContent): string | null {
|
||||
export function buildSummary(toolUse: TranscriptContent): string | null {
|
||||
const input = toolUse.input;
|
||||
if (!input || typeof input !== "object" || "_truncated" in input) return null;
|
||||
const obj = input as Record<string, unknown>;
|
||||
@@ -112,7 +112,7 @@ function buildSummary(toolUse: TranscriptContent): string | null {
|
||||
const tt = (key: string) => i18n.t(`sessions:conversation.tool.${key}`);
|
||||
|
||||
/** Render the input pane with tool-aware formatting. */
|
||||
function renderInput(toolUse: TranscriptContent) {
|
||||
export function renderInput(toolUse: TranscriptContent) {
|
||||
const input = toolUse.input;
|
||||
if (!input) return null;
|
||||
|
||||
@@ -231,7 +231,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
}
|
||||
|
||||
/** Render the result pane: detect diff/json/text. */
|
||||
function renderResult(toolResult: TranscriptContent, toolName: string) {
|
||||
export function renderResult(toolResult: TranscriptContent, toolName: string) {
|
||||
const text = toolResult.output ?? "";
|
||||
if (text.length === 0)
|
||||
return <div className="text-xs text-fg-muted italic px-1">({tt("empty")})</div>;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* @file MessageList.sender.test.tsx
|
||||
* @description Verifies the transcript renders each message under its TRUE
|
||||
* sender label — User / Assistant / Main agent / System — instead of labeling
|
||||
* every `type:"user"` line "User" (reported transcript mis-attribution).
|
||||
* @description Verifies the transcript renders each message by its TRUE
|
||||
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
|
||||
* layout), Main agent / System under their own label — instead of treating
|
||||
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
||||
* and folds a run of tool calls into one line.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MessageList } from "../MessageList";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MessageList, workLabel } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
@@ -36,8 +38,8 @@ describe("MessageList — sender attribution", () => {
|
||||
];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-user")).toHaveTextContent("spin up a team");
|
||||
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("on it");
|
||||
expect(screen.getByText("System")).toBeInTheDocument();
|
||||
expect(screen.getByText("Main agent")).toBeInTheDocument();
|
||||
});
|
||||
@@ -48,7 +50,31 @@ describe("MessageList — sender attribution", () => {
|
||||
msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant"
|
||||
];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-user")).toHaveTextContent("hi there");
|
||||
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("hello");
|
||||
});
|
||||
|
||||
it("folds consecutive tool calls — one per transcript line — into one line", () => {
|
||||
const call = (id: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
|
||||
});
|
||||
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
|
||||
const messages = [call("a"), result("a"), call("b"), result("b"), call("c")];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
const group = screen.getAllByTestId("chat-tools");
|
||||
expect(group).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Ran 3 commands/ }));
|
||||
expect(group[0]).toHaveTextContent("echo b");
|
||||
});
|
||||
|
||||
it("names a mixed run by what it did, like Claude Desktop", () => {
|
||||
const use = (name: string) => ({ type: "tool_use" as const, name });
|
||||
expect(workLabel([use("Bash"), use("Bash"), use("Edit"), use("Read")])).toBe(
|
||||
"Ran 2 commands, edited a file, read a file"
|
||||
);
|
||||
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
|
||||
* a function that moves the cursor to the next / previous changed block.
|
||||
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
|
||||
* Chat chip above it, which hands the panel the selection's 1-based lines.
|
||||
* Chat chip at the end of its first line (inline, so it covers no code),
|
||||
* which hands the panel the selection's 1-based lines.
|
||||
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -49,9 +50,7 @@ import {
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
showTooltip,
|
||||
type DecorationSet,
|
||||
type Tooltip,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
@@ -285,23 +284,41 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Add to Chat above a non-empty selection; `build` makes its DOM from the
|
||||
* selection's first and last 1-based line. A selection ending at a line's
|
||||
* start (a whole-line drag) does not take that line. */
|
||||
class ChatChip extends WidgetType {
|
||||
constructor(
|
||||
readonly start: number,
|
||||
readonly end: number,
|
||||
readonly build: (start: number, end: number) => HTMLElement
|
||||
) {
|
||||
super();
|
||||
}
|
||||
eq(o: ChatChip) {
|
||||
return this.start === o.start && this.end === o.end;
|
||||
}
|
||||
toDOM() {
|
||||
return this.build(this.start, this.end);
|
||||
}
|
||||
}
|
||||
|
||||
/** Add to Chat inline at the end of a non-empty selection's first line —
|
||||
* the empty space right of the code, so it covers no line. `build` makes its
|
||||
* DOM from the selection's first and last 1-based line. A selection ending
|
||||
* at a line's start (a whole-line drag) does not take that line. */
|
||||
function chatChip(build: (start: number, end: number) => HTMLElement) {
|
||||
return StateField.define<Tooltip | null>({
|
||||
create: () => null,
|
||||
update(tip, tr) {
|
||||
if (!tr.docChanged && !tr.selection) return tip;
|
||||
return StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(deco, tr) {
|
||||
if (!tr.docChanged && !tr.selection) return deco;
|
||||
const { from, to, empty } = tr.state.selection.main;
|
||||
if (empty) return null;
|
||||
if (empty) return Decoration.none;
|
||||
const doc = tr.state.doc;
|
||||
const start = doc.lineAt(from).number;
|
||||
const first = doc.lineAt(from);
|
||||
const last = doc.lineAt(to);
|
||||
const end = to === last.from && last.number > start ? last.number - 1 : last.number;
|
||||
return { pos: from, above: true, create: () => ({ dom: build(start, end) }) };
|
||||
const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
|
||||
const widget = new ChatChip(first.number, end, build);
|
||||
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
|
||||
},
|
||||
provide: (f) => showTooltip.from(f),
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -319,8 +336,6 @@ const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
|
||||
const M = "&.cm-merge-b.cm-merge-b";
|
||||
const A = "&.cm-merge-a.cm-merge-a";
|
||||
const theme = EditorView.theme({
|
||||
// The chip draws its own box; drop its wrapper's default tooltip one.
|
||||
".cm-tooltip.cm-ah-chat": { border: "none", background: "transparent" },
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "11px",
|
||||
@@ -491,7 +506,11 @@ export default function DiffEditor({
|
||||
|
||||
useEffect(() => {
|
||||
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
|
||||
const common = [lineNumbers(), diffColours(lang), theme];
|
||||
// A CRLF file stays CRLF: CodeMirror would hand `onChange` LF text, the
|
||||
// panel would read that as an unsaved edit (no Undo / Keep bars) and Save
|
||||
// would rewrite every line ending.
|
||||
const eol = value.includes("\r\n") ? [EditorState.lineSeparator.of("\r\n")] : [];
|
||||
const common = [lineNumbers(), diffColours(lang), theme, ...eol];
|
||||
const editing = [
|
||||
...common,
|
||||
highlightActiveLineGutter(),
|
||||
@@ -513,9 +532,9 @@ export default function DiffEditor({
|
||||
]),
|
||||
EditorView.contentAttributes.of({ "aria-label": label }),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
|
||||
if (u.docChanged) cb.current.onChange(u.state.sliceDoc());
|
||||
}),
|
||||
blockBars(Text.of(original.split("\n")), buildBar),
|
||||
blockBars(Text.of(original.split(/\r?\n/)), buildBar),
|
||||
blameSlot.of([]),
|
||||
...(onAddToChat
|
||||
? [
|
||||
@@ -525,16 +544,11 @@ export default function DiffEditor({
|
||||
b.dataset.testid = "lane-editor-add-to-chat";
|
||||
b.textContent = cb.current.t("changes.addToChat");
|
||||
b.className =
|
||||
"rounded border border-border-light bg-surface-1 px-1.5 py-0.5 font-sans text-[11px] text-fg-primary shadow-card hover:bg-surface-2";
|
||||
"ml-3 rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent align-middle hover:bg-surface-2";
|
||||
// Keep the editor's focus (and so its selection) through the click.
|
||||
b.onmousedown = (e) => e.preventDefault();
|
||||
b.onclick = () => cb.current.onAddToChat?.(start, end);
|
||||
// CodeMirror makes the returned element the `.cm-tooltip`, whose
|
||||
// themed rule above would strip the button's own border and fill.
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "cm-ah-chat";
|
||||
wrap.append(b);
|
||||
return wrap;
|
||||
return b;
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
@@ -628,7 +642,7 @@ export default function DiffEditor({
|
||||
// A value set from outside (Cancel's revert, a save's fresh text).
|
||||
useEffect(() => {
|
||||
const v = view.current;
|
||||
if (v && v.state.doc.toString() !== value) {
|
||||
if (v && v.state.sliceDoc() !== value) {
|
||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
@@ -104,13 +104,6 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
return locks;
|
||||
}
|
||||
|
||||
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
active: "bg-status-success",
|
||||
// A ring: a surface-4 fill vanished on the light themes.
|
||||
idle: "border border-fg-muted",
|
||||
dead: "bg-status-danger",
|
||||
};
|
||||
|
||||
export function since(sec: number | null): string {
|
||||
if (sec === null) return "—";
|
||||
if (sec < 60) return `${sec}s`;
|
||||
|
||||
@@ -128,12 +128,12 @@ export function SessionReplay({
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} />
|
||||
<ConversationView sessionId={sessionId} fill />
|
||||
</div>
|
||||
{livePane ? (
|
||||
<div
|
||||
data-testid="session-replay-live"
|
||||
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.live")}</span>
|
||||
<button
|
||||
@@ -148,7 +148,7 @@ export function SessionReplay({
|
||||
) : session && !resumable ? (
|
||||
<div
|
||||
data-testid="session-replay-no-resume"
|
||||
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
|
||||
<button
|
||||
@@ -160,7 +160,7 @@ export function SessionReplay({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
|
||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useLanes } from "../../hooks/useLanes";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||
@@ -52,6 +53,7 @@ export function SessionResumeButton({
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { lanes } = useLanes();
|
||||
const { canEditLane } = usePermissions();
|
||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -77,7 +79,8 @@ export function SessionResumeButton({
|
||||
? null
|
||||
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
||||
owningLane(session.cwd, tool, lanes));
|
||||
if (!lane || runs === null) return null;
|
||||
// Server mode: an analyst may resume only in a workspace of their own.
|
||||
if (!lane || !canEditLane(lane) || runs === null) return null;
|
||||
const run = liveRunFor(lane, runs, session.id);
|
||||
const same = (a: string, b: string) => strip(a) === strip(b);
|
||||
// Its own worktree counts too: the server resumes it there (`claude
|
||||
|
||||
@@ -776,6 +776,16 @@ function HomeBody({
|
||||
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
|
||||
</button>
|
||||
)}
|
||||
{recentLimit > RECENT_PAGE && sessionTotal > RECENT_PAGE && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-recents-less"
|
||||
onClick={() => setRecentLimit(RECENT_PAGE)}
|
||||
className="ml-3 mt-1 text-xs text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("tree.showLess")}
|
||||
</button>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<SessionFold
|
||||
testId={`workspace-archived-${lane.id}`}
|
||||
@@ -923,6 +933,7 @@ function HomeBody({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>
|
||||
{files.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
|
||||
* which never has a session) as **Terminal** while it is open — a closed one
|
||||
* leaves no row; Ctrl/Cmd+click opens
|
||||
* it in a split beside the focused pane) — attaching, never starting or
|
||||
* it in a split in the tab on screen) — attaching, never starting or
|
||||
* resuming anything;
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
* `SessionReplay`): its conversation with a prompt box, resumed only when a
|
||||
* message is SENT, because a resume re-reads the whole transcript into context.
|
||||
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
||||
* loads every one. A
|
||||
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
||||
* live tab whose session was archived is not brought back as a New session,
|
||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
||||
* dot carries its meaning as screen-reader text.
|
||||
@@ -568,6 +568,16 @@ export function WorkspaceSessionList({
|
||||
{t("tree.seeAll", { count: more })}
|
||||
</button>
|
||||
)}
|
||||
{more === 0 && !q && own && own.total > SESSION_LIMIT && limit > SESSION_LIMIT && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-sessions-less-${lane.id}`}
|
||||
onClick={() => setLimit(SESSION_LIMIT)}
|
||||
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("tree.showLess")}
|
||||
</button>
|
||||
)}
|
||||
{failure}
|
||||
{confirmArchive}
|
||||
{ctx.menu && (
|
||||
|
||||
@@ -255,6 +255,39 @@ describe("DiffEditor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["unified", "split"] as const)(
|
||||
"keeps a CRLF file's text as is (%s): no edit at mount, so the bars stay",
|
||||
async (layout) => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
layout={layout}
|
||||
value={"a\r\nB\r\nc"}
|
||||
original={"a\r\nb\r\nc"}
|
||||
lang="plain"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={onChange}
|
||||
onSave={vi.fn()}
|
||||
onBlock={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(1)
|
||||
);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
const eds = container.querySelectorAll<HTMLElement>(".cm-editor");
|
||||
const view = EditorView.findFromDOM(eds[eds.length - 1]!)!;
|
||||
expect(view.state.sliceDoc()).toBe("a\r\nB\r\nc");
|
||||
// Only B changed: the CRs are not read as an edit on every line.
|
||||
expect(
|
||||
container
|
||||
.querySelectorAll(".cm-editor")
|
||||
[eds.length - 1]!.querySelectorAll(".cm-changedLine")
|
||||
).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
|
||||
it("offers Add to Chat on a selection, with its 1-based line range", async () => {
|
||||
const onAddToChat = vi.fn();
|
||||
const { container } = render(
|
||||
@@ -280,9 +313,10 @@ describe("DiffEditor", () => {
|
||||
expect(b).not.toBeNull();
|
||||
return b!;
|
||||
});
|
||||
// The chip keeps its own box: the tooltip theme strips the WRAPPER's, so
|
||||
// the button must not be the `.cm-tooltip` itself.
|
||||
expect(chip.classList.contains("cm-tooltip")).toBe(false);
|
||||
// Inline at the end of the first selected line, never a tooltip over the
|
||||
// line above it.
|
||||
expect(chip.closest(".cm-tooltip")).toBeNull();
|
||||
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
|
||||
fireEvent.click(chip);
|
||||
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
|
||||
view.dispatch({ selection: { anchor: 6 } });
|
||||
|
||||
@@ -397,6 +397,24 @@ describe("WorkspaceHome", () => {
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
|
||||
});
|
||||
|
||||
it("shows more recents a page at a time, and collapses them again", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async (params) => {
|
||||
const all = (params as { archived?: boolean }).archived
|
||||
? []
|
||||
: Array.from({ length: 35 }, (_, i) => ({ ...sessions[1]!, id: `s-${i}` }));
|
||||
const limit = (params as { limit?: number }).limit ?? 30;
|
||||
return { sessions: all.slice(0, limit), total: all.length, limit, offset: 0 };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await user.click(await screen.findByTestId("workspace-recents-more"));
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 60 });
|
||||
await user.click(await screen.findByTestId("workspace-recents-less"));
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 30 });
|
||||
expect(await screen.findByTestId("workspace-recents-more")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workspace-recents-less")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a live recent's pane and an ended one's conversation, resuming nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
|
||||
@@ -262,8 +262,8 @@ export function splitDiff({
|
||||
collapse?: { margin: number; minSize: number };
|
||||
revertLabel: string;
|
||||
}): { a: EditorView; b: EditorView; destroy: () => void } {
|
||||
const origDoc = Text.of(original.split("\n"));
|
||||
const chunks0 = Chunk.build(origDoc, Text.of(value.split("\n")));
|
||||
const origDoc = Text.of(original.split(/\r?\n/));
|
||||
const chunks0 = Chunk.build(origDoc, Text.of(value.split(/\r?\n/)));
|
||||
|
||||
const root = parent.appendChild(document.createElement("div"));
|
||||
root.className = "flex h-full min-h-0";
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
|
||||
* workspace's home) at its left end, `actions` (the single pane's split menus)
|
||||
* at its right end. A click shows a tab; Ctrl/Cmd+click on a one-pane tab (or
|
||||
* a `+` item) brings it into the tab on screen, split to the right, and a
|
||||
* a `+` item) brings it into the tab on screen, in the same order as a join, and a
|
||||
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
|
||||
* tab drags along the bar to reorder it, and a pane header dragged onto the
|
||||
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
* new lane's own live session for its slot, or an empty pane — never the
|
||||
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
|
||||
* tab) attaches to that tmux session only; nothing is started or resumed.
|
||||
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
|
||||
* conversation laid OVER the terminal, which stays mounted and attached under
|
||||
* it. Chat or terminal is the viewer's own Chat UI setting (Settings →
|
||||
* Appearance, `lib/chatUi.ts`, per account) — one choice for every pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -23,6 +27,10 @@ import type { RunHandle, RunListResponse } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { PaneChat } from "./PaneChat";
|
||||
import { useChatUi } from "../../lib/chatUi";
|
||||
|
||||
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
|
||||
export interface LaneConsolePaneProps {
|
||||
lanes: Lane[];
|
||||
@@ -70,6 +78,12 @@ export function LaneConsolePane({
|
||||
// The tool of the run on screen — a session's own, which need not be the
|
||||
// workspace's; the workspace's with no run.
|
||||
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
|
||||
const chat = useChatUi();
|
||||
// A run learns its session id after it starts (Codex): the page's poll has it.
|
||||
const chatSession = handle
|
||||
? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId)
|
||||
: null;
|
||||
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
|
||||
|
||||
useEffect(() => {
|
||||
onHasActiveRunChange?.(handle !== null);
|
||||
@@ -173,11 +187,22 @@ export function LaneConsolePane({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
{/* Over the terminal, never instead of it: unmounting would drop the attach. */}
|
||||
{chatable && chat && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-surface-0">
|
||||
<PaneChat
|
||||
runId={handle.id}
|
||||
sessionId={chatSession}
|
||||
tool={handle.tool}
|
||||
cwd={handle.cwd || currentLane?.cwd || ""}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<TerminalView
|
||||
runId={handle.id}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={autoFocus}
|
||||
autoFocus={autoFocus && !(chatable && chat)}
|
||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||
// the tab bar moves on (another tab, or the home's prompt box)
|
||||
// rather than leaving a dead terminal the user has to click out of.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @file PaneChat.tsx
|
||||
* @description A live pane's Chat view, the way Claude Desktop shows a session:
|
||||
* the session's conversation (`ConversationView`, kept current by its own poll)
|
||||
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and
|
||||
* attached underneath — a message is sent through that terminal
|
||||
* (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so
|
||||
* nothing new reaches the server. A permission prompt or a menu still needs the
|
||||
* terminal; the hint under the box says so.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import { PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||
import { submitToRun } from "../../lib/panePaste";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
export function PaneChat({
|
||||
runId,
|
||||
sessionId,
|
||||
tool,
|
||||
cwd,
|
||||
}: {
|
||||
runId: string;
|
||||
sessionId: string;
|
||||
tool: string;
|
||||
cwd: string;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const text = promptWithImages(prompt, images);
|
||||
|
||||
const send = () => {
|
||||
if (!text || uploading) return;
|
||||
if (!submitToRun(runId, text)) {
|
||||
toast.error(t("chat.notSent"));
|
||||
return;
|
||||
}
|
||||
setPrompt("");
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="pane-chat" className="flex min-h-0 flex-1 flex-col gap-2 pb-3">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} fill />
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={send}
|
||||
placeholder={t("chat.placeholder")}
|
||||
rows={2}
|
||||
slash={tool === "claude"}
|
||||
fileCwd={cwd}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("chat.hint")}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-send"
|
||||
onClick={send}
|
||||
disabled={uploading || !text}
|
||||
aria-label={t("chat.send")}
|
||||
title={t("chat.send")}
|
||||
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,10 @@
|
||||
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
|
||||
* READ the user's clipboard). While the socket is down a "Reconnecting…"
|
||||
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
|
||||
* most every 150 ms.
|
||||
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
||||
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
||||
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
|
||||
* here takes the lead back (`resize` with `drive: true`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
@@ -328,6 +331,25 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let ws: WebSocket | null = null;
|
||||
// Another browser drives this pane's size: the grid it set, or null.
|
||||
let follow: { cols: number; rows: number } | null = null;
|
||||
// A follower's grid may not fit this container: shrink it (never grow).
|
||||
const scaleToFit = () => {
|
||||
const el = term.element;
|
||||
const box = containerRef.current;
|
||||
if (!el || !box) return;
|
||||
el.style.transformOrigin = "0 0";
|
||||
el.style.transform = "";
|
||||
// `.xterm` is a block as wide as the box; the grid's size is the screen's.
|
||||
const grid = el.querySelector<HTMLElement>(".xterm-screen");
|
||||
if (!follow || !grid?.offsetWidth || !grid.offsetHeight) return;
|
||||
const s = Math.min(
|
||||
1,
|
||||
box.clientWidth / grid.offsetWidth,
|
||||
box.clientHeight / grid.offsetHeight
|
||||
);
|
||||
if (s < 1) el.style.transform = `scale(${s})`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tell the server this terminal's size. tmux draws the pane at whatever
|
||||
@@ -342,10 +364,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
* (`display:none` leaves the container 0×0), and fitting to that would
|
||||
* resize the real tmux pane down to nothing.
|
||||
*/
|
||||
const sendSizeNow = () => {
|
||||
const sendSizeNow = (drive = false) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (term.cols < 2 || term.rows < 2) return;
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
|
||||
};
|
||||
// This browser is the one in use now: back to its own size, and the
|
||||
// pane (and every other viewer) follows it.
|
||||
const takeLead = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||
follow = null;
|
||||
scaleToFit();
|
||||
fit.fit();
|
||||
sendSizeNow(true);
|
||||
};
|
||||
// A divider drag or a window resize fires a resize per frame; tmux and the
|
||||
// program in the pane redraw on each, so the LAST size is sent once things
|
||||
@@ -400,7 +432,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
if (data.startsWith('{"type"')) {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === "exit") {
|
||||
if (msg.type === "size") {
|
||||
follow = { cols: msg.cols, rows: msg.rows };
|
||||
term.resize(msg.cols, msg.rows);
|
||||
requestAnimationFrame(scaleToFit);
|
||||
} else if (msg.type === "lead") {
|
||||
takeLead();
|
||||
} else if (msg.type === "exit") {
|
||||
ended = true;
|
||||
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
||||
onExitRef.current?.();
|
||||
@@ -429,6 +467,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
connect();
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
// A keystroke takes the lead; a mouse report (wheel, hover) does not.
|
||||
if (follow && !/^\x1b\[(<|M)/.test(data)) takeLead();
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||
});
|
||||
|
||||
@@ -438,16 +478,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
const resizeDisposable = term.onResize(sendSize);
|
||||
|
||||
// Re-assert on focus. Several attach clients can share one tmux session
|
||||
// (a second tab, another lane pane), and tmux's `window-size latest` sizes
|
||||
// the window to whichever client was last active — resizing this pane out
|
||||
// from under a terminal that is never told. Coming back to it says so.
|
||||
const reassert = () => sendSizeNow();
|
||||
// (a second tab, another browser): coming back to this one makes it the
|
||||
// driver again, and the others follow its size.
|
||||
const reassert = takeLead;
|
||||
term.textarea?.addEventListener("focus", reassert);
|
||||
|
||||
const refit = () => {
|
||||
// Never fit a collapsed console — see sendSize.
|
||||
const el = containerRef.current;
|
||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||
if (follow) return scaleToFit(); // the driver's grid, not ours
|
||||
fit.fit(); // a real change fires onResize, which reports it
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(refit);
|
||||
@@ -502,9 +542,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
() =>
|
||||
registerPane(
|
||||
runId,
|
||||
(text) => {
|
||||
(text, submit) => {
|
||||
termRef.current?.paste(text);
|
||||
termRef.current?.focus();
|
||||
// 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.
|
||||
if (submit)
|
||||
setTimeout(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send("\r");
|
||||
}, 100);
|
||||
else termRef.current?.focus();
|
||||
},
|
||||
// On screen (a console hidden with CSS measures 0×0) AND attached:
|
||||
// `onData` drops input while the socket is connecting or reconnecting,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { LaneConsolePane } from "../LaneConsolePane";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
import { __resetChatUi } from "../../../lib/chatUi";
|
||||
|
||||
vi.mock("../TerminalView", () => ({
|
||||
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
||||
@@ -24,8 +25,15 @@ vi.mock("../TerminalView", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../PaneChat", () => ({
|
||||
PaneChat: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="pane-chat" data-session-id={sessionId} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
me: { prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }) },
|
||||
lanes: {
|
||||
ensure: vi.fn(),
|
||||
action: vi.fn(),
|
||||
@@ -139,6 +147,34 @@ describe("LaneConsolePane", () => {
|
||||
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a Claude pane as a chat OVER its terminal when the viewer's Chat UI is on", async () => {
|
||||
// Unmounting the terminal would drop the attach the chat sends through.
|
||||
vi.mocked(api.me.prefs).mockResolvedValueOnce({
|
||||
prefs: { "agenthub.chatUi": "1" },
|
||||
onboardedAt: null,
|
||||
});
|
||||
__resetChatUi();
|
||||
const live = {
|
||||
id: "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
status: "running" as const,
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: "s-1",
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
tool: "claude" as const,
|
||||
};
|
||||
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
|
||||
expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1");
|
||||
expect(screen.getByTestId("terminal-view")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pane-view-chat")).toBeNull(); // no per-pane switch
|
||||
__resetChatUi();
|
||||
});
|
||||
|
||||
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
|
||||
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
|
||||
const run = (id: string, laneId: number) => ({
|
||||
|
||||
@@ -69,6 +69,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
paste: pasteMock,
|
||||
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
|
||||
})),
|
||||
}));
|
||||
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
|
||||
@@ -368,7 +369,9 @@ describe("TerminalView", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws.onopen?.();
|
||||
expect(ws.sent).toContain(JSON.stringify({ type: "resize", cols: 131, rows: 28 }));
|
||||
expect(ws.sent).toContain(
|
||||
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a size change xterm made on its own, with no container resize", async () => {
|
||||
@@ -385,7 +388,34 @@ describe("TerminalView", () => {
|
||||
onResizeHandlers.forEach((fn) => fn());
|
||||
expect(ws.sent).toEqual([]);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(ws.sent).toEqual([JSON.stringify({ type: "resize", cols: 127, rows: 28 })]);
|
||||
expect(ws.sent).toEqual([
|
||||
JSON.stringify({ type: "resize", cols: 127, rows: 28, drive: false }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("follows another browser's size until a keystroke here takes the lead", () => {
|
||||
// Two browsers of different sizes on one pane: the one not in use renders
|
||||
// the driver's grid instead of resizing tmux under it.
|
||||
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800);
|
||||
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600);
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws.onopen?.();
|
||||
ws.onmessage?.({ data: '{"type":"size","cols":90,"rows":20}' });
|
||||
expect([termState.cols, termState.rows]).toEqual([90, 20]);
|
||||
ws.sent.length = 0;
|
||||
onDataHandlers.forEach((fn) => fn("\x1b[<64;5;5M")); // a wheel notch
|
||||
expect(ws.sent).toEqual(["\x1b[<64;5;5M"]);
|
||||
onDataHandlers.forEach((fn) => fn("a"));
|
||||
expect(ws.sent[1]).toBe(
|
||||
JSON.stringify({ type: "resize", cols: 90, rows: 20, drive: true })
|
||||
);
|
||||
expect(ws.sent[2]).toBe("a");
|
||||
} finally {
|
||||
w.mockRestore();
|
||||
h.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("never reports a collapsed console's zero size", () => {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
||||
* A right-click on an installed row opens its Update / Set default /
|
||||
* Enable-Disable (plus Copy path) as a menu; the buttons stay.
|
||||
* A click on an installed row (outside its buttons) opens that tool's settings
|
||||
* tab when the hub gives it one (`settingsHref`); the name is the keyboard link.
|
||||
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
|
||||
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
|
||||
* working.
|
||||
@@ -19,12 +21,14 @@
|
||||
import { Switch } from "../Switch";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
||||
|
||||
@@ -115,9 +119,17 @@ function JobLog({ job }: { job: AiToolJob }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AiToolsSettings() {
|
||||
export function AiToolsSettings({
|
||||
settingsHref,
|
||||
}: {
|
||||
/** A tool's own settings page, when it has one. */
|
||||
settingsHref?: (id: string) => string | undefined;
|
||||
} = {}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const navigate = useNavigate();
|
||||
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
|
||||
// Installing, updating and switching tools changes the host: an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [job, setJob] = useState<AiToolJob | null>(null);
|
||||
@@ -218,7 +230,7 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.installed", { count: installed.length })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{updateAllTools.length > 0 && (
|
||||
{isAdmin && updateAllTools.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm({ kind: "update-all" })}
|
||||
@@ -229,15 +241,17 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.updateAll", { count: updateAllTools.length })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void run("refresh", refresh)}
|
||||
disabled={busy !== null}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void run("refresh", refresh)}
|
||||
disabled={busy !== null}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
|
||||
@@ -248,12 +262,18 @@ export function AiToolsSettings() {
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
|
||||
{installed.map((tool) => {
|
||||
const isDefault = tool.id === defaultId;
|
||||
const href = settingsHref?.(tool.id);
|
||||
return (
|
||||
<li
|
||||
key={tool.id}
|
||||
data-testid={`ai-tool-${tool.id}`}
|
||||
onContextMenu={(e) => ctx.open(e, tool)}
|
||||
className="px-3 py-2.5"
|
||||
onClick={(e) => {
|
||||
// The row's own buttons, switch, links and log keep their click.
|
||||
if (href && !(e.target as Element).closest("button, a, input, pre"))
|
||||
navigate(href);
|
||||
}}
|
||||
className={`px-3 py-2.5 ${href ? "cursor-pointer hover:bg-surface-3" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
@@ -262,7 +282,17 @@ export function AiToolsSettings() {
|
||||
<AiToolIcon tool={tool} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
|
||||
<span className="truncate">{tool.label}</span>
|
||||
{href ? (
|
||||
<Link
|
||||
to={href}
|
||||
title={t("aiTools.openSettings", { tool: tool.label })}
|
||||
className="truncate hover:text-accent hover:underline"
|
||||
>
|
||||
{tool.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate">{tool.label}</span>
|
||||
)}
|
||||
<Homepage tool={tool} />
|
||||
{isDefault && (
|
||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
@@ -279,31 +309,39 @@ export function AiToolsSettings() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Fixed slots, so the buttons line up from row to row. */}
|
||||
<div className="flex min-w-20 shrink-0 justify-end">
|
||||
{tool.update && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "update", tool })}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-28 shrink-0 justify-end">
|
||||
{!isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tool.enabled || busy !== null}
|
||||
onClick={() => setDefault(tool)}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.setDefault")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ToolSwitch tool={tool} disabled={busy !== null} onToggle={() => toggle(tool)} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="flex min-w-20 shrink-0 justify-end">
|
||||
{tool.update && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "update", tool })}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-28 shrink-0 justify-end">
|
||||
{!isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tool.enabled || busy !== null}
|
||||
onClick={() => setDefault(tool)}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.setDefault")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ToolSwitch
|
||||
tool={tool}
|
||||
disabled={busy !== null}
|
||||
onToggle={() => toggle(tool)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
@@ -312,7 +350,7 @@ export function AiToolsSettings() {
|
||||
</ul>
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-tool-menu">
|
||||
{ctx.menu.item.update && (
|
||||
{isAdmin && ctx.menu.item.update && (
|
||||
<MenuItem
|
||||
disabled={jobBusy}
|
||||
onSelect={pick((tool) => setConfirm({ kind: "update", tool }))}
|
||||
@@ -320,7 +358,7 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.update")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{ctx.menu.item.id !== defaultId && (
|
||||
{isAdmin && ctx.menu.item.id !== defaultId && (
|
||||
<MenuItem
|
||||
disabled={!ctx.menu.item.enabled || busy !== null}
|
||||
onSelect={pick(setDefault)}
|
||||
@@ -328,12 +366,14 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.setDefault")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
|
||||
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
|
||||
</MenuItem>
|
||||
{isAdmin && (
|
||||
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
|
||||
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{ctx.menu.item.path && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{isAdmin && <MenuSeparator />}
|
||||
<MenuItem
|
||||
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
|
||||
>
|
||||
@@ -376,7 +416,7 @@ export function AiToolsSettings() {
|
||||
</span>
|
||||
</span>
|
||||
<Homepage tool={tool} />
|
||||
{tool.install && (
|
||||
{isAdmin && tool.install && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
|
||||
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* per-user Chat UI switch (`ChatUiSettings`, `#chat-ui`), the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
|
||||
@@ -17,6 +18,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||
import { PetSettings } from "./PetSettings";
|
||||
import { ChatUiSettings } from "./ChatUiSettings";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -146,6 +148,8 @@ export function AppearanceSettings() {
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<ChatUiSettings />
|
||||
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||
<SettingsRow
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
* tray Quit): `POST /api/settings/quit` closes every workspace terminal and
|
||||
* stops the server, which stays stopped until `agenthub start` — or the next
|
||||
* boot/login while the switch is on. Between them, the status-area icon switch
|
||||
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js).
|
||||
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last,
|
||||
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
|
||||
* the machine, database included (scripts/uninstall.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -74,6 +76,24 @@ export function AutostartSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const [uninstalling, setUninstalling] = useState(false);
|
||||
const uninstall = async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("uninstall.confirmTitle"),
|
||||
message: t("uninstall.confirmMessage"),
|
||||
confirmLabel: t("uninstall.button"),
|
||||
});
|
||||
if (!ok) return;
|
||||
setUninstalling(true);
|
||||
try {
|
||||
const { log } = await api.settings.uninstall();
|
||||
toast.success(t("uninstall.done", { log }));
|
||||
} catch (err) {
|
||||
setUninstalling(false);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const note = !status
|
||||
? undefined
|
||||
: !status.available
|
||||
@@ -113,6 +133,21 @@ export function AutostartSettings() {
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("uninstall.label")}
|
||||
description={t("uninstall.desc")}
|
||||
control={
|
||||
<button
|
||||
type="button"
|
||||
data-testid="uninstall-agenthub"
|
||||
disabled={uninstalling}
|
||||
onClick={() => void uninstall()}
|
||||
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
>
|
||||
{t("uninstall.button")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @file BackupSettings.tsx
|
||||
* @description Settings → Data → Backup: the daily database backup of
|
||||
* `server/lib/db-backup.js` — the folder (best on another disk), the server's
|
||||
* local hour, how many to keep, "Back up now" (polls `GET /api/settings/backup`
|
||||
* while it runs, then toasts the outcome) and the newest backup in the folder.
|
||||
* Saved through `PUT /api/settings/backup`, sending only the fields changed; a
|
||||
* field the environment set is read-only. Restoring is manual and the card says
|
||||
* how.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, DatabaseBackup } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { BackupState } from "../../lib/api";
|
||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { Select } from "../Select";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
export function BackupSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [state, setState] = useState<BackupState | null>(null);
|
||||
const [dir, setDir] = useState("");
|
||||
const [hour, setHour] = useState(2);
|
||||
const [keep, setKeep] = useState("7");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const apply = (s: BackupState) => {
|
||||
setState(s);
|
||||
setDir(s.dir);
|
||||
setHour(s.hour);
|
||||
setKeep(String(s.keep));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.backup
|
||||
.get()
|
||||
.then(apply)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
// A backup takes tens of seconds on a large database: poll until it ends.
|
||||
const running = !!state?.running;
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const id = window.setInterval(() => {
|
||||
api.settings.backup
|
||||
.get()
|
||||
.then((s) => {
|
||||
setState(s); // the form keeps its drafts
|
||||
if (s.running || !s.last) return;
|
||||
if (s.last.ok) toast.success(t("backup.done", { file: s.last.file }));
|
||||
else toast.error(t("backup.failed", { error: s.last.error }));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 2000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [running, t]);
|
||||
|
||||
const keepN = Number(keep);
|
||||
const keepValid = Number.isInteger(keepN) && keepN >= 1 && keepN <= 365;
|
||||
const patch: { dir?: string; hour?: number; keep?: number } = {};
|
||||
if (state) {
|
||||
if (dir.trim() !== state.dir) patch.dir = dir.trim();
|
||||
if (hour !== state.hour) patch.hour = hour;
|
||||
if (keepN !== state.keep) patch.keep = keepN;
|
||||
}
|
||||
const changed = Object.keys(patch).length > 0;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
apply(await api.settings.backup.save(patch));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runNow() {
|
||||
try {
|
||||
setState(await api.settings.backup.run());
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const owned = state?.env_owned;
|
||||
const newest = state?.files[0];
|
||||
const total = state?.files.reduce((n, f) => n + f.size, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="backup"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<DatabaseBackup className="w-4 h-4 text-fg-muted" />
|
||||
{t("backup.title")}
|
||||
</span>
|
||||
}
|
||||
description={t("backup.description")}
|
||||
>
|
||||
<div className="py-3 space-y-3">
|
||||
<label className="block text-xs text-fg-muted">
|
||||
{t("backup.folder")}
|
||||
<input
|
||||
className="input mt-1 w-full font-mono text-xs"
|
||||
placeholder="/mnt/hdd/agenthub-backups"
|
||||
value={dir}
|
||||
disabled={!state || owned?.dir}
|
||||
onChange={(e) => {
|
||||
setDir(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-fg-muted">{t("backup.folderHint")}</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="w-28">
|
||||
<Select
|
||||
value={String(hour)}
|
||||
onChange={(v) => setHour(Number(v))}
|
||||
label={t("backup.hour")}
|
||||
disabled={!state || owned?.hour}
|
||||
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-xs text-fg-muted">
|
||||
{t("backup.keep")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
step={1}
|
||||
className="input mt-1 block w-24 text-sm"
|
||||
value={keep}
|
||||
disabled={!state || owned?.keep}
|
||||
onChange={(e) => setKeep(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving || !changed || !keepValid}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!state?.dir || running || changed}
|
||||
onClick={() => void runNow()}
|
||||
>
|
||||
<DatabaseBackup className={`w-4 h-4 ${running ? "animate-pulse" : ""}`} />{" "}
|
||||
{running ? t("backup.running") : t("backup.runNow")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||
<p className="text-xs text-fg-secondary">
|
||||
{newest
|
||||
? t("backup.newest", {
|
||||
time: formatDateTime(newest.mtime),
|
||||
size: formatBytes(newest.size),
|
||||
n: state?.files.length,
|
||||
total: formatBytes(total),
|
||||
})
|
||||
: state?.dir
|
||||
? t("backup.none")
|
||||
: t("backup.off")}
|
||||
</p>
|
||||
{state?.last && !state.last.ok && (
|
||||
<p className="text-xs text-status-danger">
|
||||
{t("backup.lastFailed", {
|
||||
time: formatDateTime(state.last.at),
|
||||
error: state.last.error,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{state?.dir_error && <p className="text-xs text-status-danger">{state.dir_error}</p>}
|
||||
<p className="text-xs text-fg-muted">{t("backup.restoreHint")}</p>
|
||||
{(owned?.dir || owned?.hour || owned?.keep) && (
|
||||
<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>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or
|
||||
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of
|
||||
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each
|
||||
* account on a shared dashboard keeps its own choice on any browser.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { saveChatUi, useChatUi } from "../../lib/chatUi";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function ChatUiSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const chatUi = useChatUi();
|
||||
return (
|
||||
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("chatUi.label")}
|
||||
description={t("chatUi.labelDesc")}
|
||||
checked={chatUi}
|
||||
onChange={(on) =>
|
||||
saveChatUi(on).catch((err: unknown) =>
|
||||
toast.error(
|
||||
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" })
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePets, useWornPet } from "../Sagi/usePets";
|
||||
import type { PetView } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
const PREVIEW = 72;
|
||||
|
||||
@@ -121,6 +122,8 @@ export function PetSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const worn = useWornPet();
|
||||
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
|
||||
// Pets are stored for the whole install: adding or deleting one is an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const bundleInput = useRef<HTMLInputElement>(null);
|
||||
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
|
||||
@@ -219,6 +222,7 @@ export function PetSettings() {
|
||||
/>
|
||||
{list?.featured.map((f) => {
|
||||
const have = installed.get(f.id);
|
||||
if (!have && !isAdmin) return null;
|
||||
return (
|
||||
<PetCard
|
||||
key={f.id}
|
||||
@@ -252,50 +256,54 @@ export function PetSettings() {
|
||||
disabled={busy !== null}
|
||||
preview={<Preview pet={p} size={PREVIEW} />}
|
||||
onPick={() => wear(p)}
|
||||
onDelete={() => void remove(p)}
|
||||
onDelete={isAdmin ? () => void remove(p) : undefined}
|
||||
deleteLabel={t("sagi.pet.delete", { name: p.displayName })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy !== null}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy !== null}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
<a
|
||||
href="https://codex-pets.net/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t("sagi.pet.browse")}
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy !== null}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy !== null}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
<a
|
||||
href="https://codex-pets.net/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t("sagi.pet.browse")}
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* a homepage link, Refresh re-detects, every updater gets Update without a version
|
||||
* lookup, and Install/Update confirm the exact command, then show the polled
|
||||
* job log, folded once it is done. An installed row's right-click menu runs
|
||||
* the same Set default / enable switch as its controls.
|
||||
* the same Set default / enable switch as its controls. A click on a row (or
|
||||
* its name link) opens the tool's settings page; its buttons do not.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -13,6 +14,7 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { AiToolsSettings } from "../AiToolsSettings";
|
||||
import { api, type AiTool, type AiToolJob, type AiToolsView } from "../../../lib/api";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
@@ -92,9 +94,48 @@ const job = (over: Partial<AiToolJob> = {}): AiToolJob => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
function Where() {
|
||||
const loc = useLocation();
|
||||
return <span data-testid="where">{loc.pathname}</span>;
|
||||
}
|
||||
|
||||
describe("AiToolsSettings", () => {
|
||||
it("opens a tool's settings from its row or name, never from its buttons", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings/agents"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<>
|
||||
<AiToolsSettings
|
||||
settingsHref={(id) => (id === "gemini" ? `/settings/agents/${id}` : undefined)}
|
||||
/>
|
||||
<Where />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||
await user.click(within(gemini).getByText("Set default"));
|
||||
expect(screen.getByTestId("where").textContent).toBe("/settings/agents");
|
||||
await user.click(gemini);
|
||||
expect(screen.getByTestId("where").textContent).toBe("/settings/agents/gemini");
|
||||
expect(within(gemini).getByRole("link", { name: "Gemini" })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/agents/gemini"
|
||||
);
|
||||
// A tool without a page has no link.
|
||||
expect(
|
||||
within(screen.getByTestId("ai-tool-claude")).queryByRole("link", { name: "Claude Code" })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("splits installed tools from the ones available to install", async () => {
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" }));
|
||||
const claude = screen.getByTestId("ai-tool-claude");
|
||||
@@ -107,7 +148,7 @@ describe("AiToolsSettings", () => {
|
||||
|
||||
it("toggles a tool and sets the default through the API", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||
await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" }));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
||||
@@ -121,7 +162,7 @@ describe("AiToolsSettings", () => {
|
||||
|
||||
it("refreshes detection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
await screen.findByTestId("ai-tool-claude");
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
|
||||
@@ -129,7 +170,7 @@ describe("AiToolsSettings", () => {
|
||||
|
||||
it("sets the default and disables a tool from its right-click menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||
await user.pointer({ keys: "[MouseRight]", target: gemini });
|
||||
await user.click(screen.getByRole("menuitem", { name: "Set default" }));
|
||||
@@ -182,7 +223,7 @@ describe("AiToolsSettings — install and update", () => {
|
||||
vi.mocked(api.aiTools.job)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
await user.click(await screen.findByRole("button", { name: /Available to install/ }));
|
||||
const qwen = await screen.findByTestId("ai-tool-qwen");
|
||||
await user.click(within(qwen).getByRole("button", { name: "Install" }));
|
||||
@@ -212,7 +253,7 @@ describe("AiToolsSettings — install and update", () => {
|
||||
|
||||
it("names the vendor script for a script install", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
await openAvailable();
|
||||
const cursor = await screen.findByTestId("ai-tool-cursor");
|
||||
await user.click(within(cursor).getByRole("button", { name: "Install" }));
|
||||
@@ -222,7 +263,7 @@ describe("AiToolsSettings — install and update", () => {
|
||||
});
|
||||
|
||||
it("offers Update on every installed tool with an updater", async () => {
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
for (const id of ["claude", "codex", "gemini"]) {
|
||||
const row = await screen.findByTestId(`ai-tool-${id}`);
|
||||
expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument();
|
||||
@@ -231,7 +272,7 @@ describe("AiToolsSettings — install and update", () => {
|
||||
|
||||
it("Update all lists every installed tool's updater", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
|
||||
const cmd = screen.getByTestId("ai-tool-confirm-command");
|
||||
expect(cmd).toHaveTextContent("claude update");
|
||||
@@ -241,13 +282,13 @@ describe("AiToolsSettings — install and update", () => {
|
||||
|
||||
it("hides Update all when no tool has an updater", async () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
await screen.findByTestId("ai-tool-claude");
|
||||
expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the letter when the icon fails", async () => {
|
||||
const { container } = render(<AiToolsSettings />);
|
||||
const { container } = render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
await openAvailable();
|
||||
await screen.findByTestId("ai-tool-qwen");
|
||||
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file Tests Settings → About's Quit AgentHub button (issue #65): it asks
|
||||
* first, says when start-at-login will bring the dashboard back, and only a
|
||||
* confirmed ask reaches POST /api/settings/quit. Plus the status-area icon
|
||||
* switch, which goes straight to PUT /api/settings/tray.
|
||||
* switch, which goes straight to PUT /api/settings/tray, and Uninstall
|
||||
* AgentHub, which also asks first.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -17,6 +18,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
autostart: { get: vi.fn(), set: vi.fn() },
|
||||
tray: { get: vi.fn(), set: vi.fn() },
|
||||
quit: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -65,3 +67,30 @@ describe("Quit AgentHub", () => {
|
||||
await waitFor(() => expect(icon).toBeChecked());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Uninstall AgentHub", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.settings.autostart.get).mockResolvedValue(status as never);
|
||||
vi.mocked(api.settings.tray.get).mockResolvedValue(trayOff);
|
||||
vi.mocked(api.settings.uninstall)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, log: "/tmp/agenthub-uninstall-1.log" });
|
||||
});
|
||||
|
||||
it("does nothing when the ask is cancelled", async () => {
|
||||
vi.spyOn(confirm, "confirmDialog").mockResolvedValue(false);
|
||||
render(<AutostartSettings />);
|
||||
fireEvent.click(screen.getByTestId("uninstall-agenthub"));
|
||||
await waitFor(() => expect(confirm.confirmDialog).toHaveBeenCalled());
|
||||
expect(api.settings.uninstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uninstalls once confirmed, saying the database goes too", async () => {
|
||||
const ask = vi.spyOn(confirm, "confirmDialog").mockResolvedValue(true);
|
||||
render(<AutostartSettings />);
|
||||
fireEvent.click(screen.getByTestId("uninstall-agenthub"));
|
||||
await waitFor(() => expect(api.settings.uninstall).toHaveBeenCalledTimes(1));
|
||||
expect(ask.mock.calls[0]![0].message).toMatch(/database/);
|
||||
expect(screen.getByTestId("uninstall-agenthub")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @file BackupSettings.test.tsx
|
||||
* @description Settings → Data → Backup: a save sends only the fields that
|
||||
* changed, "Back up now" is off until a folder is saved, and the newest backup
|
||||
* in the folder is shown.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
|
||||
const { backup } = vi.hoisted(() => ({
|
||||
backup: { get: vi.fn(), save: vi.fn(), run: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({ api: { settings: { backup } } }));
|
||||
|
||||
import { BackupSettings } from "../BackupSettings";
|
||||
|
||||
const off = {
|
||||
dir: "",
|
||||
hour: 2,
|
||||
keep: 7,
|
||||
running: false,
|
||||
last: null,
|
||||
files: [],
|
||||
dir_error: null,
|
||||
env_owned: { dir: false, hour: false, keep: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("BackupSettings", () => {
|
||||
it("saves only the folder when only the folder changed, then allows a run", async () => {
|
||||
const user = userEvent.setup();
|
||||
backup.get.mockResolvedValue(off);
|
||||
backup.save.mockImplementation((p) => Promise.resolve({ ok: true, ...off, ...p }));
|
||||
render(<BackupSettings />);
|
||||
const run = await screen.findByRole("button", { name: /Back up now/ });
|
||||
expect(run).toBeDisabled();
|
||||
expect(screen.getByText("Backups are off.")).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByLabelText("Backup folder"), "/mnt/hdd/b");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(backup.save).toHaveBeenCalledWith({ dir: "/mnt/hdd/b" }));
|
||||
await waitFor(() => expect(run).toBeEnabled());
|
||||
});
|
||||
|
||||
it("shows the newest backup", async () => {
|
||||
backup.get.mockResolvedValue({
|
||||
...off,
|
||||
dir: "/mnt/hdd/b",
|
||||
files: [
|
||||
{ name: "agenthub-20261008-020000.db", size: 2048, mtime: "2026-10-08T02:00:30Z" },
|
||||
{ name: "agenthub-20261007-020000.db", size: 1024, mtime: "2026-10-07T02:00:30Z" },
|
||||
],
|
||||
});
|
||||
render(<BackupSettings />);
|
||||
expect(await screen.findByText(/2\.0 KB · 2 kept, 3\.0 KB in all/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,14 +6,17 @@
|
||||
* refetches), and a WebSocket reconnect refetches the whole list because the
|
||||
* frames missed while disconnected are never replayed. The copy is dropped
|
||||
* when the last component unmounts, so a fresh mount always reads fresh rows.
|
||||
* `useWorkLanes()` is the same list narrowed to the workspaces this user may
|
||||
* work in (server mode: an analyst's own), for the Workspace page and tree.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import type { Lane, WSMessage } from "../lib/types";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
|
||||
let lanes: Lane[] | null = null;
|
||||
// Bumped when the store is torn down, so a reply to a request made before the
|
||||
@@ -92,3 +95,21 @@ export function useLanes() {
|
||||
}, []);
|
||||
return { lanes: v ?? EMPTY, loaded: v !== null, refresh: load };
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspaces this user WORKS in — the Workspace page and its sidebar tree.
|
||||
* Server mode: an analyst gets every workspace from the API (to read) but may
|
||||
* change only their own, so only those are listed here; other users' work is
|
||||
* read on the Sessions, Analytics and Users pages.
|
||||
*/
|
||||
export function useWorkLanes() {
|
||||
const all = useLanes();
|
||||
const { state } = useAuth();
|
||||
const analystId =
|
||||
state.mode === "server" && state.user?.role === "analyst" ? state.user.id : null;
|
||||
const lanes = useMemo(
|
||||
() => (analystId == null ? all.lanes : all.lanes.filter((l) => l.owner_id === analystId)),
|
||||
[all.lanes, analystId]
|
||||
);
|
||||
return { ...all, lanes };
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ describe("i18n resources", () => {
|
||||
await i18n.changeLanguage("vi");
|
||||
|
||||
expect(i18n.t("nav:dashboard")).toBe("Tổng quan");
|
||||
expect(i18n.t("nav:agentBoard")).toBe("Bảng Kanban");
|
||||
expect(i18n.t("nav:languageShort.vi")).toBe("VI");
|
||||
expect(i18n.t("nav:settings")).toBe("Cài đặt");
|
||||
});
|
||||
|
||||
it("should keep Agent terminology untranslated in the vi locale", async () => {
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
"export": "Export",
|
||||
"totalSessions": "Total Sessions",
|
||||
"totalAgents": "Total Agents",
|
||||
"totalTokens": "Total Tokens",
|
||||
"totalCost": "Total Cost",
|
||||
"totalEvents": "Total Events",
|
||||
"cacheHitRate": "% cache hit rate",
|
||||
"perSession": " per session",
|
||||
"noDataCost": "No data",
|
||||
"eventCountLabel_one": "{{count}} event",
|
||||
"eventCountLabel_other": "{{count}} events",
|
||||
"tabs": {
|
||||
|
||||
@@ -30,14 +30,12 @@
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Roots",
|
||||
"claudeHome": "Claude home",
|
||||
"projectClaudeDir": "Project .claude",
|
||||
"projectRoot": "Project root",
|
||||
"claudeJson": "claude.json",
|
||||
"summary": "Quick summary",
|
||||
"user": "user",
|
||||
"project": "project",
|
||||
"items": "items"
|
||||
"project": "project"
|
||||
},
|
||||
"common": {
|
||||
"search": "Search by name…",
|
||||
@@ -47,32 +45,19 @@
|
||||
"close": "Close",
|
||||
"copyPath": "Copy path",
|
||||
"copyName": "Copy name",
|
||||
"copied": "Copied",
|
||||
"truncated": "(truncated for preview)",
|
||||
"size": "Size",
|
||||
"modified": "Modified",
|
||||
"scopeLabel": "Scope",
|
||||
"noFrontmatter": "No frontmatter",
|
||||
"redactedNotice": "Values matching /token|secret|password|key|auth/i are redacted server-side.",
|
||||
"preview": "Preview",
|
||||
"raw": "Raw"
|
||||
},
|
||||
"skills": {
|
||||
"name": "Name",
|
||||
"description": "Description"
|
||||
},
|
||||
"agents": {
|
||||
"name": "Name",
|
||||
"model": "Model",
|
||||
"tools": "Tools"
|
||||
},
|
||||
"plugins": {
|
||||
"manifest": "Manifest",
|
||||
"marketplace": "Marketplace",
|
||||
"version": "Version",
|
||||
"installedAt": "Installed",
|
||||
"lastUpdated": "Updated",
|
||||
"installPath": "Install path",
|
||||
"missing": "Install path missing",
|
||||
"manifestMissing": "No installed_plugins.json found at {{path}}",
|
||||
"enabled": "Enabled",
|
||||
@@ -90,8 +75,7 @@
|
||||
"hooks_other": "{{count}} hook scripts",
|
||||
"author": "Author",
|
||||
"homepage": "Homepage",
|
||||
"license": "License",
|
||||
"description": "Description"
|
||||
"license": "License"
|
||||
},
|
||||
"marketplaces": {
|
||||
"manifest": "Known marketplaces manifest",
|
||||
@@ -100,11 +84,9 @@
|
||||
"lastUpdated": "Last synced",
|
||||
"pluginCount": "Plugins",
|
||||
"owner": "Owner",
|
||||
"installLocation": "Install path",
|
||||
"addCmd": "claude plugin marketplace add <repo>"
|
||||
},
|
||||
"keybindings": {
|
||||
"file": "Source file",
|
||||
"missing": "No keybindings.json found at {{path}}",
|
||||
"context": "Context",
|
||||
"key": "Key",
|
||||
@@ -128,8 +110,7 @@
|
||||
},
|
||||
"hookScripts": {
|
||||
"title": "~/.claude/hooks/ scripts",
|
||||
"subtitle": "Helper scripts referenced by your hook commands. These run when settings.json `hooks.<event>.hooks[].command` invokes them.",
|
||||
"empty": "No scripts in ~/.claude/hooks/."
|
||||
"subtitle": "Helper scripts referenced by your hook commands. These run when settings.json `hooks.<event>.hooks[].command` invokes them."
|
||||
},
|
||||
"mcp": {
|
||||
"userScope": "User-scoped servers",
|
||||
@@ -143,8 +124,6 @@
|
||||
"env": "Env names",
|
||||
"url": "URL",
|
||||
"headers": "Header names",
|
||||
"kind": "Kind",
|
||||
"source": "Source",
|
||||
"noServers": "No MCP servers found.",
|
||||
"notRemovable": "Configured in settings.json — remove it there; no CLI scope addresses this entry."
|
||||
},
|
||||
@@ -162,8 +141,6 @@
|
||||
"rawJson": "Raw JSON"
|
||||
},
|
||||
"memory": {
|
||||
"userScope": "User memory",
|
||||
"projectScope": "Project memory",
|
||||
"missing": "No CLAUDE.md found.",
|
||||
"autoTitle": "Project memory store",
|
||||
"autoSubtitle": "Per-project file-based memory under ~/.claude/projects/<project>/memory/ — a MEMORY.md index plus one file per remembered fact. Read-only here.",
|
||||
@@ -241,15 +218,9 @@
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"empty": "No backups yet - every edit or delete creates a timestamped backup that lands here.",
|
||||
"viewLink": "Show backup history",
|
||||
"openButton": "Backups",
|
||||
"subtitle": "Every save / delete in the editor writes a copy here first. Backup folders live outside the directories Claude Code scans, so they don't reappear as ghosts in your CLI.",
|
||||
"restoreHint": "Restore by copying the backup back into place (the backup is kept):",
|
||||
"size": "Size",
|
||||
"created": "Created",
|
||||
"type": "Type",
|
||||
"scopeLabel": "Scope",
|
||||
"filterAll": "All types"
|
||||
"restoreHint": "Restore by copying the backup back into place (the backup is kept):"
|
||||
},
|
||||
"profiles": {
|
||||
"usageCheck": "Check quota",
|
||||
@@ -314,9 +285,7 @@
|
||||
"title": "Plugins run through the `claude` CLI",
|
||||
"body": "Plugins are bundles with their own registry — `installed_plugins.json` plus install paths under `plugins/cache/`. The buttons here run the matching `claude plugin …` command on this machine, so the registry stays consistent. Installing a plugin means running third-party code: only add marketplaces you trust.",
|
||||
"install": "Install a plugin:",
|
||||
"installCmd": "claude plugin install <marketplace>/<plugin>",
|
||||
"uninstall": "Uninstall this plugin:",
|
||||
"uninstallButton": "Copy uninstall command"
|
||||
"installCmd": "claude plugin install <marketplace>/<plugin>"
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP servers are managed by the CLI",
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Roots",
|
||||
"codexHome": "Codex home",
|
||||
"projectCodexDir": "Project .codex",
|
||||
"projectRoot": "Project root",
|
||||
"summary": "Quick summary",
|
||||
@@ -72,9 +71,6 @@
|
||||
"saving": "Creating…",
|
||||
"close": "Close"
|
||||
},
|
||||
"config": {
|
||||
"missing": "No config.toml found."
|
||||
},
|
||||
"mcp": {
|
||||
"addTitle": "Add an MCP server",
|
||||
"nameLabel": "Name",
|
||||
|
||||
@@ -21,14 +21,11 @@
|
||||
"live": "Live",
|
||||
"offline": "Offline",
|
||||
"running": "running",
|
||||
"dashboardRun": "Run",
|
||||
"days": "days",
|
||||
"hours": "hours",
|
||||
"updated": "Updated ",
|
||||
"lastUpdated": "Last updated: ",
|
||||
"showMore": "Show more ({{count}} remaining)",
|
||||
"plusMore": "+{{count}} more",
|
||||
"ofSessions": "% of sessions",
|
||||
"save": "Save",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -89,14 +86,8 @@
|
||||
"cost": {
|
||||
"model": "Model",
|
||||
"cost": "Cost",
|
||||
"totalCost": "Total Cost",
|
||||
"totalEstimatedCost": "Total Estimated Cost"
|
||||
},
|
||||
"duration": {
|
||||
"h_m": "{{h}}h {{m}}m",
|
||||
"m_s": "{{m}}m {{s}}s",
|
||||
"s": "{{s}}s"
|
||||
},
|
||||
"eventDetail": {
|
||||
"summary": "Summary",
|
||||
"seeDetailsBelow": "Check below for full details in {{fields}}",
|
||||
@@ -137,8 +128,6 @@
|
||||
"rawPayload": "Raw Payload",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"noPayload": "No payload captured for this event.",
|
||||
"viewDetails": "View details",
|
||||
"expand": "Expand event",
|
||||
"collapse": "Collapse event"
|
||||
},
|
||||
@@ -157,12 +146,7 @@
|
||||
"loadMore": "Load more",
|
||||
"loading": "Loading...",
|
||||
"noResults": "No events match the current filters",
|
||||
"grouped": "Group by tool call",
|
||||
"flat": "Flat list",
|
||||
"groupEventCount_one": "{{count}} event",
|
||||
"groupEventCount_other": "{{count}} events",
|
||||
"help": {
|
||||
"toggle": "Help",
|
||||
"title": "About events, filters, and grouping",
|
||||
"subtitle": "Unlock deeper insights: badges, lifecycles, and filter mechanics explained.",
|
||||
"valuesTitle": "What each filter accepts",
|
||||
@@ -176,10 +160,8 @@
|
||||
"statusesTitle": "Status badges",
|
||||
"statusesIntro": "Status is derived from event_type. It's what you see on each row.",
|
||||
"statusWorkingDesc": "Agent started a tool call (PreToolUse). The tool is about to run.",
|
||||
"statusConnectedDesc": "Tool call completed and returned a result (PostToolUse).",
|
||||
"statusCompletedDesc": "Turn or lifecycle boundary (Stop, SubagentStop, Compaction).",
|
||||
"statusErrorDesc": "An error was recorded (error, APIError).",
|
||||
"statusIdleDesc": "Lifecycle or metadata (Notification, TurnDuration, SessionStart, SessionEnd).",
|
||||
"lifecycleTitle": "Event lifecycle",
|
||||
"lifecycleDesc": "A typical tool call emits two events sharing the same tool_use_id:",
|
||||
"lifecycleFlow": "PreToolUse (Working) → PostToolUse (Connected) → ... → Stop (Completed)",
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"eventsToday": "Events Today",
|
||||
"totalEvents": "Total Events",
|
||||
"totalCost": "Total Cost",
|
||||
"viewBoard": "View Board",
|
||||
"viewAll": "View All",
|
||||
"activeAgentsSection": "Active Agents",
|
||||
"recentActivity": "Recent Activity",
|
||||
@@ -15,7 +14,6 @@
|
||||
"noAgentsDesc": "Agents will appear here when a Claude Code session is running.",
|
||||
"noActivity": "No activity yet",
|
||||
"noActivityDesc": "Events from Claude Code sessions will stream here in real-time.",
|
||||
"failedConnect": "Failed to connect to server",
|
||||
"failedLoad": "Failed to load data",
|
||||
"activeTrend": " active",
|
||||
"totalTrend": " in active sessions",
|
||||
|
||||
@@ -19,15 +19,6 @@
|
||||
"sessionError": "Session Error",
|
||||
"subagentSpawned": "Subagent Spawned"
|
||||
},
|
||||
"toolLegend": {
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"edit": "Edit",
|
||||
"bash": "Bash",
|
||||
"grep": "Grep",
|
||||
"glob": "Glob",
|
||||
"agent": "Agent",
|
||||
"other": "Other"
|
||||
},
|
||||
"badResponse": "The server sent an unexpected response instead of data. The dashboard may be restarting, or a proxy is answering in its place — reload in a moment."
|
||||
"badResponse": "The server sent an unexpected response instead of data. The dashboard may be restarting, or a proxy is answering in its place — reload in a moment.",
|
||||
"adminOnly": "Only an admin can do this. Ask an admin of this AgentHub if you need it."
|
||||
}
|
||||
|
||||
@@ -1,45 +1,18 @@
|
||||
{
|
||||
"title": "Kanban Board",
|
||||
"subtitle": "Kanban view of all agents by status",
|
||||
"subtitleSessions": "Kanban view of all sessions by status",
|
||||
"viewToggle": {
|
||||
"agents": "Agents",
|
||||
"sessions": "Sessions"
|
||||
},
|
||||
"noAgents": "No agents tracked yet",
|
||||
"noAgentsDesc": "Start a Claude Code session with hooks installed to see agents appear here.",
|
||||
"agentCount": "{{count}} agent tracked",
|
||||
"agentCount_plural": "{{count}} agents tracked",
|
||||
"noAgentsInColumn": "No agents",
|
||||
"noSessions": "No sessions tracked yet",
|
||||
"noSessionsDesc": "Start a Claude Code session with hooks installed to see sessions appear here.",
|
||||
"sessionCount": "{{count}} session tracked",
|
||||
"sessionCount_plural": "{{count}} sessions tracked",
|
||||
"noSessionsInColumn": "No sessions",
|
||||
"ran": "ran ",
|
||||
"running": "running ",
|
||||
"session": {
|
||||
"anonymous": "Untitled session",
|
||||
"agentSummary_one": "{{count}} agent",
|
||||
"agentSummary_other": "{{count}} agents",
|
||||
"subagentSummary_one": "{{count}} subagent",
|
||||
"subagentSummary_other": "{{count}} subagents",
|
||||
"turnSummary_one": "{{count}} turn",
|
||||
"turnSummary_other": "{{count}} turns"
|
||||
},
|
||||
"tooltip": {
|
||||
"agent": {
|
||||
"working": "Agent is actively running a tool call right now.\n\nEntered on PreToolUse or UserPromptSubmit, exits on Stop (turn finished).",
|
||||
"waiting": "Agent finished its turn and is blocked on the human - either Claude is waiting for the next user message or has prompted for permission to use a tool.\n\nCleared on the next PreToolUse / PostToolUse, or when the session ends.",
|
||||
"completed": "Agent has fully ended for this session - typically because SessionEnd fired or, for subagents, SubagentStop was processed.\n\nNo further activity will be recorded against this agent.",
|
||||
"error": "Agent recorded an error and will not continue. For main agents this comes from a Stop with stop_reason=error; for subagents, from a failed run."
|
||||
},
|
||||
"session": {
|
||||
"active": "Session is mid-turn - Claude is currently running and the user isn't blocking anything. Brief by nature: a session moves through Active during tool calls and back to Waiting at end of turn.",
|
||||
"waiting": "Session is sitting on the user. Either Claude finished its turn (most common) or it has surfaced a permission prompt and is blocked until the human responds.\n\nLook here for sessions you owe a reply to.",
|
||||
"completed": "Session has fully closed - SessionEnd fired (CLI exited cleanly) or the user explicitly closed it.\n\nAll agents on this session are also completed.",
|
||||
"error": "Session ended with an error - Claude returned stop_reason=error, an unrecoverable failure occurred, or an upstream API error terminated the run.",
|
||||
"abandoned": "Session went stale without a clean SessionEnd. Marked abandoned after 5+ minutes of no activity (orphaned by /resume, network drop, or the CLI being killed). Distinct from Completed: we never saw a real exit signal."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
{
|
||||
"action.clear": "clear state",
|
||||
"action.forget": "Delete workspace",
|
||||
"action.purge": "delete history",
|
||||
"action.reset": "reset worktree",
|
||||
"action.start": "start",
|
||||
"action.stop": "stop",
|
||||
"actionError": "Workspace action failed: {{message}}",
|
||||
"actionErrorUnknown": "Unknown error",
|
||||
"add": "Add workspace",
|
||||
@@ -110,14 +105,6 @@
|
||||
"changes.unstage": "Unstage this file",
|
||||
"changes.changesView": "Changes",
|
||||
"changes.wholeFile": "Whole file",
|
||||
"confirmRemoveCancel": "Cancel",
|
||||
"confirmRemoveConfirm": "Remove",
|
||||
"confirmRemoveMessage": "This action cannot be undone.",
|
||||
"confirmRemoveTitle": "Remove workspace?",
|
||||
"countDead": "dead",
|
||||
"countNeedsYou": "need you",
|
||||
"countRunning": "running",
|
||||
"countTotal": "workspaces",
|
||||
"destructive.blocked.adopted": "This is an adopted directory and cannot be changed by a worktree action.",
|
||||
"destructive.blocked.missing": "The workspace directory is missing, so this action cannot continue.",
|
||||
"destructive.blocked.unreadable": "The workspace directory cannot be read as a Git worktree, so this action cannot continue.",
|
||||
@@ -189,10 +176,6 @@
|
||||
"gitBar.switchHint": "Switch this worktree to another local branch",
|
||||
"gitBar.switchLabel": "Switch branch…",
|
||||
"gitBar.unbornHint": "Commit first, then publish this repo to a remote.",
|
||||
"kind.adopted": "adopted",
|
||||
"kind.managed": "managed",
|
||||
"laneDetail.hide": "Hide details",
|
||||
"laneDetail.show": "Workspace details",
|
||||
"locks.held_one": "{{count}} lock held",
|
||||
"locks.held_other": "{{count}} locks held",
|
||||
"mode.repo": "Repo",
|
||||
@@ -206,25 +189,18 @@
|
||||
"preflightErrorWithMessage": "Could not load the current workspace facts: {{message}}",
|
||||
"proof.ticketReport": "Task report",
|
||||
"renameLabel": "Rename this workspace",
|
||||
"reorderHint": "Drag to reorder · Alt+←/→",
|
||||
"proxy.native": "Native ({{tool}})",
|
||||
"proxy.label": "AI Proxy",
|
||||
"runtime.boot": "▶ up",
|
||||
"runtime.busy.down": "stopping…",
|
||||
"runtime.busy.up": "booting…",
|
||||
"runtime.down": "down",
|
||||
"runtime.healthy": "healthy",
|
||||
"runtime.partial": "partial",
|
||||
"runtime.slot": "slot {{slot}}",
|
||||
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
|
||||
"runtime.stop": "■ down",
|
||||
"runtime.toggleTitle": "Boot or stop this workspace's own application stack (separate from its Claude run)",
|
||||
"status.failed": "failed",
|
||||
"status.idle": "idle",
|
||||
"status.provisioning": "provisioning",
|
||||
"status.running": "running",
|
||||
"title": "Workspaces",
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktrees",
|
||||
"changes.undoHunk": "Undo this hunk",
|
||||
@@ -386,6 +362,7 @@
|
||||
"home.memoryEmpty": "Memory shows here once the folder has a CLAUDE.md or AGENTS.md, or Claude saves memory for it.",
|
||||
"home.context": "Context",
|
||||
"home.contextEmpty": "Add PDFs, documents, or other text to reference in this workspace.",
|
||||
"home.contextSnapshot": "Each file is copied when you add it — later edits to the original are not seen. Remove and add it again, or add its folder under Folder to always read the latest.",
|
||||
"home.addFile": "Add files",
|
||||
"home.folder": "Folder",
|
||||
"home.ownFolder": "Workspace folder",
|
||||
@@ -452,6 +429,7 @@
|
||||
"tree.showMore_one": "Show {{count}} more",
|
||||
"tree.showMore_other": "Show {{count}} more",
|
||||
"tree.seeAll": "See all ({{count}} more)",
|
||||
"tree.showLess": "Show less",
|
||||
"home.contextPanel": "Workspace context",
|
||||
"row.state.needsYou": "needs you",
|
||||
"row.state.error": "error",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"activityFeed": "Activity Feed",
|
||||
"agentBoard": "Kanban Board",
|
||||
"agents": "Agents",
|
||||
"analytics": "Analytics",
|
||||
"avg": "avg",
|
||||
@@ -19,20 +18,11 @@
|
||||
"daysAgo": "{{count}}d ago",
|
||||
"disconnected": "Disconnected",
|
||||
"eventsLastMin": "Last 60s",
|
||||
"eventsReceived": "Events received",
|
||||
"eventsTotal": "Total",
|
||||
"expand": "Expand sidebar",
|
||||
"hoursAgo": "{{count}}h ago",
|
||||
"justNow": "just now",
|
||||
"language": "Language",
|
||||
"languageNames": {
|
||||
"en": "English",
|
||||
"vi": "Vietnamese"
|
||||
},
|
||||
"languageShort": {
|
||||
"en": "EN",
|
||||
"vi": "VI"
|
||||
},
|
||||
"lastEvent": "Last event",
|
||||
"live": "Live",
|
||||
"minutesAgo": "{{count}}m ago",
|
||||
@@ -55,7 +45,6 @@
|
||||
"unitPerSec": "events/s",
|
||||
"upToDate": "Up to date",
|
||||
"updateAvailable": "Update available",
|
||||
"website": "Website",
|
||||
"workspace": "Workspace",
|
||||
"wsEndpoint": "WebSocket",
|
||||
"releaseAvailable": "{{version}} available",
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
{
|
||||
"emptyPane": "No session in this pane. Start one from the workspace's prompt box.",
|
||||
"actions": {
|
||||
"newSession": "New session",
|
||||
"send": "Send",
|
||||
"sending": "Sending…",
|
||||
"start": "Run",
|
||||
"starting": "Starting…",
|
||||
"stop": "Stop",
|
||||
"stopping": "Stopping…"
|
||||
"newSession": "New session"
|
||||
},
|
||||
"autocomplete": {
|
||||
"fileHint": "Type @ to reference a file",
|
||||
"groupBuiltin": "Built-in (CLI only - won't run from here)",
|
||||
"groupFiles": "Files in cwd",
|
||||
"groupProject": "Project commands",
|
||||
"groupUser": "User commands",
|
||||
"noMatches": "No matches",
|
||||
"slashHint": "Type / for slash commands"
|
||||
},
|
||||
"binary": {
|
||||
"found": "Using {{path}}",
|
||||
"missing": "The `{{cli}}` CLI isn't on your PATH. Install {{tool}} or set PATH so the dashboard can spawn it."
|
||||
},
|
||||
"concurrency": {
|
||||
"active_one": "{{count}} run already in flight",
|
||||
"active_other": "{{count}} runs already in flight",
|
||||
"atCap": "Concurrency cap reached ({{max}}). Stop one of the running runs to start a new one.",
|
||||
"viewActive": "View active runs"
|
||||
},
|
||||
"empty": {
|
||||
"body": "Type a prompt and click Run. Output streams here as the model thinks, calls tools, and answers.",
|
||||
"title": "Nothing yet"
|
||||
@@ -40,28 +23,16 @@
|
||||
"runStartedButNotAttached": "Run is live but couldn't attach to console: {{message}}",
|
||||
"sendFailed": "Couldn't send: {{message}}"
|
||||
},
|
||||
"events": {
|
||||
"claude": "Claude",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"system": "System",
|
||||
"thinking": "Thinking",
|
||||
"tool": "Tool",
|
||||
"toolResult": "Tool result",
|
||||
"you": "You"
|
||||
},
|
||||
"fields": {
|
||||
"approval": "Approval",
|
||||
"approvalNever": "Never",
|
||||
"approvalOnRequest": "On request",
|
||||
"cwd": "Working directory",
|
||||
"cwdGroups": {
|
||||
"dashboard": "Dashboard server",
|
||||
"home": "Home",
|
||||
"repo": "Source repos",
|
||||
"recent": "Recent - used by Claude Code"
|
||||
},
|
||||
"cwdHint": "Absolute path. Defaults to the dashboard's own cwd.",
|
||||
"cwdNoMatches": "No matches. Type an absolute path to use it directly.",
|
||||
"cwdPlaceholder": "Type to search or paste an absolute path…",
|
||||
"effort": "Thinking effort",
|
||||
@@ -82,64 +53,15 @@
|
||||
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
|
||||
"permissionMode": "Permission mode",
|
||||
"permissionPlan": "plan (read-only planning)",
|
||||
"prompt": "Prompt",
|
||||
"promptPlaceholder": "Ask Claude anything…",
|
||||
"promptPlaceholderTerminal": "Ask {{tool}} anything…",
|
||||
"sandbox": "Sandbox",
|
||||
"sandboxFullAccess": "Full access (dangerous)",
|
||||
"sandboxReadOnly": "Read only",
|
||||
"sandboxWorkspaceWrite": "Workspace write"
|
||||
},
|
||||
"footer": {
|
||||
"cost": "Cost",
|
||||
"duration": "Duration",
|
||||
"exitCode": "Exit code",
|
||||
"sessionId": "Session ID",
|
||||
"turns": "Turns"
|
||||
},
|
||||
"hint": {
|
||||
"headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.",
|
||||
"permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.",
|
||||
"bypassWarning": "{{tool}} will run every command and edit every file without asking. Only for a prompt and a folder you trust.",
|
||||
"sandboxWarning": "Codex will run commands with no sandbox.",
|
||||
"shortcut": "Enter to send · Shift+Enter for newline"
|
||||
},
|
||||
"mode": {
|
||||
"conversation": "Conversation",
|
||||
"conversationHint": "Multi-turn - keep typing follow-ups while the agent works.",
|
||||
"headless": "One-shot",
|
||||
"headlessHint": "Single prompt, single response. Stdin closes after spawn.",
|
||||
"label": "Mode"
|
||||
},
|
||||
"resume": {
|
||||
"clear": "Clear selection",
|
||||
"freshHint": "Start a fresh Claude Code session.",
|
||||
"freshOption": "New session",
|
||||
"label": "Source session",
|
||||
"lastOption": "Resume last session",
|
||||
"noSessions": "No sessions found.",
|
||||
"originalCwd": "Original cwd",
|
||||
"pickSession": "Pick a session…",
|
||||
"preview": "Last activity",
|
||||
"resumeHint": "Pick a session from your history and continue the conversation. Cwd is locked to the original.",
|
||||
"search": "Search by ID, cwd, or status…",
|
||||
"selectedBadge": "Resuming"
|
||||
},
|
||||
"statusHeader": {
|
||||
"cwd": "Cwd",
|
||||
"duration": "Elapsed",
|
||||
"effort": "Effort",
|
||||
"envelopes": "Events",
|
||||
"model": "Model",
|
||||
"permissionMode": "Mode",
|
||||
"session": "Session"
|
||||
},
|
||||
"tokens": {
|
||||
"cacheRead": "Cache hit",
|
||||
"cost": "Cost",
|
||||
"input": "Input",
|
||||
"label": "Context",
|
||||
"output": "Output"
|
||||
"sandboxWarning": "Codex will run commands with no sandbox."
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Drop files or images to attach",
|
||||
@@ -159,5 +81,11 @@
|
||||
"findPrev": "Previous match",
|
||||
"findNext": "Next match",
|
||||
"findClose": "Close find"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "Write a message…",
|
||||
"send": "Send",
|
||||
"hint": "Enter sends · permission prompts and menus need the terminal (Settings → Appearance → Chat UI)",
|
||||
"notSent": "The terminal is not connected — the message was not sent."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"filterError": "Error",
|
||||
"filterAbandoned": "Abandoned",
|
||||
"sessionCount": "{{count}} session recorded",
|
||||
"sessionCount_plural": "{{count}} sessions recorded",
|
||||
"noSessions": "No sessions found",
|
||||
"noSessionsDesc": "Try adjusting your search or filters.",
|
||||
"noSessionsHint": "Start a Claude Code session with hooks installed to begin tracking.",
|
||||
@@ -26,14 +25,12 @@
|
||||
"generic": "Claude Code is paused in this session until you respond in the terminal."
|
||||
},
|
||||
"failedLoad": "Failed to load session",
|
||||
"loading": "Loading session...",
|
||||
"notFound": "Session not found",
|
||||
"backToSessions": "Back to Sessions",
|
||||
"agents": "Agents",
|
||||
"noAgents": "No agents recorded.",
|
||||
"unparented": "Unparented Subagents",
|
||||
"costBreakdown": "Cost Breakdown",
|
||||
"eventTimeline": "Event Timeline",
|
||||
"noEvents": "No events recorded.",
|
||||
"conversation": "Conversation",
|
||||
"timeline": "Timeline ({{shown}}/{{total}})",
|
||||
@@ -151,6 +148,8 @@
|
||||
"persistedOutput": "Persisted output",
|
||||
"taskNotification": "Task notification",
|
||||
"thinking": "Thinking",
|
||||
"showMore": "Show more",
|
||||
"showLess": "Show less",
|
||||
"chars_one": "{{formatted}} char",
|
||||
"chars_other": "{{formatted}} chars",
|
||||
"sender": {
|
||||
@@ -181,6 +180,22 @@
|
||||
"statusError": "error",
|
||||
"statusOk": "ok",
|
||||
"statusPending": "pending"
|
||||
},
|
||||
"work": {
|
||||
"commands_one": "ran {{count}} command",
|
||||
"commands_other": "ran {{count}} commands",
|
||||
"edits_one": "edited a file",
|
||||
"edits_other": "made {{count}} edits",
|
||||
"reads_one": "read a file",
|
||||
"reads_other": "read {{count}} files",
|
||||
"searches_one": "searched the code",
|
||||
"searches_other": "ran {{count}} searches",
|
||||
"web_one": "looked up the web",
|
||||
"web_other": "made {{count}} web lookups",
|
||||
"agents_one": "ran an agent",
|
||||
"agents_other": "ran {{count}} agents",
|
||||
"tools_one": "used a tool",
|
||||
"tools_other": "used {{count}} tools"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
|
||||
@@ -326,6 +326,14 @@
|
||||
"confirmMessageAutostart": "Every workspace terminal is closed — running agents stop — and the dashboard shuts down. It stays off until you run `agenthub start`, or until the next boot/login (start automatically is on).",
|
||||
"done": "AgentHub is stopping. Run `agenthub start` to start it again."
|
||||
},
|
||||
"uninstall": {
|
||||
"label": "Uninstall AgentHub",
|
||||
"desc": "Remove AgentHub from this computer: the dashboard, its database and settings, its hooks in Claude Code, Codex and Antigravity, and the plugin. Your conversations, workspace folders and repositories are kept.",
|
||||
"button": "Uninstall",
|
||||
"confirmTitle": "Uninstall AgentHub?",
|
||||
"confirmMessage": "Every workspace terminal is closed and AgentHub is removed for good, its database (history, workspaces, settings, saved logins) included — this cannot be undone. Conversations of Claude Code, Codex and the other AI tools, workspace worktrees and your repositories are kept.",
|
||||
"done": "AgentHub is being uninstalled; this page stops answering in a moment. Log: {{log}}"
|
||||
},
|
||||
"about": {
|
||||
"title": "System",
|
||||
"release": "Release",
|
||||
@@ -670,6 +678,7 @@
|
||||
"enabledOf": "{{tool}} enabled",
|
||||
"homepage": "Homepage",
|
||||
"homepageOf": "{{tool}} homepage",
|
||||
"openSettings": "Open {{tool}} settings",
|
||||
"install": "Install",
|
||||
"update": "Update",
|
||||
"updateAll": "Update all ({{count}})",
|
||||
@@ -796,6 +805,8 @@
|
||||
},
|
||||
"settingsNav": {
|
||||
"label": "Settings sections",
|
||||
"toolsTab": "All tools",
|
||||
"toolTabs": "AI tool settings",
|
||||
"back": "Back to Workspace",
|
||||
"group": {
|
||||
"general": "General",
|
||||
@@ -936,6 +947,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."
|
||||
},
|
||||
"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.",
|
||||
"folder": "Backup folder",
|
||||
"folderHint": "An absolute path to an existing folder the server can write to. Leave it empty to turn backups off. AgentHub only ever deletes its own agenthub-YYYYMMDD-HHMMSS.db files here, oldest first beyond the number kept.",
|
||||
"hour": "Time (server)",
|
||||
"keep": "Backups kept",
|
||||
"runNow": "Back up now",
|
||||
"running": "Backing up…",
|
||||
"done": "Backup written: {{file}}",
|
||||
"failed": "Backup failed: {{error}}",
|
||||
"newest": "Newest: {{time}}, {{size}} · {{n}} kept, {{total}} in all",
|
||||
"none": "No backup in this folder yet.",
|
||||
"off": "Backups are off.",
|
||||
"lastFailed": "The last run ({{time}}) failed: {{error}}",
|
||||
"restoreHint": "To restore: agenthub stop, delete dashboard.db-wal and dashboard.db-shm, copy a backup over dashboard.db (its path is on the System page), then agenthub start."
|
||||
},
|
||||
"reports": {
|
||||
"title": "Scheduled reports",
|
||||
"description": "A summary of the last days — sessions, events, tokens, cost by model and by AI tool, top tools, budgets — written as HTML or JSON on a schedule. Reports and their history survive Clear Data.",
|
||||
@@ -985,5 +1013,12 @@
|
||||
"title": "Model pricing",
|
||||
"description": "Prices for every vendor's models live on one page.",
|
||||
"open": "Open Pricing"
|
||||
},
|
||||
"chatUi": {
|
||||
"title": "Chat UI",
|
||||
"description": "How a Claude or Codex session opens in its workspace. Saved for your account only.",
|
||||
"label": "Open sessions as a chat",
|
||||
"labelDesc": "Show every Claude or Codex session's conversation like Claude Desktop, with a message box, instead of its terminal. Permission prompts and menus still need the terminal: turn this off to answer them.",
|
||||
"saveFailed": "Could not save: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
"roles": {
|
||||
"admin": "Admin",
|
||||
"analyst": "Analyst",
|
||||
"member": "Member"
|
||||
"developer": "Developer"
|
||||
},
|
||||
"roleHint": {
|
||||
"admin": "Everything, including this server's configuration and every user's data",
|
||||
"analyst": "Reads everything; no terminal, no changes",
|
||||
"member": "Only their own workspaces"
|
||||
"analyst": "Sees every user's data; works only in their own workspaces, changes no settings",
|
||||
"developer": "Only their own workspaces; changes no settings"
|
||||
},
|
||||
"add": "Add user",
|
||||
"username": "Username",
|
||||
@@ -48,7 +48,7 @@
|
||||
"password": "Reset {{name}}'s password",
|
||||
"passwordBody": "{{name}} is signed out everywhere and signs in with the new password.",
|
||||
"workspace": "Give {{name}} a workspace",
|
||||
"workspaceBody": "The workspace's sessions and costs move to {{name}}. A member sees only their own workspaces."
|
||||
"workspaceBody": "The workspace's sessions and costs move to {{name}}. A developer sees only their own workspaces."
|
||||
},
|
||||
"done": {
|
||||
"created": "{{name}} added",
|
||||
@@ -68,6 +68,6 @@
|
||||
},
|
||||
"readOnly": "Analysts can view accounts but not change them.",
|
||||
"auditTitle": "Audit log",
|
||||
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a member owns.",
|
||||
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
|
||||
"auditEmpty": "Nothing recorded yet."
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
"title": "Workflows",
|
||||
"subtitle": "Agent orchestration intelligence",
|
||||
"failedLoad": "Failed to load workflow data",
|
||||
"filteredBy": "Filtered by:",
|
||||
"clearFilter": "Clear filter",
|
||||
"refreshData": "Refresh data",
|
||||
"exportJson": "Export as JSON",
|
||||
"allSessions": "All Sessions",
|
||||
@@ -238,8 +236,6 @@
|
||||
"noToolEvents": "No tool events recorded.",
|
||||
"noEvents": "No events recorded for this session.",
|
||||
"showingOf": "Showing 100 of {{total}} events",
|
||||
"sessionInfo": " session(s) - active {{from}}%-{{to}}% of session",
|
||||
"sortedByCompaction": " session(s) (sorted by compaction count)",
|
||||
"openSession": "Open session",
|
||||
"openInWorkspace": "Open in workspace"
|
||||
},
|
||||
@@ -266,7 +262,6 @@
|
||||
"subtitle": "Where errors cluster in agent hierarchy depth",
|
||||
"noErrors": "No errors recorded",
|
||||
"allSuccess": "All sessions completed successfully",
|
||||
"sessionsHadErrors": " sessions had errors",
|
||||
"sessionsErrorSummary": "{{errorSessions}} of {{totalSessions}} sessions had errors",
|
||||
"agentErrors": " agent-level error(s) across hierarchy",
|
||||
"sessionErrorsOnly": "Session-level errors only",
|
||||
@@ -306,7 +301,6 @@
|
||||
"ofTotal": "of {{total}}",
|
||||
"avgPerSession": "Avg / affected session",
|
||||
"peakSession": "Most in one session",
|
||||
"tokensRecovered": "Tokens Recovered",
|
||||
"distribution": "Sessions by compaction count",
|
||||
"xAxis": "Compactions per session",
|
||||
"yAxis": "Sessions",
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
"export": "Xuất",
|
||||
"totalSessions": "Tổng số phiên",
|
||||
"totalAgents": "Tổng số Agent",
|
||||
"totalTokens": "Tổng token",
|
||||
"totalCost": "Tổng chi phí",
|
||||
"totalEvents": "Tổng sự kiện",
|
||||
"cacheHitRate": "% tỷ lệ trúng bộ nhớ đệm",
|
||||
"perSession": " mỗi phiên",
|
||||
"noDataCost": "Không có dữ liệu",
|
||||
"eventCountLabel_one": "{{count}} sự kiện",
|
||||
"eventCountLabel_other": "{{count}} sự kiện",
|
||||
"tabs": {
|
||||
|
||||
@@ -30,14 +30,12 @@
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Thư mục gốc",
|
||||
"claudeHome": "Claude home",
|
||||
"projectClaudeDir": "Dự án .claude",
|
||||
"projectRoot": "Thư mục dự án",
|
||||
"claudeJson": "claude.json",
|
||||
"summary": "Tóm tắt nhanh",
|
||||
"user": "người dùng",
|
||||
"project": "dự án",
|
||||
"items": "mục"
|
||||
"project": "dự án"
|
||||
},
|
||||
"common": {
|
||||
"search": "Tìm theo tên…",
|
||||
@@ -47,32 +45,19 @@
|
||||
"close": "Đóng",
|
||||
"copyPath": "Sao chép đường dẫn",
|
||||
"copyName": "Sao chép tên",
|
||||
"copied": "Đã sao chép",
|
||||
"truncated": "(đã cắt bớt để xem trước)",
|
||||
"size": "Kích thước",
|
||||
"modified": "Đã sửa",
|
||||
"scopeLabel": "Phạm vi",
|
||||
"noFrontmatter": "Không có frontmatter",
|
||||
"redactedNotice": "Các giá trị khớp /token|secret|password|key|auth/i đã được ẩn ở phía máy chủ.",
|
||||
"preview": "Xem trước",
|
||||
"raw": "Mã nguồn"
|
||||
},
|
||||
"skills": {
|
||||
"name": "Tên",
|
||||
"description": "Mô tả"
|
||||
},
|
||||
"agents": {
|
||||
"name": "Tên",
|
||||
"model": "Model",
|
||||
"tools": "Công cụ"
|
||||
},
|
||||
"plugins": {
|
||||
"manifest": "Manifest",
|
||||
"marketplace": "Marketplace",
|
||||
"version": "Phiên bản",
|
||||
"installedAt": "Cài đặt lúc",
|
||||
"lastUpdated": "Cập nhật",
|
||||
"installPath": "Đường dẫn cài đặt",
|
||||
"missing": "Thiếu đường dẫn cài đặt",
|
||||
"manifestMissing": "Không tìm thấy installed_plugins.json tại {{path}}",
|
||||
"enabled": "Đang bật",
|
||||
@@ -85,8 +70,7 @@
|
||||
"hooks_other": "{{count}} hook script",
|
||||
"author": "Tác giả",
|
||||
"homepage": "Trang chủ",
|
||||
"license": "Giấy phép",
|
||||
"description": "Mô tả"
|
||||
"license": "Giấy phép"
|
||||
},
|
||||
"marketplaces": {
|
||||
"manifest": "Manifest marketplace đã biết",
|
||||
@@ -95,11 +79,9 @@
|
||||
"lastUpdated": "Đồng bộ lần cuối",
|
||||
"pluginCount": "Plugin",
|
||||
"owner": "Chủ sở hữu",
|
||||
"installLocation": "Đường dẫn cài",
|
||||
"addCmd": "claude plugin marketplace add <repo>"
|
||||
},
|
||||
"keybindings": {
|
||||
"file": "Tệp nguồn",
|
||||
"missing": "Không tìm thấy keybindings.json tại {{path}}",
|
||||
"context": "Ngữ cảnh",
|
||||
"key": "Phím",
|
||||
@@ -123,8 +105,7 @@
|
||||
},
|
||||
"hookScripts": {
|
||||
"title": "Script ~/.claude/hooks/",
|
||||
"subtitle": "Các script hỗ trợ được tham chiếu bởi lệnh hook của bạn. Chạy khi `hooks.<event>.hooks[].command` trong settings.json gọi đến.",
|
||||
"empty": "Không có script trong ~/.claude/hooks/."
|
||||
"subtitle": "Các script hỗ trợ được tham chiếu bởi lệnh hook của bạn. Chạy khi `hooks.<event>.hooks[].command` trong settings.json gọi đến."
|
||||
},
|
||||
"mcp": {
|
||||
"userScope": "Máy chủ phạm vi người dùng",
|
||||
@@ -138,8 +119,6 @@
|
||||
"env": "Tên env",
|
||||
"url": "URL",
|
||||
"headers": "Tên Header",
|
||||
"kind": "Loại",
|
||||
"source": "Nguồn",
|
||||
"noServers": "Không tìm thấy máy chủ MCP.",
|
||||
"notRemovable": "Được cấu hình trong settings.json — hãy xóa ở đó; không có phạm vi CLI nào áp dụng cho mục này."
|
||||
},
|
||||
@@ -157,8 +136,6 @@
|
||||
"rawJson": "JSON thô"
|
||||
},
|
||||
"memory": {
|
||||
"userScope": "Bộ nhớ người dùng",
|
||||
"projectScope": "Bộ nhớ dự án",
|
||||
"missing": "Không tìm thấy CLAUDE.md.",
|
||||
"autoTitle": "Kho bộ nhớ dự án",
|
||||
"autoSubtitle": "Bộ nhớ theo tệp cho từng dự án trong ~/.claude/projects/<dự-án>/memory/ — một chỉ mục MEMORY.md cùng một tệp cho mỗi sự kiện được ghi nhớ. Chỉ đọc tại đây.",
|
||||
@@ -236,15 +213,9 @@
|
||||
"backups": {
|
||||
"title": "Bản sao lưu",
|
||||
"empty": "Chưa có bản sao lưu - mỗi lần sửa hoặc xoá sẽ tạo bản sao có dấu thời gian.",
|
||||
"viewLink": "Xem lịch sử sao lưu",
|
||||
"openButton": "Bản sao lưu",
|
||||
"subtitle": "Mỗi thao tác lưu / xoá đều ghi bản sao tới đây trước. Thư mục sao lưu nằm ngoài các vị trí Claude Code quét, nên không xuất hiện như mục thừa trong CLI.",
|
||||
"restoreHint": "Khôi phục bằng cách sao chép bản sao lưu về chỗ cũ (bản sao lưu vẫn được giữ):",
|
||||
"size": "Kích thước",
|
||||
"created": "Tạo lúc",
|
||||
"type": "Loại",
|
||||
"scopeLabel": "Phạm vi",
|
||||
"filterAll": "Tất cả loại"
|
||||
"restoreHint": "Khôi phục bằng cách sao chép bản sao lưu về chỗ cũ (bản sao lưu vẫn được giữ):"
|
||||
},
|
||||
"profiles": {
|
||||
"usageCheck": "Kiểm tra quota",
|
||||
@@ -309,9 +280,7 @@
|
||||
"title": "Plugin chạy qua CLI `claude`",
|
||||
"body": "Plugin là các gói có registry riêng — `installed_plugins.json` cùng đường dẫn cài trong `plugins/cache/`. Các nút ở đây chạy đúng lệnh `claude plugin …` trên máy này nên registry luôn nhất quán. Cài plugin nghĩa là chạy mã của bên thứ ba: chỉ thêm marketplace mà bạn tin tưởng.",
|
||||
"install": "Cài plugin:",
|
||||
"installCmd": "claude plugin install <marketplace>/<plugin>",
|
||||
"uninstall": "Gỡ plugin này:",
|
||||
"uninstallButton": "Sao chép lệnh gỡ"
|
||||
"installCmd": "claude plugin install <marketplace>/<plugin>"
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP server được CLI quản lý",
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Thư mục gốc",
|
||||
"codexHome": "Thư mục Codex",
|
||||
"projectCodexDir": "Thư mục .codex của dự án",
|
||||
"projectRoot": "Thư mục gốc dự án",
|
||||
"summary": "Tóm tắt nhanh",
|
||||
@@ -72,9 +71,6 @@
|
||||
"saving": "Đang tạo…",
|
||||
"close": "Đóng"
|
||||
},
|
||||
"config": {
|
||||
"missing": "Không tìm thấy config.toml."
|
||||
},
|
||||
"mcp": {
|
||||
"addTitle": "Thêm máy chủ MCP",
|
||||
"nameLabel": "Tên",
|
||||
|
||||
@@ -21,14 +21,11 @@
|
||||
"live": "Trực tiếp",
|
||||
"offline": "Ngoại tuyến",
|
||||
"running": "đang chạy",
|
||||
"dashboardRun": "Chạy",
|
||||
"days": "ngày",
|
||||
"hours": "giờ",
|
||||
"updated": "Đã cập nhật ",
|
||||
"lastUpdated": "Cập nhật lần cuối: ",
|
||||
"showMore": "Hiển thị thêm (còn {{count}})",
|
||||
"plusMore": "+{{count}} nữa",
|
||||
"ofSessions": "% số phiên",
|
||||
"save": "Lưu",
|
||||
"edit": "Sửa",
|
||||
"delete": "Xóa",
|
||||
@@ -89,14 +86,8 @@
|
||||
"cost": {
|
||||
"model": "Mô hình",
|
||||
"cost": "Chi phí",
|
||||
"totalCost": "Tổng chi phí",
|
||||
"totalEstimatedCost": "Tổng chi phí ước tính"
|
||||
},
|
||||
"duration": {
|
||||
"h_m": "{{h}}h {{m}}m",
|
||||
"m_s": "{{m}}m {{s}}s",
|
||||
"s": "{{s}}s"
|
||||
},
|
||||
"eventDetail": {
|
||||
"summary": "Tóm tắt",
|
||||
"seeDetailsBelow": "Xem chi tiết đầy đủ bên dưới trong {{fields}}",
|
||||
@@ -137,8 +128,6 @@
|
||||
"rawPayload": "Dữ Liệu Thô",
|
||||
"copy": "Sao chép",
|
||||
"copied": "Đã sao chép",
|
||||
"noPayload": "Không có dữ liệu cho sự kiện này.",
|
||||
"viewDetails": "Xem chi tiết",
|
||||
"expand": "Mở rộng sự kiện",
|
||||
"collapse": "Thu gọn sự kiện"
|
||||
},
|
||||
@@ -157,12 +146,7 @@
|
||||
"loadMore": "Tải thêm",
|
||||
"loading": "Đang tải...",
|
||||
"noResults": "Không có sự kiện nào khớp với bộ lọc",
|
||||
"grouped": "Nhóm theo lần gọi công cụ",
|
||||
"flat": "Danh sách phẳng",
|
||||
"groupEventCount_one": "{{count}} sự kiện",
|
||||
"groupEventCount_other": "{{count}} sự kiện",
|
||||
"help": {
|
||||
"toggle": "Trợ giúp",
|
||||
"title": "Về sự kiện, bộ lọc và nhóm",
|
||||
"subtitle": "Nhấn để mở - trạng thái, vòng đời, ngữ nghĩa bộ lọc, giá trị bộ lọc",
|
||||
"valuesTitle": "Mỗi bộ lọc chấp nhận gì",
|
||||
@@ -176,10 +160,8 @@
|
||||
"statusesTitle": "Trạng thái",
|
||||
"statusesIntro": "Trạng thái được suy ra từ event_type.",
|
||||
"statusWorkingDesc": "Agent bắt đầu gọi công cụ (PreToolUse).",
|
||||
"statusConnectedDesc": "Công cụ hoàn tất và trả kết quả (PostToolUse).",
|
||||
"statusCompletedDesc": "Mốc vòng đời (Stop, SubagentStop, Compaction).",
|
||||
"statusErrorDesc": "Sự kiện lỗi (error, APIError).",
|
||||
"statusIdleDesc": "Sự kiện vòng đời / siêu dữ liệu (Notification, TurnDuration, SessionStart, SessionEnd).",
|
||||
"lifecycleTitle": "Vòng đời sự kiện",
|
||||
"lifecycleDesc": "Một lần gọi công cụ thường phát ra hai sự kiện chia sẻ tool_use_id:",
|
||||
"lifecycleFlow": "PreToolUse (Working) → PostToolUse (Connected) → ... → Stop (Completed)",
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"eventsToday": "Sự kiện hôm nay",
|
||||
"totalEvents": "Tổng sự kiện",
|
||||
"totalCost": "Tổng chi phí",
|
||||
"viewBoard": "Xem bảng",
|
||||
"viewAll": "Xem tất cả",
|
||||
"activeAgentsSection": "Agent đang hoạt động",
|
||||
"recentActivity": "Hoạt động gần đây",
|
||||
@@ -15,7 +14,6 @@
|
||||
"noAgentsDesc": "Agent sẽ xuất hiện ở đây khi phiên Claude Code đang chạy.",
|
||||
"noActivity": "Chưa có hoạt động",
|
||||
"noActivityDesc": "Sự kiện từ các phiên Claude Code sẽ được phát trực tiếp tại đây.",
|
||||
"failedConnect": "Không thể kết nối máy chủ",
|
||||
"failedLoad": "Không thể tải dữ liệu",
|
||||
"activeTrend": " đang hoạt động",
|
||||
"totalTrend": " trong phiên hoạt động",
|
||||
|
||||
@@ -19,15 +19,6 @@
|
||||
"sessionError": "Lỗi phiên",
|
||||
"subagentSpawned": "Đã tạo Subagent"
|
||||
},
|
||||
"toolLegend": {
|
||||
"read": "Đọc",
|
||||
"write": "Ghi",
|
||||
"edit": "Sửa",
|
||||
"bash": "Bash",
|
||||
"grep": "Grep",
|
||||
"glob": "Glob",
|
||||
"agent": "Agent",
|
||||
"other": "Khác"
|
||||
},
|
||||
"badResponse": "Máy chủ trả về phản hồi không mong đợi thay vì dữ liệu. Dashboard có thể đang khởi động lại, hoặc một proxy đang trả lời thay nó — hãy tải lại sau giây lát."
|
||||
"badResponse": "Máy chủ trả về phản hồi không mong đợi thay vì dữ liệu. Dashboard có thể đang khởi động lại, hoặc một proxy đang trả lời thay nó — hãy tải lại sau giây lát.",
|
||||
"adminOnly": "Chỉ admin mới làm được thao tác này. Hãy nhờ admin của AgentHub nếu bạn cần."
|
||||
}
|
||||
|
||||
@@ -1,45 +1,18 @@
|
||||
{
|
||||
"title": "Bảng Kanban",
|
||||
"subtitle": "Góc nhìn kanban của mọi Agent theo trạng thái",
|
||||
"subtitleSessions": "Góc nhìn kanban của mọi phiên theo trạng thái",
|
||||
"viewToggle": {
|
||||
"agents": "Agent",
|
||||
"sessions": "Phiên"
|
||||
},
|
||||
"noAgents": "Chưa có Agent được theo dõi",
|
||||
"noAgentsDesc": "Bắt đầu một phiên Claude Code với hooks đã cài để thấy Agent xuất hiện tại đây.",
|
||||
"agentCount": "Đã theo dõi {{count}} Agent",
|
||||
"agentCount_plural": "Đã theo dõi {{count}} Agent",
|
||||
"noAgentsInColumn": "Không có Agent",
|
||||
"noSessions": "Chưa có phiên được theo dõi",
|
||||
"noSessionsDesc": "Bắt đầu một phiên Claude Code với hooks đã cài để thấy phiên xuất hiện tại đây.",
|
||||
"sessionCount": "Đã theo dõi {{count}} phiên",
|
||||
"sessionCount_plural": "Đã theo dõi {{count}} phiên",
|
||||
"noSessionsInColumn": "Không có phiên",
|
||||
"ran": "đã chạy ",
|
||||
"running": "đang chạy ",
|
||||
"session": {
|
||||
"anonymous": "Phiên chưa đặt tên",
|
||||
"agentSummary_one": "{{count}} Agent",
|
||||
"agentSummary_other": "{{count}} Agent",
|
||||
"subagentSummary_one": "{{count}} Agent con",
|
||||
"subagentSummary_other": "{{count}} Agent con",
|
||||
"turnSummary_one": "{{count}} lượt",
|
||||
"turnSummary_other": "{{count}} lượt"
|
||||
},
|
||||
"tooltip": {
|
||||
"agent": {
|
||||
"working": "Agent đang chạy một tool call ngay lúc này.\n\nVào trạng thái khi PreToolUse hoặc UserPromptSubmit, thoát khi Stop (hết lượt).",
|
||||
"waiting": "Agent đã kết thúc lượt và đang chờ người dùng - Claude chờ tin nhắn tiếp theo hoặc đã hỏi xin quyền sử dụng tool.\n\nXóa khi PreToolUse / PostToolUse tiếp theo, hoặc khi phiên kết thúc.",
|
||||
"completed": "Agent đã kết thúc hoàn toàn cho phiên này - thường do SessionEnd kích hoạt, hoặc với subagent là do SubagentStop.\n\nKhông còn hoạt động nào được ghi nhận cho Agent này.",
|
||||
"error": "Agent gặp lỗi và sẽ không tiếp tục. Với main agent là từ Stop với stop_reason=error; với subagent là do chạy thất bại."
|
||||
},
|
||||
"session": {
|
||||
"active": "Phiên đang giữa lượt - Claude đang chạy và người dùng không bị chặn. Trạng thái này thoáng qua: phiên đi qua Active trong các tool call rồi quay lại Waiting khi hết lượt.",
|
||||
"waiting": "Phiên đang chờ người dùng. Có thể Claude đã hết lượt (phổ biến nhất) hoặc đang hỏi quyền và bị chặn cho tới khi người dùng phản hồi.\n\nXem cột này để biết phiên nào bạn cần trả lời.",
|
||||
"completed": "Phiên đã đóng hoàn toàn - SessionEnd đã kích hoạt (CLI thoát sạch) hoặc người dùng đã đóng thủ công.\n\nMọi Agent của phiên cũng đã hoàn tất.",
|
||||
"error": "Phiên kết thúc với lỗi - Claude trả về stop_reason=error, có lỗi không thể khôi phục, hoặc lỗi API thượng nguồn đã chấm dứt lần chạy.",
|
||||
"abandoned": "Phiên đã chết mà không có SessionEnd sạch. Bị đánh dấu bỏ dở sau 5+ phút không hoạt động (mồ côi vì /resume, mất mạng, hoặc CLI bị tắt). Khác với Completed: ta không thấy tín hiệu thoát thật sự."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
{
|
||||
"action.clear": "Dọn trạng thái",
|
||||
"action.forget": "Xóa workspace",
|
||||
"action.purge": "Xóa lịch sử",
|
||||
"action.reset": "Đặt lại worktree",
|
||||
"action.start": "Bắt đầu",
|
||||
"action.stop": "Dừng",
|
||||
"actionError": "Thao tác workspace thất bại: {{message}}",
|
||||
"actionErrorUnknown": "Lỗi không xác định",
|
||||
"add": "Thêm workspace",
|
||||
@@ -110,14 +105,6 @@
|
||||
"changes.unstage": "Bỏ file này khỏi index",
|
||||
"changes.changesView": "Thay đổi",
|
||||
"changes.wholeFile": "Toàn bộ file",
|
||||
"confirmRemoveCancel": "Hủy",
|
||||
"confirmRemoveConfirm": "Xóa",
|
||||
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
|
||||
"confirmRemoveTitle": "Xóa workspace?",
|
||||
"countDead": "đã chết",
|
||||
"countNeedsYou": "cần bạn",
|
||||
"countRunning": "đang chạy",
|
||||
"countTotal": "workspace",
|
||||
"destructive.blocked.adopted": "Đây là thư mục đã nhận và không thể thay đổi bằng thao tác worktree.",
|
||||
"destructive.blocked.missing": "Thư mục workspace không còn, nên không thể tiếp tục thao tác này.",
|
||||
"destructive.blocked.unreadable": "Không thể đọc thư mục workspace như một Git worktree, nên không thể tiếp tục thao tác này.",
|
||||
@@ -189,10 +176,6 @@
|
||||
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
|
||||
"gitBar.switchLabel": "Đổi nhánh…",
|
||||
"gitBar.unbornHint": "Hãy commit trước, rồi publish repo này lên remote.",
|
||||
"kind.adopted": "đã nhận",
|
||||
"kind.managed": "được quản lý",
|
||||
"laneDetail.hide": "Ẩn chi tiết",
|
||||
"laneDetail.show": "Chi tiết workspace",
|
||||
"locks.held_one": "Đang giữ {{count}} khóa",
|
||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||
"mode.repo": "Repo",
|
||||
@@ -206,25 +189,18 @@
|
||||
"preflightErrorWithMessage": "Không thể tải trạng thái workspace hiện tại: {{message}}",
|
||||
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
||||
"renameLabel": "Đổi tên workspace này",
|
||||
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
|
||||
"proxy.native": "Native ({{tool}})",
|
||||
"proxy.label": "AI Proxy",
|
||||
"runtime.boot": "▶ chạy",
|
||||
"runtime.busy.down": "đang dừng…",
|
||||
"runtime.busy.up": "đang khởi động…",
|
||||
"runtime.down": "tắt",
|
||||
"runtime.healthy": "khoẻ",
|
||||
"runtime.partial": "một phần",
|
||||
"runtime.slot": "slot {{slot}}",
|
||||
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
|
||||
"runtime.stop": "■ dừng",
|
||||
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của workspace này (khác với phiên Claude của nó)",
|
||||
"status.failed": "thất bại",
|
||||
"status.idle": "rảnh",
|
||||
"status.provisioning": "đang khởi tạo",
|
||||
"status.running": "đang chạy",
|
||||
"title": "Workspace",
|
||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktree",
|
||||
"changes.undoHunk": "Hoàn tác đoạn này",
|
||||
@@ -386,6 +362,7 @@
|
||||
"home.memoryEmpty": "Bộ nhớ hiện ở đây khi thư mục có CLAUDE.md hoặc AGENTS.md, hoặc Claude lưu bộ nhớ cho nó.",
|
||||
"home.context": "Ngữ cảnh",
|
||||
"home.contextEmpty": "Thêm PDF, tài liệu hoặc văn bản để tham chiếu trong workspace này.",
|
||||
"home.contextSnapshot": "Mỗi file được sao chép lúc thêm — sửa file gốc sau đó sẽ không cập nhật theo. Hãy xóa rồi thêm lại, hoặc thêm thư mục chứa nó ở mục Thư mục để luôn đọc bản mới nhất.",
|
||||
"home.addFile": "Thêm file",
|
||||
"home.folder": "Thư mục",
|
||||
"home.ownFolder": "Thư mục của workspace",
|
||||
@@ -452,6 +429,7 @@
|
||||
"tree.showMore_one": "Hiện thêm {{count}}",
|
||||
"tree.showMore_other": "Hiện thêm {{count}}",
|
||||
"tree.seeAll": "Xem tất cả (thêm {{count}})",
|
||||
"tree.showLess": "Thu gọn",
|
||||
"home.contextPanel": "Ngữ cảnh workspace",
|
||||
"row.state.needsYou": "cần bạn",
|
||||
"row.state.error": "lỗi",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"activityFeed": "Luồng hoạt động",
|
||||
"agentBoard": "Bảng Kanban",
|
||||
"agents": "Agent",
|
||||
"analytics": "Phân tích",
|
||||
"avg": "TB",
|
||||
@@ -19,20 +18,11 @@
|
||||
"daysAgo": "{{count}} ngày trước",
|
||||
"disconnected": "Mất kết nối",
|
||||
"eventsLastMin": "60s qua",
|
||||
"eventsReceived": "Sự kiện đã nhận",
|
||||
"eventsTotal": "Tổng",
|
||||
"expand": "Mở rộng thanh bên",
|
||||
"hoursAgo": "{{count}} giờ trước",
|
||||
"justNow": "vừa xong",
|
||||
"language": "Ngôn ngữ",
|
||||
"languageNames": {
|
||||
"en": "English",
|
||||
"vi": "Tiếng Việt"
|
||||
},
|
||||
"languageShort": {
|
||||
"en": "EN",
|
||||
"vi": "VI"
|
||||
},
|
||||
"lastEvent": "Sự kiện gần nhất",
|
||||
"live": "Trực tiếp",
|
||||
"minutesAgo": "{{count}} phút trước",
|
||||
@@ -55,7 +45,6 @@
|
||||
"unitPerSec": "sự kiện/s",
|
||||
"upToDate": "Đã là mới nhất",
|
||||
"updateAvailable": "Có bản cập nhật",
|
||||
"website": "Website",
|
||||
"workspace": "Không gian làm việc",
|
||||
"wsEndpoint": "WebSocket",
|
||||
"releaseAvailable": "Có {{version}}",
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
{
|
||||
"emptyPane": "Pane này chưa có phiên nào. Hãy tạo phiên từ ô prompt của workspace.",
|
||||
"actions": {
|
||||
"newSession": "Phiên mới",
|
||||
"send": "Gửi",
|
||||
"sending": "Đang gửi…",
|
||||
"start": "Chạy",
|
||||
"starting": "Đang khởi động…",
|
||||
"stop": "Dừng",
|
||||
"stopping": "Đang dừng…"
|
||||
"newSession": "Phiên mới"
|
||||
},
|
||||
"autocomplete": {
|
||||
"fileHint": "Gõ @ để tham chiếu file",
|
||||
"groupBuiltin": "Built-in (chỉ CLI - không chạy ở đây)",
|
||||
"groupFiles": "File trong cwd",
|
||||
"groupProject": "Lệnh project",
|
||||
"groupUser": "Lệnh user",
|
||||
"noMatches": "Không có",
|
||||
"slashHint": "Gõ / để tìm lệnh slash"
|
||||
},
|
||||
"binary": {
|
||||
"found": "Đang dùng {{path}}",
|
||||
"missing": "Không thấy CLI `{{cli}}` trong PATH. Cài đặt {{tool}} hoặc đặt PATH."
|
||||
},
|
||||
"concurrency": {
|
||||
"active_other": "Có {{count}} run đang chạy",
|
||||
"atCap": "Đạt giới hạn concurrent ({{max}}). Dừng một run đang chạy để bắt đầu run mới.",
|
||||
"viewActive": "Xem các run đang chạy"
|
||||
},
|
||||
"empty": {
|
||||
"body": "Gõ prompt và bấm Chạy. Output sẽ stream khi model suy nghĩ, gọi tool, và trả lời.",
|
||||
"title": "Chưa có gì"
|
||||
@@ -39,28 +23,16 @@
|
||||
"runStartedButNotAttached": "Run đã khởi động nhưng không thể gắn vào bảng điều khiển: {{message}}",
|
||||
"sendFailed": "Không gửi được: {{message}}"
|
||||
},
|
||||
"events": {
|
||||
"claude": "Claude",
|
||||
"collapse": "Thu gọn",
|
||||
"expand": "Mở rộng",
|
||||
"system": "Hệ thống",
|
||||
"thinking": "Đang suy nghĩ",
|
||||
"tool": "Tool",
|
||||
"toolResult": "Kết quả tool",
|
||||
"you": "Bạn"
|
||||
},
|
||||
"fields": {
|
||||
"approval": "Phê duyệt",
|
||||
"approvalNever": "Không bao giờ",
|
||||
"approvalOnRequest": "Khi cần",
|
||||
"cwd": "Thư mục làm việc",
|
||||
"cwdGroups": {
|
||||
"dashboard": "Máy chủ dashboard",
|
||||
"home": "Home",
|
||||
"repo": "Repo nguồn",
|
||||
"recent": "Gần đây - Claude Code đã dùng"
|
||||
},
|
||||
"cwdHint": "Đường dẫn tuyệt đối. Mặc định là cwd của dashboard.",
|
||||
"cwdNoMatches": "Không khớp. Gõ đường dẫn tuyệt đối để dùng trực tiếp.",
|
||||
"cwdPlaceholder": "Gõ để tìm hoặc dán đường dẫn tuyệt đối…",
|
||||
"effort": "Mức độ suy nghĩ",
|
||||
@@ -81,64 +53,15 @@
|
||||
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
|
||||
"permissionMode": "Chế độ quyền",
|
||||
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
||||
"prompt": "Prompt",
|
||||
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
|
||||
"promptPlaceholderTerminal": "Hỏi {{tool}} bất cứ điều gì…",
|
||||
"sandbox": "Sandbox",
|
||||
"sandboxFullAccess": "Truy cập đầy đủ (nguy hiểm)",
|
||||
"sandboxReadOnly": "Chỉ đọc",
|
||||
"sandboxWorkspaceWrite": "Ghi vào workspace"
|
||||
},
|
||||
"footer": {
|
||||
"cost": "Chi phí",
|
||||
"duration": "Thời lượng",
|
||||
"exitCode": "Mã thoát",
|
||||
"sessionId": "Session ID",
|
||||
"turns": "Lượt"
|
||||
},
|
||||
"hint": {
|
||||
"headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.",
|
||||
"permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.",
|
||||
"bypassWarning": "{{tool}} sẽ chạy mọi lệnh và sửa mọi file mà không hỏi. Chỉ dùng với prompt và thư mục bạn tin tưởng.",
|
||||
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
|
||||
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
|
||||
},
|
||||
"mode": {
|
||||
"conversation": "Hội thoại",
|
||||
"conversationHint": "Đa lượt - tiếp tục gõ trong khi agent xử lý.",
|
||||
"headless": "Một lần",
|
||||
"headlessHint": "Một prompt, một phản hồi. Stdin đóng ngay sau khi spawn.",
|
||||
"label": "Chế độ"
|
||||
},
|
||||
"resume": {
|
||||
"clear": "Xoá lựa chọn",
|
||||
"freshHint": "Bắt đầu phiên Claude Code mới.",
|
||||
"freshOption": "Phiên mới",
|
||||
"label": "Phiên nguồn",
|
||||
"lastOption": "Tiếp tục phiên trước",
|
||||
"noSessions": "Không tìm thấy phiên.",
|
||||
"originalCwd": "cwd gốc",
|
||||
"pickSession": "Chọn phiên…",
|
||||
"preview": "Hoạt động gần nhất",
|
||||
"resumeHint": "Chọn một phiên từ lịch sử để tiếp tục. cwd được khoá theo phiên gốc.",
|
||||
"search": "Tìm theo ID, cwd hoặc status…",
|
||||
"selectedBadge": "Đang tiếp tục"
|
||||
},
|
||||
"statusHeader": {
|
||||
"cwd": "Cwd",
|
||||
"duration": "Đã chạy",
|
||||
"effort": "Effort",
|
||||
"envelopes": "Sự kiện",
|
||||
"model": "Model",
|
||||
"permissionMode": "Mode",
|
||||
"session": "Session"
|
||||
},
|
||||
"tokens": {
|
||||
"cacheRead": "Trúng cache",
|
||||
"cost": "Chi phí",
|
||||
"input": "Input",
|
||||
"label": "Context",
|
||||
"output": "Output"
|
||||
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox."
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Thả file hoặc ảnh để đính kèm",
|
||||
@@ -158,5 +81,11 @@
|
||||
"findPrev": "Kết quả trước",
|
||||
"findNext": "Kết quả sau",
|
||||
"findClose": "Đóng tìm kiếm"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "Viết tin nhắn…",
|
||||
"send": "Gửi",
|
||||
"hint": "Enter để gửi · hỏi quyền và menu cần terminal (Settings → Appearance → Giao diện chat)",
|
||||
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"filterError": "Lỗi",
|
||||
"filterAbandoned": "Bị bỏ dở",
|
||||
"sessionCount": "Đã ghi nhận {{count}} phiên",
|
||||
"sessionCount_plural": "Đã ghi nhận {{count}} phiên",
|
||||
"noSessions": "Không tìm thấy phiên",
|
||||
"noSessionsDesc": "Hãy thử điều chỉnh tìm kiếm hoặc bộ lọc.",
|
||||
"noSessionsHint": "Bắt đầu một phiên Claude Code với hooks đã cài để theo dõi.",
|
||||
@@ -26,14 +25,12 @@
|
||||
"generic": "Claude Code đang tạm dừng trong phiên này cho đến khi bạn phản hồi trong terminal."
|
||||
},
|
||||
"failedLoad": "Không thể tải phiên",
|
||||
"loading": "Đang tải phiên...",
|
||||
"notFound": "Không tìm thấy phiên",
|
||||
"backToSessions": "Quay lại danh sách phiên",
|
||||
"agents": "Agent",
|
||||
"noAgents": "Chưa có Agent được ghi nhận.",
|
||||
"unparented": "Subagent không có cha",
|
||||
"costBreakdown": "Phân rã chi phí",
|
||||
"eventTimeline": "Dòng thời gian sự kiện",
|
||||
"noEvents": "Chưa có sự kiện được ghi nhận.",
|
||||
"conversation": "Hội thoại",
|
||||
"timeline": "Dòng thời gian ({{shown}}/{{total}})",
|
||||
@@ -151,6 +148,8 @@
|
||||
"persistedOutput": "Đầu ra đã lưu",
|
||||
"taskNotification": "Thông báo tác vụ",
|
||||
"thinking": "Suy nghĩ",
|
||||
"showMore": "Xem thêm",
|
||||
"showLess": "Thu gọn",
|
||||
"chars_one": "{{formatted}} ký tự",
|
||||
"chars_other": "{{formatted}} ký tự",
|
||||
"sender": {
|
||||
@@ -181,6 +180,22 @@
|
||||
"statusError": "lỗi",
|
||||
"statusOk": "ok",
|
||||
"statusPending": "đang chờ"
|
||||
},
|
||||
"work": {
|
||||
"commands_one": "đã chạy {{count}} lệnh",
|
||||
"commands_other": "đã chạy {{count}} lệnh",
|
||||
"edits_one": "sửa 1 file",
|
||||
"edits_other": "sửa {{count}} lần",
|
||||
"reads_one": "đọc 1 file",
|
||||
"reads_other": "đọc {{count}} file",
|
||||
"searches_one": "tìm trong code",
|
||||
"searches_other": "tìm {{count}} lần",
|
||||
"web_one": "tra cứu web",
|
||||
"web_other": "tra cứu web {{count}} lần",
|
||||
"agents_one": "chạy 1 agent",
|
||||
"agents_other": "chạy {{count}} agent",
|
||||
"tools_one": "dùng 1 công cụ",
|
||||
"tools_other": "dùng {{count}} công cụ"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
|
||||
@@ -326,6 +326,14 @@
|
||||
"confirmMessageAutostart": "Mọi terminal của các workspace sẽ bị đóng — agent đang chạy sẽ dừng — và dashboard tắt. Nó giữ tắt cho tới khi bạn chạy `agenthub start`, hoặc tới lần khởi động/đăng nhập máy kế tiếp (đang bật tự khởi động).",
|
||||
"done": "AgentHub đang tắt. Chạy `agenthub start` để bật lại."
|
||||
},
|
||||
"uninstall": {
|
||||
"label": "Gỡ cài đặt AgentHub",
|
||||
"desc": "Gỡ AgentHub khỏi máy: dashboard, database và cài đặt của nó, các hook trong Claude Code, Codex và Antigravity, và plugin. Các cuộc trò chuyện, thư mục workspace và repository của bạn được giữ lại.",
|
||||
"button": "Gỡ cài đặt",
|
||||
"confirmTitle": "Gỡ cài đặt AgentHub?",
|
||||
"confirmMessage": "Mọi terminal của các workspace sẽ bị đóng và AgentHub bị gỡ hẳn, kể cả database (lịch sử, workspace, cài đặt, đăng nhập đã lưu) — không thể hoàn tác. Các cuộc trò chuyện của Claude Code, Codex và các công cụ AI khác, worktree của workspace và repository của bạn được giữ lại.",
|
||||
"done": "AgentHub đang được gỡ; trang này sẽ ngừng phản hồi trong giây lát. Log: {{log}}"
|
||||
},
|
||||
"about": {
|
||||
"title": "Hệ thống",
|
||||
"release": "Phiên bản",
|
||||
@@ -670,6 +678,7 @@
|
||||
"enabledOf": "Bật {{tool}}",
|
||||
"homepage": "Trang chủ",
|
||||
"homepageOf": "Trang chủ {{tool}}",
|
||||
"openSettings": "Mở cài đặt {{tool}}",
|
||||
"install": "Cài đặt",
|
||||
"update": "Cập nhật",
|
||||
"updateAll": "Cập nhật tất cả ({{count}})",
|
||||
@@ -796,6 +805,8 @@
|
||||
},
|
||||
"settingsNav": {
|
||||
"label": "Các mục cài đặt",
|
||||
"toolsTab": "Tất cả",
|
||||
"toolTabs": "Cài đặt AI tool",
|
||||
"back": "Quay lại Không gian làm việc",
|
||||
"group": {
|
||||
"general": "Chung",
|
||||
@@ -936,6 +947,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."
|
||||
},
|
||||
"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.",
|
||||
"folder": "Thư mục backup",
|
||||
"folderHint": "Đường dẫn tuyệt đối tới một thư mục có sẵn mà server ghi được. Để trống để tắt backup. AgentHub chỉ xóa đúng các tệp agenthub-YYYYMMDD-HHMMSS.db của chính nó ở đây, bản cũ nhất trước khi vượt số bản giữ lại.",
|
||||
"hour": "Giờ chạy (giờ server)",
|
||||
"keep": "Số bản giữ lại",
|
||||
"runNow": "Backup ngay",
|
||||
"running": "Đang backup…",
|
||||
"done": "Đã ghi bản backup: {{file}}",
|
||||
"failed": "Backup thất bại: {{error}}",
|
||||
"newest": "Mới nhất: {{time}}, {{size}} · đang giữ {{n}} bản, tổng {{total}}",
|
||||
"none": "Thư mục này chưa có bản backup nào.",
|
||||
"off": "Backup đang tắt.",
|
||||
"lastFailed": "Lần chạy gần nhất ({{time}}) thất bại: {{error}}",
|
||||
"restoreHint": "Khôi phục: agenthub stop, xóa dashboard.db-wal và dashboard.db-shm, chép bản backup đè lên dashboard.db (đường dẫn ở trang Hệ thống), rồi agenthub start."
|
||||
},
|
||||
"reports": {
|
||||
"title": "Báo cáo định kỳ",
|
||||
"description": "Bản tóm tắt những ngày gần nhất — phiên, sự kiện, token, chi phí theo model và theo công cụ AI, công cụ dùng nhiều nhất, ngân sách — ghi ra HTML hoặc JSON theo lịch. Báo cáo và lịch sử chạy được giữ lại khi Xóa dữ liệu.",
|
||||
@@ -985,5 +1013,12 @@
|
||||
"title": "Giá model",
|
||||
"description": "Giá model của mọi nhà cung cấp nằm chung một trang.",
|
||||
"open": "Mở trang Giá"
|
||||
},
|
||||
"chatUi": {
|
||||
"title": "Giao diện chat",
|
||||
"description": "Cách một session Claude hoặc Codex mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
|
||||
"label": "Mở session dạng chat",
|
||||
"labelDesc": "Hiện hội thoại của mọi session Claude hoặc Codex như Claude Desktop, kèm ô nhập tin, thay cho terminal. Hỏi quyền và menu vẫn cần terminal: tắt mục này để trả lời chúng.",
|
||||
"saveFailed": "Không lưu được: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
"roles": {
|
||||
"admin": "Admin",
|
||||
"analyst": "Analyst",
|
||||
"member": "Thành viên"
|
||||
"developer": "Developer"
|
||||
},
|
||||
"roleHint": {
|
||||
"admin": "Toàn quyền, kể cả cấu hình của server và dữ liệu của mọi người",
|
||||
"analyst": "Xem được mọi thứ; không có terminal, không thay đổi gì",
|
||||
"member": "Chỉ workspace của chính mình"
|
||||
"analyst": "Xem được dữ liệu của mọi người; chỉ làm việc trong workspace của mình, không đổi cài đặt",
|
||||
"developer": "Chỉ workspace của chính mình; không đổi cài đặt"
|
||||
},
|
||||
"add": "Thêm người dùng",
|
||||
"username": "Tên đăng nhập",
|
||||
@@ -48,7 +48,7 @@
|
||||
"password": "Đặt lại mật khẩu cho {{name}}",
|
||||
"passwordBody": "{{name}} bị đăng xuất khỏi mọi nơi và đăng nhập lại bằng mật khẩu mới.",
|
||||
"workspace": "Giao workspace cho {{name}}",
|
||||
"workspaceBody": "Các phiên và chi phí của workspace chuyển sang {{name}}. Thành viên chỉ thấy workspace của chính mình."
|
||||
"workspaceBody": "Các phiên và chi phí của workspace chuyển sang {{name}}. Developer chỉ thấy workspace của chính mình."
|
||||
},
|
||||
"done": {
|
||||
"created": "Đã thêm {{name}}",
|
||||
@@ -68,6 +68,6 @@
|
||||
},
|
||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||
"auditTitle": "Nhật ký audit",
|
||||
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một thành viên.",
|
||||
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một developer.",
|
||||
"auditEmpty": "Chưa ghi nhận gì."
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
"title": "Quy trình",
|
||||
"subtitle": "Phân tích điều phối Agent",
|
||||
"failedLoad": "Không thể tải dữ liệu quy trình",
|
||||
"filteredBy": "Đang lọc theo:",
|
||||
"clearFilter": "Xóa bộ lọc",
|
||||
"refreshData": "Làm mới dữ liệu",
|
||||
"exportJson": "Xuất JSON",
|
||||
"allSessions": "Tất cả phiên",
|
||||
@@ -238,8 +236,6 @@
|
||||
"noToolEvents": "Không có sự kiện công cụ.",
|
||||
"noEvents": "Không có sự kiện cho phiên này.",
|
||||
"showingOf": "Đang hiển thị 100 / {{total}} sự kiện",
|
||||
"sessionInfo": " phiên - hoạt động {{from}}%-{{to}} thời lượng phiên",
|
||||
"sortedByCompaction": " phiên (sắp theo số lần nén ngữ cảnh)",
|
||||
"openSession": "Mở phiên",
|
||||
"openInWorkspace": "Mở trong workspace"
|
||||
},
|
||||
@@ -266,7 +262,6 @@
|
||||
"subtitle": "Điểm tập trung lỗi theo độ sâu phân cấp Agent",
|
||||
"noErrors": "Không ghi nhận lỗi",
|
||||
"allSuccess": "Tất cả phiên đã hoàn tất thành công",
|
||||
"sessionsHadErrors": " phiên gặp lỗi",
|
||||
"sessionsErrorSummary": "{{errorSessions}} trên {{totalSessions}} phiên gặp lỗi",
|
||||
"agentErrors": " lỗi cấp Agent trong toàn bộ phân cấp",
|
||||
"sessionErrorsOnly": "Chỉ có lỗi ở cấp phiên",
|
||||
@@ -306,7 +301,6 @@
|
||||
"ofTotal": "trên {{total}}",
|
||||
"avgPerSession": "TB / phiên bị ảnh hưởng",
|
||||
"peakSession": "Nhiều nhất trong một phiên",
|
||||
"tokensRecovered": "Token thu hồi",
|
||||
"distribution": "Số phiên theo số lần nén",
|
||||
"xAxis": "Số lần nén mỗi phiên",
|
||||
"yAxis": "Số phiên",
|
||||
|
||||
@@ -59,11 +59,36 @@ describe("openInLayout", () => {
|
||||
expect(out.focus).toBe(3);
|
||||
});
|
||||
|
||||
it("splits the focused pane to the right on a split open", () => {
|
||||
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
const out = openInLayout(l, 2, "split");
|
||||
expect(out.root).toEqual(split("row", split("row", pane(0), pane(2)), pane(1)));
|
||||
expect(out.focus).toBe(2);
|
||||
it("fills a split open into the 2×2 grid: right, under the right, under the left", () => {
|
||||
let l: PaneLayout = one;
|
||||
l = openInLayout(l, 1, "split");
|
||||
expect(l.root).toEqual(split("row", pane(0), pane(1)));
|
||||
// The focus is on the left pane: the grid order wins, not the focus.
|
||||
l = openInLayout({ ...l, focus: 0 }, 2, "split");
|
||||
expect(l.root).toEqual(split("row", pane(0), split("col", pane(1), pane(2))));
|
||||
l = openInLayout(l, 3, "split");
|
||||
expect(l.root).toEqual(
|
||||
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)))
|
||||
);
|
||||
expect(l.focus).toBe(3);
|
||||
});
|
||||
|
||||
it("a split open fills the one gap of a three-pane layout, whatever its shape", () => {
|
||||
const left: PaneLayout = {
|
||||
root: split("row", split("col", pane(0), pane(1)), pane(2)),
|
||||
focus: 0,
|
||||
};
|
||||
expect(openInLayout(left, 3, "split").root).toEqual(
|
||||
split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3)))
|
||||
);
|
||||
// A dragged divider does not move the gap.
|
||||
const wide: PaneLayout = {
|
||||
root: split("row", pane(0), split("col", pane(1), pane(2)), 0.3),
|
||||
focus: 1,
|
||||
};
|
||||
expect(openInLayout(wide, 3, "split").root).toEqual(
|
||||
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)), 0.3)
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces instead of splitting once MAX_PANES are shown", () => {
|
||||
@@ -226,7 +251,7 @@ describe("pane groups (tabs of up to four panes)", () => {
|
||||
for (const s of [1, 2, 3]) g = openInGroups(g, s, true);
|
||||
expect(leaves(g.groups[0]!.root)).toHaveLength(MAX_PANES);
|
||||
const out = openInGroups(g, 4, true);
|
||||
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 1, 2, 3], [4]]);
|
||||
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 3, 1, 2], [4]]);
|
||||
expect(out.active).toBe(1);
|
||||
});
|
||||
|
||||
@@ -384,6 +409,18 @@ describe("joinGroup", () => {
|
||||
expect(g.groups[0]?.focus).toBe(3);
|
||||
});
|
||||
|
||||
it("fills the gap under the right pane when the left one is already split", () => {
|
||||
const combo: PaneLayout = {
|
||||
root: split("row", split("col", pane(0), pane(1)), pane(2)),
|
||||
focus: 0,
|
||||
};
|
||||
const g = joinGroup({ groups: [combo, solo(3)], active: 1 }, [3], 0);
|
||||
expect(g.groups).toHaveLength(1);
|
||||
expect(g.groups[0]?.root).toEqual(
|
||||
split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3)))
|
||||
);
|
||||
});
|
||||
|
||||
it("activates the target tab and takes a pane out of a combo", () => {
|
||||
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
|
||||
const g = joinGroup({ groups: [solo(0), combo], active: 0 }, [2], 1);
|
||||
|
||||
+61
-2
@@ -553,7 +553,7 @@ export type AuditEntry = {
|
||||
};
|
||||
|
||||
/** Who is using the dashboard: local mode has no login at all. */
|
||||
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "member" };
|
||||
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "developer" };
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
|
||||
/** GETs in flight, by path: concurrent callers of the same read (several
|
||||
@@ -596,7 +596,12 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
if (res.status === 401 && body?.error?.code === "EAUTH") {
|
||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
||||
}
|
||||
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
|
||||
// Server mode: a role refusal reads the same in every toast, translated.
|
||||
const message =
|
||||
body?.error?.code === "EADMIN"
|
||||
? i18n.t("errors:adminOnly")
|
||||
: body?.error?.message || `HTTP ${res.status}`;
|
||||
throw Object.assign(new Error(message), {
|
||||
code: body?.error?.code,
|
||||
status: res.status,
|
||||
// EGITAUTH from a lane git action: which host wants a login.
|
||||
@@ -670,6 +675,14 @@ export const api = {
|
||||
/** POST /api/auth/logout — ends this login. */
|
||||
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
|
||||
},
|
||||
// ───────────────────────── The account's own UI prefs (routes/me.js) ─────────────────────────
|
||||
me: {
|
||||
/** GET /api/me/prefs — this account's prefs (`local` in local mode). */
|
||||
prefs: () => request<MePrefs>("/me/prefs"),
|
||||
/** PUT /api/me/prefs — merges `prefs` (a string per key, null removes it). */
|
||||
savePrefs: (prefs: Record<string, string | null>) =>
|
||||
request<MePrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||
@@ -1253,6 +1266,13 @@ export const api = {
|
||||
/** POST /api/settings/quit — close every workspace terminal and stop the
|
||||
* server until `agenthub start` (or a boot/login with autostart on). */
|
||||
quit: () => request<{ ok: true; killed: number }>("/settings/quit", { method: "POST" }),
|
||||
/** POST /api/settings/uninstall — remove AgentHub from this machine, its
|
||||
* database included, in a detached worker (scripts/uninstall.js). */
|
||||
uninstall: () =>
|
||||
request<{ ok: true; log: string }>("/settings/uninstall", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
}),
|
||||
/** Opt-in secret redaction of stored payloads and the folders whose hooks
|
||||
* are dropped (`server/lib/privacy.js`). */
|
||||
privacy: {
|
||||
@@ -1265,6 +1285,19 @@ export const api = {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
},
|
||||
/** The daily database backup (`server/lib/db-backup.js`). */
|
||||
backup: {
|
||||
/** GET /api/settings/backup */
|
||||
get: () => request<BackupState>("/settings/backup"),
|
||||
/** PUT /api/settings/backup — any subset; an empty `dir` turns it off */
|
||||
save: (data: { dir?: string; hour?: number; keep?: number }) =>
|
||||
request<BackupState & { ok: boolean }>("/settings/backup", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
/** POST /api/settings/backup/run — starts one; poll `get` while `running` */
|
||||
run: () => request<BackupState & { ok: boolean }>("/settings/backup/run", { method: "POST" }),
|
||||
},
|
||||
/** 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: {
|
||||
@@ -4279,6 +4312,12 @@ export interface RunStartArgs {
|
||||
|
||||
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
||||
* name (`agenthub-lane-<laneId>`). */
|
||||
/** One account's own UI preferences (server/routes/me.js): known keys → string values. */
|
||||
export interface MePrefs {
|
||||
prefs: Record<string, string>;
|
||||
onboardedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RunHandle {
|
||||
id: string;
|
||||
laneId: number | null;
|
||||
@@ -4510,6 +4549,26 @@ export interface PrivacySettings {
|
||||
env_owned: { redact_secrets: boolean; ignore_cwd: boolean };
|
||||
}
|
||||
|
||||
/** GET /api/settings/backup. `files` are AgentHub's own backups in `dir`,
|
||||
* newest first; `last` is this server process's last run. */
|
||||
export interface BackupState {
|
||||
dir: string;
|
||||
hour: number;
|
||||
keep: number;
|
||||
running: boolean;
|
||||
last: {
|
||||
at: string;
|
||||
ok: boolean;
|
||||
file?: string;
|
||||
size?: number;
|
||||
ms?: number;
|
||||
error?: string;
|
||||
} | null;
|
||||
files: { name: string; size: number; mtime: string }[];
|
||||
dir_error: string | null;
|
||||
env_owned: { dir: boolean; hour: boolean; keep: boolean };
|
||||
}
|
||||
|
||||
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
|
||||
export interface PushTokenStatus {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @file chatUi.ts — the viewer's Chat UI choice: whether a Claude/Codex pane
|
||||
* opens as a chat (`PaneChat`, like Claude Desktop) instead of its terminal.
|
||||
* Kept by the server per ACCOUNT (`GET/PUT /api/me/prefs`, key
|
||||
* `agenthub.chatUi` = "1"; local mode is one account), so it follows a user to
|
||||
* any browser and differs between users of one dashboard. Read once per page
|
||||
* load and shared through `useChatUi`; `saveChatUi` updates every reader at
|
||||
* once and puts the old value back if the save fails.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { api } from "./api";
|
||||
|
||||
export const CHAT_UI_KEY = "agenthub.chatUi";
|
||||
|
||||
let chatUi = false;
|
||||
let loading: Promise<void> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = (next: boolean) => {
|
||||
chatUi = next;
|
||||
listeners.forEach((fn) => fn());
|
||||
};
|
||||
|
||||
function load() {
|
||||
// A failed read (logged out, offline) must never take a page down with it.
|
||||
loading ??= Promise.resolve()
|
||||
.then(() => api.me.prefs())
|
||||
.then(
|
||||
(r) => emit(r.prefs[CHAT_UI_KEY] === "1"),
|
||||
() => {
|
||||
loading = null; // the next reader asks again
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatUi(): boolean {
|
||||
useEffect(load, []);
|
||||
return useSyncExternalStore(
|
||||
(fn) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
() => chatUi
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveChatUi(on: boolean): Promise<void> {
|
||||
const before = chatUi;
|
||||
emit(on);
|
||||
try {
|
||||
const r = await api.me.savePrefs({ [CHAT_UI_KEY]: on ? "1" : null });
|
||||
emit(r.prefs[CHAT_UI_KEY] === "1");
|
||||
} catch (err) {
|
||||
emit(before);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tests: forget what was read. */
|
||||
export function __resetChatUi() {
|
||||
chatUi = false;
|
||||
loading = null;
|
||||
}
|
||||
@@ -552,3 +552,11 @@ export function pathBasename(p: string | null | undefined): string | null {
|
||||
const idx = trimmed.lastIndexOf("/");
|
||||
return idx === -1 ? trimmed : trimmed.slice(idx + 1) || trimmed;
|
||||
}
|
||||
|
||||
/** 1536 → "1.5 KB"; binary units, one decimal from KB up. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
@@ -101,9 +101,28 @@ function focusOf(layout: PaneLayout): number | null {
|
||||
return layout.focus !== null && shown.includes(layout.focus) ? layout.focus : (shown[0] ?? null);
|
||||
}
|
||||
|
||||
/** The pane a new one splits, and on which side, filling a 2×2 grid in
|
||||
* order: right of a lone pane, under the right one, under the left one. The
|
||||
* biggest pane by the tree's SHAPE (dragged ratios ignored; a tie goes to the
|
||||
* later pane) is the gap — so a three-pane tab of any shape fills its one
|
||||
* missing quarter. A tall pane splits down, any other to the right. */
|
||||
function nextSpot(root: PaneNode): [number, Edge] {
|
||||
const even = (n: PaneNode): PaneNode =>
|
||||
n.type === "pane" ? n : { ...n, ratio: 0.5, a: even(n.a), b: even(n.b) };
|
||||
const best = layoutRects(even(root)).panes.reduce((m, r) => (r.w * r.h >= m.w * m.h ? r : m));
|
||||
return [best.slot, best.h > best.w ? "bottom" : "right"];
|
||||
}
|
||||
|
||||
/** Adds `slot` to the layout at the grid's next place (`nextSpot`), focused. */
|
||||
function placeInLayout(layout: PaneLayout, slot: number): PaneLayout {
|
||||
if (!layout.root) return { root: pane(slot), focus: slot };
|
||||
const [target, edge] = nextSpot(layout.root);
|
||||
return { root: splitLeaf(layout.root, target, slot, edge), focus: slot };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows `slot`: focuses it when it is already on screen; otherwise `split`
|
||||
* opens it to the right of the focused pane (while there is room) and
|
||||
* adds it at the grid's next place (`nextSpot`, while there is room) and
|
||||
* `replace` puts it in the focused pane.
|
||||
*/
|
||||
export function openInLayout(
|
||||
@@ -114,12 +133,9 @@ export function openInLayout(
|
||||
if (!layout.root) return { root: pane(slot), focus: slot };
|
||||
const shown = leaves(layout.root);
|
||||
if (shown.includes(slot)) return { ...layout, focus: slot };
|
||||
if (mode === "split" && shown.length < MAX_PANES) return placeInLayout(layout, slot);
|
||||
const target = focusOf(layout) as number;
|
||||
const root =
|
||||
mode === "split" && shown.length < MAX_PANES
|
||||
? splitLeaf(layout.root, target, slot, "right")
|
||||
: mapLeaves(layout.root, (s) => pane(s === target ? slot : s));
|
||||
return { root, focus: slot };
|
||||
return { root: mapLeaves(layout.root, (s) => pane(s === target ? slot : s)), focus: slot };
|
||||
}
|
||||
|
||||
/** Closes `slot`'s pane (its session keeps running as a tab). */
|
||||
@@ -289,7 +305,7 @@ export function updateActive(g: PaneGroups, fn: (l: PaneLayout) => PaneLayout):
|
||||
/**
|
||||
* Shows `slot`. Its own tab is activated (and the slot focused there); a
|
||||
* session in no tab opens a new one after the active tab. `split` (Ctrl/Cmd+
|
||||
* click) brings it into the active tab beside the focused pane instead —
|
||||
* click) brings it into the active tab at the grid's next place instead —
|
||||
* unless that tab is already full, when it falls back to the plain open.
|
||||
*/
|
||||
export function openInGroups(g: PaneGroups, slot: number, split: boolean): PaneGroups {
|
||||
@@ -327,18 +343,6 @@ export function detachSlot(g: PaneGroups, slot: number, at: number): PaneGroups
|
||||
return compact(groups, at);
|
||||
}
|
||||
|
||||
/** Where a pane joining a tab lands: right of a lone pane, then under the
|
||||
* right pane, then under the left one (2×2 at four). */
|
||||
function joinLayout(layout: PaneLayout, slot: number): PaneLayout {
|
||||
const shown = leaves(layout.root);
|
||||
if (!layout.root) return solo(slot);
|
||||
const [target, edge]: [number, Edge] =
|
||||
shown.length === 1
|
||||
? [shown[0] as number, "right"]
|
||||
: [(shown.length === 2 ? shown[1] : shown[0]) as number, "bottom"];
|
||||
return { root: splitLeaf(layout.root, target, slot, edge), focus: slot };
|
||||
}
|
||||
|
||||
/** Moves `slots` (a dropped tab, or one pane of a combo) out of their tabs
|
||||
* into the tab at `to`, which becomes active. Same object back when nothing
|
||||
* would move or the tab would pass `MAX_PANES`. */
|
||||
@@ -351,7 +355,7 @@ export function joinGroup(g: PaneGroups, slots: number[], to: number): PaneGroup
|
||||
});
|
||||
if (!moving.length || leaves(dst.root).length + moving.length > MAX_PANES) return g;
|
||||
const groups = g.groups.map((l, i) =>
|
||||
i === to ? moving.reduce(joinLayout, l) : moving.reduce(closeInLayout, l)
|
||||
i === to ? moving.reduce(placeInLayout, l) : moving.reduce(closeInLayout, l)
|
||||
);
|
||||
return compact(groups, to);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
* bracketed, so `claude` takes it as one block in its composer — and never an
|
||||
* Enter: the user reads it and sends it. Only a pane on SCREEN takes it (one
|
||||
* hidden behind a full-width Changes panel or a replay would swallow it
|
||||
* unseen); the caller says where it went, or that it went nowhere.
|
||||
* unseen); the caller says where it went, or that it went nowhere. The one
|
||||
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
|
||||
* what the user typed there, so it pastes AND presses Enter.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
interface Pane {
|
||||
paste: (text: string) => void;
|
||||
paste: (text: string, submit?: boolean) => void;
|
||||
visible: () => boolean;
|
||||
}
|
||||
const panes = new Map<string, Pane>();
|
||||
@@ -22,7 +24,7 @@ let lastFocused: string | null = null;
|
||||
* unregister. */
|
||||
export function registerPane(
|
||||
runId: string,
|
||||
paste: (text: string) => void,
|
||||
paste: (text: string, submit?: boolean) => void,
|
||||
visible: () => boolean = () => true
|
||||
): () => void {
|
||||
const entry = { paste, visible };
|
||||
@@ -52,6 +54,15 @@ export function pasteToRun(runId: string, text: string): boolean {
|
||||
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 {
|
||||
const p = panes.get(runId);
|
||||
if (!p?.visible()) return false;
|
||||
p.paste(text, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
|
||||
* last when it is — and returns its run id; null when none is on screen. */
|
||||
export function pasteToLane(laneId: number, text: string): string | null {
|
||||
|
||||
@@ -32,6 +32,8 @@ export const SETTINGS_INDEX: {
|
||||
descKey?: string;
|
||||
keywords?: string[];
|
||||
to?: string;
|
||||
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
|
||||
adminOnly?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
section: "appearance",
|
||||
@@ -114,18 +116,21 @@ export const SETTINGS_INDEX: {
|
||||
{
|
||||
section: "system",
|
||||
titleKey: "autostart.label",
|
||||
adminOnly: true,
|
||||
keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"],
|
||||
to: "#autostart",
|
||||
},
|
||||
{
|
||||
section: "system",
|
||||
titleKey: "quit.label",
|
||||
adminOnly: true,
|
||||
keywords: ["quit", "stop", "kill", "shutdown", "exit", "background"],
|
||||
to: "#autostart",
|
||||
},
|
||||
{
|
||||
section: "system",
|
||||
titleKey: "tray.label",
|
||||
adminOnly: true,
|
||||
keywords: ["tray", "icon", "taskbar", "menu bar", "status area", "notification area"],
|
||||
to: "#autostart",
|
||||
},
|
||||
@@ -154,6 +159,7 @@ export const SETTINGS_INDEX: {
|
||||
{
|
||||
section: "reports",
|
||||
titleKey: "reports.title",
|
||||
adminOnly: true,
|
||||
descKey: "reports.description",
|
||||
keywords: ["schedule", "report", "email", "summary", "html", "json", "export"],
|
||||
},
|
||||
@@ -189,6 +195,7 @@ export const SETTINGS_INDEX: {
|
||||
{
|
||||
section: "notifications",
|
||||
titleKey: "webhooks.title",
|
||||
adminOnly: true,
|
||||
keywords: ["slack", "discord", "telegram", "channels"],
|
||||
to: "#alerts",
|
||||
},
|
||||
@@ -279,7 +286,7 @@ export const SETTINGS_INDEX: {
|
||||
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
|
||||
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
|
||||
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
|
||||
{ section: "git", titleKey: "gitCreds.title", keywords: ["login", "token"] },
|
||||
{ section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] },
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "worktrees.showUntracked",
|
||||
|
||||
@@ -1683,6 +1683,9 @@ export interface AlertEvent {
|
||||
/** Agent the alert pertains to; null when not agent-specific. FK into
|
||||
* `Agent.id` when set. */
|
||||
agent_id: string | null;
|
||||
/** Server mode, an analyst's feed only: whether this alert is one of their
|
||||
* own sessions' and so theirs to acknowledge. Absent = acknowledgeable. */
|
||||
can_ack?: boolean;
|
||||
/** Human-readable description of what triggered the alert. Example:
|
||||
* `"Session idle for 32 minutes"`. */
|
||||
message: string;
|
||||
|
||||
@@ -94,8 +94,10 @@ import { SessionOverview } from "../components/SessionOverview";
|
||||
import { ConversationView } from "../components/conversation/ConversationView";
|
||||
import { SessionStatusBadge, REASON_ICONS } from "../components/StatusBadge";
|
||||
import { SessionToolBadge } from "../components/SessionToolBadge";
|
||||
import { SessionResumeButton } from "../components/lanes/SessionResumeButton";
|
||||
import { SessionResumeButton, owningLane } from "../components/lanes/SessionResumeButton";
|
||||
import { SnapshotButton } from "../components/SnapshotButton";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { EventTimeline } from "../components/EventTimeline";
|
||||
import { EventReplay, loadReplayEvents } from "../components/EventReplay";
|
||||
import { CopyButton } from "../components/event-views/primitives";
|
||||
@@ -151,6 +153,9 @@ export function SessionDetail() {
|
||||
const { t } = useTranslation("sessions");
|
||||
const { t: wfT } = useTranslation("workflows");
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
// Server mode: an analyst reads every session but acts only on their own.
|
||||
const { canEditLane } = usePermissions();
|
||||
const { lanes } = useLanes();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [workflows, setWorkflows] = useState<WorkflowRun[]>([]);
|
||||
const [events, setEvents] = useState<DashboardEvent[]>([]);
|
||||
@@ -666,16 +671,27 @@ export function SessionDetail() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const href = workspaceHref(session, runs);
|
||||
return href ? (
|
||||
<Link to={href} className="btn-ghost">
|
||||
<ExternalLink className="w-4 h-4" /> {t("openInWorkspace")}
|
||||
</Link>
|
||||
) : null;
|
||||
})()}
|
||||
<SessionResumeButton session={session} />
|
||||
<SnapshotButton sessionId={session.id} />
|
||||
{/* The owning workspace: its stamp, else the one whose folder holds it
|
||||
(a session a pane holds but no hook stamped yet). */}
|
||||
{canEditLane(
|
||||
lanes.find((l) => l.id === session.lane_id) ??
|
||||
(session.lane_id == null && session.cwd
|
||||
? owningLane(session.cwd, session.tool || "claude", lanes)
|
||||
: null)
|
||||
) && (
|
||||
<>
|
||||
{(() => {
|
||||
const href = workspaceHref(session, runs);
|
||||
return href ? (
|
||||
<Link to={href} className="btn-ghost">
|
||||
<ExternalLink className="w-4 h-4" /> {t("openInWorkspace")}
|
||||
</Link>
|
||||
) : null;
|
||||
})()}
|
||||
<SessionResumeButton session={session} />
|
||||
<SnapshotButton sessionId={session.id} />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -85,7 +85,7 @@ import {
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { fmt, fmtCost } from "../lib/format";
|
||||
import { fmt, fmtCost, formatBytes } from "../lib/format";
|
||||
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
|
||||
import {
|
||||
defaultNotifPrefs,
|
||||
@@ -102,7 +102,9 @@ import { ImportHistory } from "../components/ImportHistory";
|
||||
import { RemoteSources } from "../components/RemoteSources";
|
||||
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
|
||||
import { PrivacySettings } from "../components/settings/PrivacySettings";
|
||||
import { BackupSettings } from "../components/settings/BackupSettings";
|
||||
import { AutostartSettings } from "../components/settings/AutostartSettings";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { AlertsNotifications } from "../components/AlertsNotifications";
|
||||
import {
|
||||
@@ -142,13 +144,6 @@ const NOTIF_TYPES: [NotifType, string][] = [
|
||||
|
||||
type SystemInfo = Awaited<ReturnType<typeof api.settings.info>>;
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
@@ -201,6 +196,7 @@ export function Settings({
|
||||
section?: "data" | "about" | "notifications";
|
||||
} = {}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin } = usePermissions();
|
||||
// Only Data and About show server info; General and Notifications never read it.
|
||||
const needsInfo = section === "data" || section === "about";
|
||||
const [loading, setLoading] = useState(needsInfo);
|
||||
@@ -966,6 +962,7 @@ export function Settings({
|
||||
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
|
||||
<PushIngestSettings />
|
||||
<PrivacySettings />
|
||||
<BackupSettings />
|
||||
|
||||
{/* ─── SESSION CLEANUP ─── */}
|
||||
<section id="cleanup" className="card p-5 space-y-4 scroll-mt-24">
|
||||
@@ -1120,7 +1117,8 @@ export function Settings({
|
||||
|
||||
{section === "about" && (
|
||||
<div id="about" className="space-y-6 scroll-mt-24">
|
||||
<AutostartSettings />
|
||||
{/* Start at login, the icon, Quit and Uninstall act on the host: an admin's. */}
|
||||
{isAdmin && <AutostartSettings />}
|
||||
{sysInfo ? (
|
||||
<SystemDetails info={sysInfo} />
|
||||
) : (
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
* (`snapshots`). A page's content is capped at a readable width. A tool's own settings live under `agents/<id>` — Claude Code and
|
||||
* Codex have their own pages, every other installed, enabled catalog tool the
|
||||
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
|
||||
* disabled. A workspace's own settings live on its home, not here.
|
||||
* disabled. Those pages are not in the nav: they are tabs on AI tools (the nav
|
||||
* marks AI tools current, the search shows their matches under it), and a
|
||||
* row in the AI tools list opens its tool's tab. A workspace's own settings live on its home, not here.
|
||||
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -65,7 +67,8 @@ import {
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
|
||||
const GROUPS = ["general", "agents", "data"] as const;
|
||||
type Group = (typeof GROUPS)[number];
|
||||
@@ -77,8 +80,13 @@ interface Section {
|
||||
description: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
render: () => ReactNode;
|
||||
/** A tool's own page: a tab on AI tools, not a nav item. */
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
/** The nav item a section sits under: a tool's page is a tab on AI tools. */
|
||||
const navId = (id: string) => (id.startsWith("agents/") ? "agents" : id);
|
||||
|
||||
/** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
|
||||
const LEGACY_TOOL: Record<string, string> = {
|
||||
dashboard: "appearance",
|
||||
@@ -131,8 +139,8 @@ const MOVED: Record<string, string> = {
|
||||
worktrees: "git#worktrees",
|
||||
};
|
||||
|
||||
/** Sections whose API a server-mode member may not reach (STAFF_ONLY_PREFIXES in server/lib/auth.js). */
|
||||
const STAFF_SECTIONS = new Set(["data", "agents/claude-code", "agents/codex"]);
|
||||
/** Sections that only configure the host (ADMIN_WRITE_PREFIXES in server/lib/auth.js). */
|
||||
const ADMIN_SECTIONS = new Set(["data", "ai-proxy", "pricing"]);
|
||||
|
||||
/** An unknown section: say so, then show Appearance. */
|
||||
function UnknownSection({ name }: { name: string }) {
|
||||
@@ -215,8 +223,7 @@ export function SettingsHub() {
|
||||
};
|
||||
}, [location.key, location.hash]);
|
||||
|
||||
const auth = useAuth();
|
||||
const isMember = auth.state.mode === "server" && auth.state.user?.role === "member";
|
||||
const { isAdmin } = usePermissions();
|
||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||
const s = (key: string) => ({
|
||||
title: t(`settingsNav.section.${key}.title`),
|
||||
@@ -249,13 +256,14 @@ export function SettingsHub() {
|
||||
group: "agents",
|
||||
Icon: Bot,
|
||||
...s("agents"),
|
||||
render: () => <AiToolsSettings />,
|
||||
render: () => <AiToolsSettings settingsHref={toolHref} />,
|
||||
},
|
||||
...(enabled("claude")
|
||||
? [
|
||||
{
|
||||
id: "agents/claude-code",
|
||||
group: "agents" as const,
|
||||
tool: "claude",
|
||||
Icon: toolNavIcon("claude", "Claude Code"),
|
||||
...s("claudeCode"),
|
||||
render: () => <CcConfig />,
|
||||
@@ -267,6 +275,7 @@ export function SettingsHub() {
|
||||
{
|
||||
id: "agents/codex",
|
||||
group: "agents" as const,
|
||||
tool: "codex",
|
||||
Icon: toolNavIcon("codex", "Codex"),
|
||||
...s("codex"),
|
||||
render: () => <CodexConfig />,
|
||||
@@ -280,6 +289,7 @@ export function SettingsHub() {
|
||||
.map((x) => ({
|
||||
id: `agents/${x.id}`,
|
||||
group: "agents" as const,
|
||||
tool: x.id,
|
||||
Icon: toolNavIcon(x.id, x.label),
|
||||
title: x.label,
|
||||
description: t("settingsNav.section.tool.description", { tool: x.label }),
|
||||
@@ -335,8 +345,8 @@ export function SettingsHub() {
|
||||
<div id="snapshots" className="scroll-mt-24">
|
||||
<SnapshotSettings />
|
||||
</div>
|
||||
{/* Scheduled reports read every user's sessions: staff only. */}
|
||||
{!isMember && <ReportSettings />}
|
||||
{/* Scheduled reports read every user's sessions: an admin's. */}
|
||||
{isAdmin && <ReportSettings />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -347,8 +357,8 @@ export function SettingsHub() {
|
||||
...s("git"),
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
{/* Git logins are the host's own: staff only. */}
|
||||
{!isMember && (
|
||||
{/* Git logins are the host's own: an admin's. */}
|
||||
{isAdmin && (
|
||||
<section id="git-credentials" className="scroll-mt-24">
|
||||
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
|
||||
<GitCredentialsConfig />
|
||||
@@ -361,18 +371,27 @@ export function SettingsHub() {
|
||||
),
|
||||
},
|
||||
];
|
||||
// Server mode: a member cannot open the host's own configuration or the
|
||||
// all-users views (the server answers 403 EADMIN), so those pages are hidden.
|
||||
const sections = isMember ? allSections.filter((x) => !STAFF_SECTIONS.has(x.id)) : allSections;
|
||||
// Server mode: only an admin changes the host's own configuration (the
|
||||
// server answers 403 EADMIN), so its pages — every tool's too — are hidden.
|
||||
const sections = isAdmin
|
||||
? allSections
|
||||
: allSections.filter((x) => !x.tool && !ADMIN_SECTIONS.has(x.id));
|
||||
const navSections = sections.filter((x) => !x.tool);
|
||||
const toolTabs = sections.filter((x) => x.tool);
|
||||
function toolHref(id: string) {
|
||||
const page = toolTabs.find((x) => x.tool === id);
|
||||
return page ? `/settings/${page.id}` : undefined;
|
||||
}
|
||||
|
||||
const entries: SettingsEntry[] = sections.flatMap((sec) => [
|
||||
{ section: sec.id, title: sec.title, description: sec.description, sectionTitle: true },
|
||||
...SETTINGS_INDEX.filter(
|
||||
(e) =>
|
||||
e.section === sec.id ||
|
||||
(e.section === "agents/*" &&
|
||||
sec.id.startsWith("agents/") &&
|
||||
!SETTINGS_INDEX.some((x) => x.section === sec.id))
|
||||
(isAdmin || !e.adminOnly) &&
|
||||
(e.section === sec.id ||
|
||||
(e.section === "agents/*" &&
|
||||
sec.id.startsWith("agents/") &&
|
||||
!SETTINGS_INDEX.some((x) => x.section === sec.id)))
|
||||
).map((e) => ({
|
||||
section: sec.id,
|
||||
title: t(e.titleKey),
|
||||
@@ -410,8 +429,11 @@ export function SettingsHub() {
|
||||
}
|
||||
|
||||
const ranked = query.trim() ? rankSettings(query, entries) : null;
|
||||
const shown = ranked ? sections.filter((x) => ranked.some((r) => r.section === x.id)) : sections;
|
||||
const first = ranked?.[0]?.section ?? null;
|
||||
const shown = ranked
|
||||
? navSections.filter((x) => ranked.some((r) => navId(r.section) === x.id))
|
||||
: navSections;
|
||||
const first = ranked?.[0] ? navId(ranked[0].section) : null;
|
||||
const navActive = navId(active.id);
|
||||
const results = ranked ? rankSettingsEntries(query, entries) : [];
|
||||
const titleOf = (id: string) => sections.find((x) => x.id === id)?.title ?? id;
|
||||
const hrefOf = (e: SettingsEntry) => `/settings/${e.section}${e.to ?? ""}`;
|
||||
@@ -494,10 +516,10 @@ export function SettingsHub() {
|
||||
<Link
|
||||
key={id}
|
||||
to={`/settings/${id}`}
|
||||
aria-current={id === active.id ? "page" : undefined}
|
||||
aria-current={id === navActive ? "page" : undefined}
|
||||
data-first-match={id === first ? "true" : undefined}
|
||||
className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
|
||||
id === active.id
|
||||
id === navActive
|
||||
? "bg-surface-3 font-medium text-fg-primary before:absolute before:inset-y-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-accent"
|
||||
: id === first
|
||||
? "bg-surface-3 text-fg-primary"
|
||||
@@ -505,7 +527,7 @@ export function SettingsHub() {
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${id === active.id ? "text-accent" : "text-fg-muted"}`}
|
||||
className={`h-4 w-4 shrink-0 ${id === navActive ? "text-accent" : "text-fg-muted"}`}
|
||||
/>
|
||||
<span className="truncate">{title}</span>
|
||||
</Link>
|
||||
@@ -521,12 +543,12 @@ export function SettingsHub() {
|
||||
<div className="mb-4 md:hidden">
|
||||
<select
|
||||
aria-label={t("settingsNav.label")}
|
||||
value={active.id}
|
||||
value={navActive}
|
||||
onChange={(e) => go(e.target.value)}
|
||||
className="select w-full py-1.5"
|
||||
>
|
||||
{GROUPS.map((g) => {
|
||||
const own = sections.filter((x) => x.group === g);
|
||||
const own = navSections.filter((x) => x.group === g);
|
||||
return own.length === 0 ? null : (
|
||||
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
|
||||
{own.map((x) => (
|
||||
@@ -548,7 +570,21 @@ export function SettingsHub() {
|
||||
<p className="mt-0.5 text-sm text-fg-muted">{active.description}</p>
|
||||
</div>
|
||||
</header>
|
||||
{active.render()}
|
||||
{navActive === "agents" && toolTabs.length > 0 ? (
|
||||
<PageTabs
|
||||
ariaLabel={t("settingsNav.toolTabs")}
|
||||
activeId={active.id}
|
||||
onSelect={go}
|
||||
tabs={[
|
||||
{ id: "agents", label: t("settingsNav.toolsTab"), Icon: Bot },
|
||||
...toolTabs.map(({ id, title, Icon }) => ({ id, label: title, Icon })),
|
||||
]}
|
||||
>
|
||||
<div className="pt-4">{active.render()}</div>
|
||||
</PageTabs>
|
||||
) : (
|
||||
active.render()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ import { ConfirmModal } from "../components/ConfirmModal";
|
||||
import { SettingsSection } from "../components/settings/SettingsRow";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "member"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
|
||||
type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace";
|
||||
type Dialog = { kind: DialogKind; user?: ServerUserUsage };
|
||||
@@ -61,11 +61,11 @@ export function Users() {
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
|
||||
useEffect(() => {
|
||||
if (me && me.role !== "member") void load();
|
||||
if (me && me.role !== "developer") void load();
|
||||
}, [me, load]);
|
||||
|
||||
// Local mode, or a member: there is no Users page for them.
|
||||
if (!me || me.role === "member") return <Navigate to="/" replace />;
|
||||
// Local mode, or a developer: there is no Users page for them.
|
||||
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
@@ -265,7 +265,7 @@ function AccountDialog({
|
||||
const name = user?.username ?? "";
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<Role>(user?.role ?? "member");
|
||||
const [role, setRole] = useState<Role>(user?.role ?? "developer");
|
||||
const [laneId, setLaneId] = useState<number | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||
const [current, setCurrent] = useState("");
|
||||
|
||||
@@ -69,7 +69,7 @@ import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { toast } from "../lib/toast";
|
||||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useWorkLanes } from "../hooks/useLanes";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import {
|
||||
ConsoleTabs,
|
||||
@@ -168,7 +168,7 @@ function slotOf(runId: string | null | undefined): number {
|
||||
* lane. The focused pane of the tab on screen is the URL's `?run=`: a plain
|
||||
* open (a tab, a sidebar row) shows the tab holding that run — a session in
|
||||
* no tab gets one of its own — and a split open (Ctrl/Cmd+click) brings it
|
||||
* into the tab on screen, to the right of the focused pane. Every live run
|
||||
* into the tab on screen, at the 2×2 grid's next place. Every live run
|
||||
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
|
||||
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
|
||||
* run ends closes; the last tab gone leaves for the home. */
|
||||
@@ -189,7 +189,7 @@ function ConsoleArea({
|
||||
activeRuns: RunListResponse | null;
|
||||
onHasActiveRunChange: (val: boolean) => void;
|
||||
/** The opened run (`?run=`): its pane is the focused one. `split` opens it
|
||||
* beside the focused pane instead of in it; `focusOnly` (the URL following a
|
||||
* in a split (the grid's next place) instead; `focusOnly` (the URL following a
|
||||
* focus change made here) never opens anything. */
|
||||
attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null;
|
||||
/** Back to the workspace home, where a new session is started. */
|
||||
@@ -519,7 +519,7 @@ export function Workspace() {
|
||||
// Lane state: the list is the shared store the sidebar reads too; the
|
||||
// selection is the URL's `?lane=` (the sidebar sets it, so does a
|
||||
// notification click), falling back to the last one shown, then the first.
|
||||
const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useLanes();
|
||||
const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useWorkLanes();
|
||||
const laneParam = searchParams.get("lane");
|
||||
// `?run=` — a live session picked in the sidebar; the location key makes a
|
||||
// second pick of the same session a new request.
|
||||
@@ -529,7 +529,7 @@ export function Workspace() {
|
||||
// conversation, with a prompt box whose message resumes it (SessionReplay).
|
||||
const sessionParam = runParam ? null : searchParams.get("session");
|
||||
const navigate = useNavigate();
|
||||
// A Ctrl/Cmd+click (tab or sidebar) opens the run beside the focused pane;
|
||||
// A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
|
||||
// it rides on the navigation's state, never in the URL.
|
||||
const navState = location.state as { split?: unknown; focusOnly?: unknown } | null;
|
||||
const splitOpen = navState?.split === true;
|
||||
|
||||
@@ -51,10 +51,12 @@ let mockCost: { total_cost: number; breakdown: unknown[] } = { total_cost: 0, br
|
||||
// The header's Resume reads lanes and runs; this file is about the agent tree.
|
||||
vi.mock("../../components/lanes/SessionResumeButton", () => ({
|
||||
SessionResumeButton: () => null,
|
||||
owningLane: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: { list: vi.fn(() => Promise.resolve({ lanes: [] })) },
|
||||
sessions: {
|
||||
get: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
@@ -104,6 +106,7 @@ vi.mock("../../lib/api", () => ({
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
onConnection: vi.fn(() => () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -65,6 +65,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
env_owned: { redact_secrets: false, ignore_cwd: false },
|
||||
})),
|
||||
},
|
||||
backup: {
|
||||
get: vi.fn(async () => ({
|
||||
dir: "",
|
||||
hour: 2,
|
||||
keep: 7,
|
||||
running: false,
|
||||
last: null,
|
||||
files: [],
|
||||
dir_error: null,
|
||||
env_owned: { dir: false, hour: false, keep: false },
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* @description The Settings page shell: a path picks its section, `/settings`
|
||||
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
|
||||
* unknown section falls back to Appearance, a disabled tool has no page while an
|
||||
* installed, enabled catalog tool gets one, the mobile
|
||||
* installed, enabled catalog tool gets one — as a tab on AI tools, not a nav
|
||||
* item —, the mobile
|
||||
* select navigates, and the search filters the nav, opens the first match on
|
||||
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
|
||||
* text), its result list deep-links a row's sub-tab, an anchor in the URL is
|
||||
@@ -159,13 +160,27 @@ describe("SettingsHub", () => {
|
||||
view.tools[1] = tool("codex", { launcher: "codex", enabled: false });
|
||||
renderAt("/settings/agents/gemini");
|
||||
expect(await screen.findByTestId("tool-gemini")).toBeInTheDocument();
|
||||
const tabs = within(screen.getByRole("tablist", { name: "AI tool settings" }))
|
||||
.getAllByRole("tab")
|
||||
.map((a) => a.textContent);
|
||||
expect(tabs).toEqual(["All tools", "Claude Code", "Gemini"]);
|
||||
});
|
||||
|
||||
it("shows tool pages as tabs on AI tools, not as nav items", async () => {
|
||||
renderAt("/settings/agents/codex");
|
||||
expect(await screen.findByTestId("codex-config")).toBeInTheDocument();
|
||||
const links = within(nav())
|
||||
.getAllByRole("link")
|
||||
.map((a) => a.textContent);
|
||||
expect(links).toContain("Claude Code");
|
||||
expect(links).not.toContain("Codex");
|
||||
expect(links).not.toContain("Qwen");
|
||||
expect(links).not.toContain("Aider");
|
||||
expect(within(nav()).getByRole("link", { name: "AI tools" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page"
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "Codex" })).toHaveAttribute("aria-selected", "true");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "All tools" }));
|
||||
expect(screen.getByTestId("where").textContent).toBe("/settings/agents");
|
||||
expect(screen.getByTestId("ai-tools")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates from the mobile select", async () => {
|
||||
|
||||
@@ -66,7 +66,7 @@ function renderPage() {
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(users)) fn.mockReset();
|
||||
users.list.mockResolvedValue({
|
||||
users: [row(1, "boss", "admin"), row(2, "mia", "member")],
|
||||
users: [row(1, "boss", "admin"), row(2, "mia", "developer")],
|
||||
});
|
||||
users.audit.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
@@ -76,7 +76,7 @@ describe("Users page", () => {
|
||||
authState = { mode: "local" };
|
||||
renderPage();
|
||||
expect(await screen.findByTestId("home")).toBeTruthy();
|
||||
authState = { mode: "server", user: { id: 2, username: "mia", role: "member" } };
|
||||
authState = { mode: "server", user: { id: 2, username: "mia", role: "developer" } };
|
||||
renderPage();
|
||||
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
|
||||
expect(users.list).not.toHaveBeenCalled();
|
||||
@@ -94,7 +94,7 @@ describe("Users page", () => {
|
||||
it("adds a user only with the admin's own password", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.create.mockResolvedValue({
|
||||
user: { id: 9, username: "neo", role: "member", disabled: false },
|
||||
user: { id: 9, username: "neo", role: "developer", disabled: false },
|
||||
});
|
||||
const ue = userEvent.setup();
|
||||
renderPage();
|
||||
@@ -111,7 +111,7 @@ describe("Users page", () => {
|
||||
await ue.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(users.create).toHaveBeenCalledWith(
|
||||
{ username: "neo", password: "neo-pass-123", role: "member" },
|
||||
{ username: "neo", password: "neo-pass-123", role: "developer" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -732,7 +732,7 @@ describe("split panes", () => {
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2", "pane-3"]);
|
||||
await ctrlClick(screen.getByTestId("console-tab-4"));
|
||||
expect(shownPanes()).toEqual(["pane-4"]);
|
||||
expect(screen.getByTestId("console-tab-0-1-2-3")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("console-tab-0-3-1-2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("console-tab-4")).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
|
||||
@@ -7189,6 +7189,35 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Uninstall AgentHub
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Remove AgentHub from this computer: the dashboard, its database and settings, its hooks in Claude Code, Codex and Antigravity, and the plugin. Your conversations, workspace folders and repositories are kept.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
data-testid="uninstall-agenthub"
|
||||
type="button"
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
@@ -7220,7 +7249,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.5.12 (rebuild client to match)
|
||||
UI build v2.5.13 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -9066,6 +9095,205 @@ exit 0
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="backup"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<span
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-database-backup w-4 h-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<ellipse
|
||||
cx="12"
|
||||
cy="5"
|
||||
rx="9"
|
||||
ry="3"
|
||||
/>
|
||||
<path
|
||||
d="M3 12a9 3 0 0 0 5 2.69"
|
||||
/>
|
||||
<path
|
||||
d="M21 9.3V5"
|
||||
/>
|
||||
<path
|
||||
d="M3 5v14a9 3 0 0 0 6.47 2.88"
|
||||
/>
|
||||
<path
|
||||
d="M12 12v4h4"
|
||||
/>
|
||||
<path
|
||||
d="M13 20a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L12 16"
|
||||
/>
|
||||
</svg>
|
||||
Daily backup
|
||||
</span>
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="py-3 space-y-3"
|
||||
>
|
||||
<label
|
||||
class="block text-xs text-fg-muted"
|
||||
>
|
||||
Backup folder
|
||||
<input
|
||||
class="input mt-1 w-full font-mono text-xs"
|
||||
placeholder="/mnt/hdd/agenthub-backups"
|
||||
value=""
|
||||
/>
|
||||
</label>
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
An absolute path to an existing folder the server can write to. Leave it empty to turn backups off. AgentHub only ever deletes its own agenthub-YYYYMMDD-HHMMSS.db files here, oldest first beyond the number kept.
|
||||
</p>
|
||||
<div
|
||||
class="flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<div
|
||||
class="w-28"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Time (server)"
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
title="Time (server)"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
02:00
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
Backups kept
|
||||
<input
|
||||
class="input mt-1 block w-24 text-sm"
|
||||
max="365"
|
||||
min="1"
|
||||
step="1"
|
||||
type="number"
|
||||
value="7"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<button
|
||||
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-database-backup w-4 h-4"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<ellipse
|
||||
cx="12"
|
||||
cy="5"
|
||||
rx="9"
|
||||
ry="3"
|
||||
/>
|
||||
<path
|
||||
d="M3 12a9 3 0 0 0 5 2.69"
|
||||
/>
|
||||
<path
|
||||
d="M21 9.3V5"
|
||||
/>
|
||||
<path
|
||||
d="M3 5v14a9 3 0 0 0 6.47 2.88"
|
||||
/>
|
||||
<path
|
||||
d="M12 12v4h4"
|
||||
/>
|
||||
<path
|
||||
d="M13 20a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L12 16"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
Back up now
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-secondary"
|
||||
>
|
||||
Backups are off.
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
To restore: agenthub stop, delete dashboard.db-wal and dashboard.db-shm, copy a backup over dashboard.db (its path is on the System page), then agenthub start.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="card p-5 space-y-4 scroll-mt-24"
|
||||
id="cleanup"
|
||||
|
||||
@@ -350,6 +350,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
save: r({ ok: true }),
|
||||
},
|
||||
backup: {
|
||||
get: r({
|
||||
dir: "",
|
||||
hour: 2,
|
||||
keep: 7,
|
||||
running: false,
|
||||
last: null,
|
||||
files: [],
|
||||
dir_error: null,
|
||||
env_owned: { dir: false, hour: false, keep: false },
|
||||
}),
|
||||
},
|
||||
exportData: () => "/api/settings/export",
|
||||
cleanup: r({
|
||||
ok: true,
|
||||
|
||||
Reference in New Issue
Block a user