feat(server-s3): one Linux user per account (S3) — experimental
Server mode can now run each account as its own Linux user (ah-<name>) when `agenthub server install-system` has set S3 up. The dashboard reaches a member only through sudo -u / write-as / pane-launch, reads a member's files only through owner- and type-checked opens, and hooks are authenticated by peer uid. Local mode and server mode without S3 are unchanged. EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not yet been verified on a real install with real member accounts, sudo or a real Codex login. Do not offer it to real users yet. - S3 core: provisioning, per-member tmux servers, the proxy key gateway, shared tools in /opt, dropped capabilities for children (P0–P5) - P4: plan and quota per member home - P6: ~/.claude.json seed; opt-in plugin baseline installed as the member; managed-settings spike script (not run) - P7: Codex account pool (`agenthub accounts add --tool codex`) - Roles merged with main's admin / analyst / developer: analysts write only their own rows, under S3 as well - Per-account prefs: one implementation (Chat UI + onboarding) - Security reviews F1–F49 (five rounds), each fix with a regression test: symlink/TOCTOU-safe member reads, no host writes in a member home, credentials and the data dir never served, owner and folder account must agree, pane tokens bound to the pane's uid, bounded sudo/tmux/git calls and member reads (no hang or crash from a member), async route rejections answer 500 instead of exiting - Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box); client 1162/1162; tsc clean; headers check passes. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,8 @@
|
||||
* (`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`;
|
||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||
* Once an account is known (local mode is one), its own UI preferences are
|
||||
* loaded into this browser (`lib/userPrefs.ts`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
type AuthValue = { state: AuthState; logout: () => Promise<void> };
|
||||
|
||||
@@ -114,6 +117,18 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The signed-in account's own preferences (lib/userPrefs.ts): loaded once
|
||||
// per account, so a second login in this browser brings that account's.
|
||||
const account =
|
||||
state?.mode === "local"
|
||||
? "local"
|
||||
: state?.mode === "server" && state.user
|
||||
? `u${state.user.id}`
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (account) void hydrate();
|
||||
}, [account]);
|
||||
|
||||
if (!state) return null;
|
||||
if (state.mode === "server" && !state.user) {
|
||||
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||
|
||||
@@ -83,6 +83,8 @@ import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
|
||||
import { OnboardingTour } from "./onboarding/OnboardingTour";
|
||||
|
||||
const WIDTH_KEY = "agenthub.sidebar.width";
|
||||
/** The default width, and the narrowest the edge drags to. */
|
||||
@@ -172,6 +174,8 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<SagiMount />
|
||||
<OnboardingWizard />
|
||||
<OnboardingTour />
|
||||
{drawer && drawerOpen && (
|
||||
<div
|
||||
aria-hidden
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||
@@ -111,7 +112,14 @@ export function AddLaneModal({
|
||||
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
|
||||
const [pickedTool, setPickedTool] = useState<string | null>(null);
|
||||
const aiTools = useAiTools();
|
||||
const defaultTool = aiTools.defaultId ?? "claude";
|
||||
// The account's own pick (first-run setup), while it is installed and on.
|
||||
const ownTool = readDefaultTool();
|
||||
const defaultTool =
|
||||
(ownTool && aiTools.tools.some((x) => x.id === ownTool && x.installed && x.enabled)
|
||||
? ownTool
|
||||
: null) ??
|
||||
aiTools.defaultId ??
|
||||
"claude";
|
||||
const tool = pickedTool ?? defaultTool;
|
||||
// Installed + enabled, the default first.
|
||||
const toolChoices = aiTools.tools
|
||||
@@ -355,6 +363,7 @@ export function AddLaneModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-tour="add-lane"
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-border px-5 py-3.5">
|
||||
<h3 id={titleId} className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
|
||||
@@ -449,7 +458,7 @@ export function AddLaneModal({
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||||
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CwdAutocomplete
|
||||
inputId="add-lane-repo"
|
||||
@@ -695,6 +704,7 @@ export function AddLaneModal({
|
||||
type="button"
|
||||
onClick={done ? handleCancel : submit}
|
||||
disabled={busy || disabled}
|
||||
data-tour="add-lane-create"
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{done ? t("addLaneSetupClose") : t("add")}
|
||||
|
||||
@@ -173,6 +173,7 @@ export function SessionReplay({
|
||||
rows={2}
|
||||
slash={tool === "claude"}
|
||||
fileCwd={session?.cwd || lane.cwd}
|
||||
lane={lane.id}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
|
||||
|
||||
@@ -85,6 +85,7 @@ export {
|
||||
} from "./startSession";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { readDefaultMode } from "../../lib/sessionDefaults";
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
@@ -173,7 +174,8 @@ function HomeBody({
|
||||
// Claude and Codex always take a model and an effort; a catalog tool a
|
||||
// model only when its file names the flag, an effort never.
|
||||
const hasModel = claude || codex || !!entry?.modelFlag;
|
||||
const [mode, setMode] = useState<PermissionMode>("auto");
|
||||
// The account's own default (first-run setup) — never bypass.
|
||||
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
|
||||
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
|
||||
const [approval, setApproval] = useState<CodexApproval>("on-request");
|
||||
// Per session, never remembered.
|
||||
@@ -529,6 +531,7 @@ function HomeBody({
|
||||
rows={3}
|
||||
slash={claude}
|
||||
fileCwd={lane.cwd}
|
||||
lane={lane.id}
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @file OnboardingTour.tsx
|
||||
* @description The step-by-step spotlight tour after the first-run setup
|
||||
* (docs/plans/onboarding): it dims the page around ONE real control and says
|
||||
* what to do with it — Add workspace → the folder → Add → the prompt box →
|
||||
* the live session → done. The overlay never takes a click (the user works
|
||||
* the real UI); a step moves on when what it asked for has happened (the
|
||||
* dialog opened, the workspace exists, the session started), and steps back
|
||||
* when the user backed out (the dialog closed with nothing made). Targets are
|
||||
* existing `data-testid`s plus three `data-tour` marks in AddLaneModal. The
|
||||
* step reached is `localStorage["agenthub.tour"]`, so it is synced to the
|
||||
* account and survives a reload; `done` ends it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
const KEY = "agenthub.tour";
|
||||
const EVENT = "agenthub-tour";
|
||||
const POLL_MS = 250;
|
||||
|
||||
type StepId = "add" | "folder" | "create" | "prompt" | "session" | "finish";
|
||||
const ORDER: StepId[] = ["add", "folder", "create", "prompt", "session", "finish"];
|
||||
|
||||
const $ = (sel: string) => document.querySelector(sel);
|
||||
/** An element that is on screen (a console collapsed with CSS has no box). */
|
||||
function shown(sel: string): Element | null {
|
||||
const el = $(sel);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 ? el : null;
|
||||
}
|
||||
|
||||
const modalOpen = () => !!$('[data-tour="add-lane"]');
|
||||
const composer = () => shown('[data-testid="workspace-home-composer"]');
|
||||
const sessionLive = () =>
|
||||
!!new URLSearchParams(window.location.search).get("run") && !!shown('[data-testid="pane-grid"]');
|
||||
|
||||
/** Where each step points, and what moves it on (or back). */
|
||||
const STEPS: Record<
|
||||
StepId,
|
||||
{ target: () => Element | null; next?: () => StepId | null; info?: boolean }
|
||||
> = {
|
||||
add: {
|
||||
target: () =>
|
||||
shown('[data-testid="lane-empty"]') || shown('[data-testid="workspace-tree-add"]'),
|
||||
next: () => (modalOpen() ? "folder" : null),
|
||||
},
|
||||
folder: {
|
||||
target: () => $('[data-tour="add-lane-folder"]'),
|
||||
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
|
||||
info: true,
|
||||
},
|
||||
create: {
|
||||
target: () => $('[data-tour="add-lane-create"]'),
|
||||
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
|
||||
},
|
||||
prompt: {
|
||||
target: composer,
|
||||
next: () => (sessionLive() ? "session" : null),
|
||||
},
|
||||
session: { target: () => shown('[data-testid="pane-grid"]'), info: true },
|
||||
finish: { target: () => null, info: true },
|
||||
};
|
||||
|
||||
function read(): StepId | null {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return (ORDER as string[]).includes(v ?? "") ? (v as StepId) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function write(v: StepId | "done") {
|
||||
try {
|
||||
localStorage.setItem(KEY, v);
|
||||
} catch {
|
||||
/* storage blocked: the tour just ends with the page */
|
||||
}
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
|
||||
/** Begin the tour at its first step (the setup's last button). */
|
||||
export const startTour = () => write("add");
|
||||
|
||||
/** Mounted once in the layout; renders nothing unless a tour is running. */
|
||||
export function OnboardingTour() {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [step, setStep] = useState<StepId | null>(read);
|
||||
const [rect, setRect] = useState<DOMRect | null>(null);
|
||||
// The dialog the target sits in, if any: the card goes beside the dialog.
|
||||
const [box, setBox] = useState<DOMRect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setStep(read());
|
||||
window.addEventListener(EVENT, sync);
|
||||
window.addEventListener("storage", sync);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, sync);
|
||||
window.removeEventListener("storage", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!step) return;
|
||||
const tick = () => {
|
||||
const def = STEPS[step];
|
||||
const moved = def.next?.();
|
||||
if (moved && moved !== step) return write(moved);
|
||||
const el = def.target();
|
||||
const r = el ? el.getBoundingClientRect() : null;
|
||||
const d = el?.closest("[role=dialog]")?.getBoundingClientRect() ?? null;
|
||||
setBox((prev) => (prev && d && prev.right === d.right ? prev : d));
|
||||
// Only when it moved: a new DOMRect every tick would re-render forever.
|
||||
setRect((prev) =>
|
||||
prev &&
|
||||
r &&
|
||||
prev.x === r.x &&
|
||||
prev.y === r.y &&
|
||||
prev.width === r.width &&
|
||||
prev.height === r.height
|
||||
? prev
|
||||
: r
|
||||
);
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, POLL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [step, location]);
|
||||
|
||||
if (!step) return null;
|
||||
const n = ORDER.indexOf(step) + 1;
|
||||
const end = () => write("done");
|
||||
const advance = () => {
|
||||
if (step === "folder") return write("create");
|
||||
if (step === "session") return write("finish");
|
||||
end();
|
||||
};
|
||||
const lost = !rect && step !== "finish";
|
||||
|
||||
// Beside a narrow target (a field in a dialog: below it, the card would
|
||||
// cover the rest of the dialog), else under it when there is room, else
|
||||
// above; centred when there is no target (the finish, or the page moved).
|
||||
const W = 320;
|
||||
let style: React.CSSProperties = { left: "50%", top: "50%", transform: "translate(-50%, -50%)" };
|
||||
if (rect) {
|
||||
const right = (box ?? rect).right;
|
||||
const top = Math.min(Math.max(12, rect.top), window.innerHeight - 220);
|
||||
const left = Math.min(Math.max(12, rect.left), window.innerWidth - W - 12);
|
||||
if (rect.width < window.innerWidth / 2 && right + W + 24 < window.innerWidth) {
|
||||
style = { left: right + 16, top };
|
||||
} else if (rect.bottom + 180 < window.innerHeight) {
|
||||
style = { left, top: rect.bottom + 12 };
|
||||
} else {
|
||||
style = { left, top: Math.max(12, rect.top - 12), transform: "translateY(-100%)" };
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[70]" data-testid="onboarding-tour">
|
||||
{rect ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute rounded-lg ring-2 ring-accent transition-all duration-200"
|
||||
style={{
|
||||
left: rect.left - 6,
|
||||
top: rect.top - 6,
|
||||
width: rect.width + 12,
|
||||
height: rect.height + 12,
|
||||
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.55)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
step === "finish" && <div aria-hidden className="absolute inset-0 bg-black/55" />
|
||||
)}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-live="polite"
|
||||
aria-label={t(`tour.${step}.title`)}
|
||||
className="pointer-events-auto absolute w-80 space-y-2 rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||
style={style}
|
||||
>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-fg-muted">
|
||||
{t("tour.progress", { n, total: ORDER.length })}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-fg-primary">{t(`tour.${step}.title`)}</p>
|
||||
<p className="text-xs leading-relaxed text-fg-secondary">{t(`tour.${step}.body`)}</p>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{step !== "finish" && (
|
||||
<button type="button" onClick={end} className="btn-ghost px-0 text-xs">
|
||||
{t("tour.skip")}
|
||||
</button>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{lost && !location.pathname.startsWith("/workspace") && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/workspace")}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("tour.goWorkspaces")}
|
||||
</button>
|
||||
)}
|
||||
{STEPS[step].info && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={advance}
|
||||
data-testid="onboarding-tour-next"
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{step === "finish" ? t("tour.done") : t("tour.next")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* @file OnboardingWizard.tsx
|
||||
* @description The first-run setup, once per ACCOUNT (docs/plans/onboarding;
|
||||
* modelled on Orca's welcome): 1 the default agent — the AI tools detected on
|
||||
* this machine, the rest behind "show more" — and the permission mode a new
|
||||
* session starts in (never bypass: that stays per session); 2 the look —
|
||||
* System / Dark / Light, a theme for each mode, language and terminal font;
|
||||
* 3 the corner pet (`PetSettings`); 4 notifications — permission, sound,
|
||||
* a test. Every choice is the same localStorage write Settings makes, so it
|
||||
* applies at once and is synced to the account (`lib/userPrefs.ts`). Shown
|
||||
* when the account has not been through it and has no workspace yet (one that
|
||||
* has is marked done silently), or when Settings asks for it again. The last
|
||||
* step hands over to the tour (`OnboardingTour`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Check, ChevronLeft, Monitor, Moon, Play, Sun, Zap } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { Switch } from "../Switch";
|
||||
import { SettingsSegmented } from "../settings/SettingsRow";
|
||||
import { PetSettings } from "../settings/PetSettings";
|
||||
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
|
||||
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
|
||||
import {
|
||||
DEFAULT_MODES,
|
||||
readDefaultMode,
|
||||
readDefaultTool,
|
||||
writeDefaultMode,
|
||||
writeDefaultTool,
|
||||
type DefaultMode,
|
||||
} from "../../lib/sessionDefaults";
|
||||
import {
|
||||
TERMINAL_FONTS,
|
||||
readTerminalFont,
|
||||
writeTerminalFont,
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
import {
|
||||
NOTIF_SOUNDS,
|
||||
loadNotifPrefs,
|
||||
saveNotifPrefs,
|
||||
type NotifPrefs,
|
||||
type NotifSound,
|
||||
} from "../../lib/notifPrefs";
|
||||
import { notify, playSound } from "../../lib/notify";
|
||||
import { subscribeToPush } from "../../lib/push";
|
||||
import { startTour } from "./OnboardingTour";
|
||||
|
||||
const STEPS = ["agent", "look", "pet", "notify"] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
/** Mounted once in the layout: decides whether this account sees the setup. */
|
||||
export function OnboardingWizard() {
|
||||
const { loaded, onboardedAt, forced } = useOnboarding();
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!loaded || onboardedAt) return setShow(false);
|
||||
if (forced) return setShow(true);
|
||||
let alive = true;
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
// An account already at work never needed a welcome.
|
||||
if (r.lanes.length) void markOnboarded();
|
||||
else setShow(true);
|
||||
})
|
||||
.catch(() => {
|
||||
/* cannot tell: ask again next load rather than interrupt */
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [loaded, onboardedAt, forced]);
|
||||
return show ? <Wizard /> : null;
|
||||
}
|
||||
|
||||
function Wizard() {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const navigate = useNavigate();
|
||||
const [index, setIndex] = useState(0);
|
||||
const step: Step = STEPS[index] ?? "agent";
|
||||
const last = index === STEPS.length - 1;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef);
|
||||
|
||||
const skip = () => void markOnboarded();
|
||||
const next = () => {
|
||||
if (!last) return setIndex(index + 1);
|
||||
void markOnboarded();
|
||||
startTour();
|
||||
navigate("/workspace");
|
||||
};
|
||||
|
||||
// Ctrl/⌘+Enter continues, like Orca.
|
||||
const nextRef = useRef(next);
|
||||
nextRef.current = next;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
nextRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[65] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="onboarding-title"
|
||||
data-testid="onboarding-wizard"
|
||||
className="flex h-[min(820px,92vh)] w-full max-w-4xl flex-col rounded-2xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
|
||||
>
|
||||
<div className="space-y-5 px-8 pt-7">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src="/favicon.svg" alt="" className="h-7 w-7" />
|
||||
<span className="text-sm font-semibold text-fg-primary">{t("brand")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3" aria-hidden>
|
||||
{STEPS.map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
className={`h-1 w-9 rounded-full ${i <= index ? "bg-fg-primary" : "bg-surface-4"}`}
|
||||
/>
|
||||
))}
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t("progress", { n: index + 1, total: STEPS.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-8 py-6">
|
||||
{step === "agent" && <AgentStep />}
|
||||
{step === "look" && <LookStep />}
|
||||
{step === "pet" && (
|
||||
<StepHead title={t("pet.title")} body={t("pet.body")}>
|
||||
<PetSettings />
|
||||
</StepHead>
|
||||
)}
|
||||
{step === "notify" && <NotifyStep />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-border px-8 py-4">
|
||||
<button type="button" onClick={skip} className="btn-ghost text-xs">
|
||||
{t("skip")}
|
||||
</button>
|
||||
<span className="flex-1" />
|
||||
{index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(index - 1)}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
{t("back")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
data-testid="onboarding-next"
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{last ? t("finish") : t("continue")}
|
||||
<kbd className="ml-1 rounded border border-white/30 px-1 text-[10px] font-normal opacity-80">
|
||||
Ctrl ↵
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepHead({
|
||||
eyebrow,
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
{eyebrow && (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-fg-muted">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h2 id="onboarding-title" className="text-3xl font-bold text-fg-primary">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-fg-secondary">{body}</p>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentStep() {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const aiTools = useAiTools();
|
||||
const installed = aiTools.tools.filter((x) => x.installed && x.enabled);
|
||||
const others = aiTools.tools.filter((x) => !(x.installed && x.enabled));
|
||||
const [more, setMore] = useState(false);
|
||||
const [picked, setPicked] = useState<string | null>(readDefaultTool);
|
||||
const current =
|
||||
(picked && installed.some((x) => x.id === picked) ? picked : null) ??
|
||||
aiTools.defaultId ??
|
||||
installed[0]?.id ??
|
||||
null;
|
||||
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
|
||||
|
||||
const pick = (id: string) => {
|
||||
writeDefaultTool(id);
|
||||
setPicked(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<StepHead eyebrow={t("agent.eyebrow")} title={t("agent.title")} body={t("agent.body")}>
|
||||
<div className="space-y-3">
|
||||
<p className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-fg-muted">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-status-success" />
|
||||
{t("agent.detected", { count: installed.length })}
|
||||
</p>
|
||||
{aiTools.loaded && !installed.length && (
|
||||
<p className="text-sm text-fg-muted">{t("agent.none")}</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{installed.map((tool) => {
|
||||
const selected = tool.id === current;
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => pick(tool.id)}
|
||||
aria-pressed={selected}
|
||||
data-testid={`onboarding-agent-${tool.id}`}
|
||||
className={`relative flex items-center gap-3 rounded-xl border px-4 py-3.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent bg-accent/10 ring-1 ring-accent/50"
|
||||
: "border-border bg-surface-2 hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={tool} className="h-6 w-6 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-fg-primary">
|
||||
{tool.label}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-xs text-fg-muted">
|
||||
{tool.command || tool.id}
|
||||
</span>
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="absolute right-2.5 top-2.5 flex h-5 w-5 items-center justify-center rounded-full bg-accent-solid text-white">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{more &&
|
||||
others.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-3.5 opacity-60"
|
||||
>
|
||||
<AiToolIcon tool={tool} className="h-6 w-6 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm text-fg-primary">{tool.label}</span>
|
||||
<span className="block text-xs text-fg-muted">{t("agent.notInstalled")}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{others.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMore(!more)}
|
||||
className="text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
{more ? t("agent.less") : `${t("agent.more", { count: others.length })} →`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-xl border border-border bg-surface-2 px-4 py-3.5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm font-medium text-fg-primary">{t("agent.permission")}</span>
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("agent.permission")}
|
||||
value={mode}
|
||||
onChange={(m) => {
|
||||
writeDefaultMode(m);
|
||||
setMode(m);
|
||||
}}
|
||||
options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
|
||||
</div>
|
||||
</StepHead>
|
||||
);
|
||||
}
|
||||
|
||||
const MODE_CARDS: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||
{ mode: "system", Icon: Monitor },
|
||||
{ mode: "dark", Icon: Moon },
|
||||
{ mode: "light", Icon: Sun },
|
||||
];
|
||||
|
||||
function LookStep() {
|
||||
const { t, i18n } = useTranslation("onboarding");
|
||||
const { t: ts } = useTranslation("settings");
|
||||
const { mode, themeFor, setMode, setThemeFor } = useTheme();
|
||||
const [font, setFont] = useState<TerminalFont>(readTerminalFont);
|
||||
|
||||
return (
|
||||
<StepHead title={t("look.title")} body={t("look.body")}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{MODE_CARDS.map(({ mode: m, Icon }) => {
|
||||
const selected = mode === m;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
aria-pressed={selected}
|
||||
data-testid={`onboarding-mode-${m}`}
|
||||
className={`rounded-xl border p-2.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
{m === "system" ? (
|
||||
<div className="grid grid-cols-2 overflow-hidden rounded-md">
|
||||
<Swatch mode="dark" name={themeFor.dark} />
|
||||
<Swatch mode="light" name={themeFor.light} />
|
||||
</div>
|
||||
) : (
|
||||
<Swatch mode={m} name={themeFor[m]} />
|
||||
)}
|
||||
<span className="flex items-center gap-1.5 px-1 pt-2 text-sm font-medium text-fg-primary">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(`look.modes.${m}`)}
|
||||
<span className="ml-auto text-[11px] font-normal text-fg-muted">
|
||||
{t(`look.modeHints.${m}`)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(["dark", "light"] as Theme[]).map((group) => (
|
||||
<div key={group} className="space-y-2">
|
||||
<p className="text-xs font-medium text-fg-secondary">
|
||||
{t(group === "dark" ? "look.darkThemes" : "look.lightThemes")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{THEMES[group].map((name) => {
|
||||
const selected = themeFor[group] === name;
|
||||
const label = THEME_LABELS[name] ?? ts("appearance.default");
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setThemeFor(group, name)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${t(group === "dark" ? "look.darkThemes" : "look.lightThemes")}: ${label}`}
|
||||
className={`rounded-lg border p-1 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Swatch mode={group} name={name} />
|
||||
<span className="block px-1 pt-1 text-[11px] text-fg-primary">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-fg-secondary">{t("look.language")}</p>
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("look.language")}
|
||||
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
|
||||
onChange={(v) => void i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: "en" as const, label: ts("language.names.en") },
|
||||
{ value: "vi" as const, label: ts("language.names.vi") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-fg-secondary">{t("look.font")}</p>
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("look.font")}
|
||||
value={font}
|
||||
onChange={(f) => {
|
||||
writeTerminalFont(f);
|
||||
setFont(f);
|
||||
}}
|
||||
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
|
||||
value: f,
|
||||
label: (
|
||||
<span style={{ fontFamily: TERMINAL_FONTS[f] }}>
|
||||
{ts(`terminalFont.names.${f}`)}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">{t("look.more")}</p>
|
||||
</StepHead>
|
||||
);
|
||||
}
|
||||
|
||||
function NotifyStep() {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const { t: ts } = useTranslation("settings");
|
||||
const [prefs, setPrefs] = useState<NotifPrefs>(loadNotifPrefs);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const supported = typeof window !== "undefined" && "Notification" in window;
|
||||
const [permission, setPermission] = useState(() =>
|
||||
supported ? Notification.permission : "default"
|
||||
);
|
||||
const update = (patch: Partial<NotifPrefs>) => {
|
||||
const next = { ...prefs, ...patch };
|
||||
saveNotifPrefs(next);
|
||||
setPrefs(next);
|
||||
};
|
||||
|
||||
const enable = async (on: boolean) => {
|
||||
if (!on) return update({ enabled: false });
|
||||
if (supported && Notification.permission !== "granted") {
|
||||
const perm = await Notification.requestPermission();
|
||||
setPermission(perm);
|
||||
if (perm !== "granted") return;
|
||||
}
|
||||
update({ enabled: true });
|
||||
await subscribeToPush().catch(() => {
|
||||
/* the local fallback still shows them */
|
||||
});
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
const r = await notify(ts("notifications.testTitle"), ts("notifications.testBody"));
|
||||
setResult(
|
||||
r.via === "push" || r.via === "native"
|
||||
? ts("notifications.testSent")
|
||||
: r.via === "local"
|
||||
? ts("notifications.testLocal")
|
||||
: r.via === "blocked"
|
||||
? ts("notifications.testBlocked")
|
||||
: ts("notifications.testFailed")
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StepHead title={t("notify.title")} body={t("notify.body")}>
|
||||
<div className="space-y-4 rounded-xl border border-border bg-surface-2 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-fg-primary">{t("notify.enable")}</p>
|
||||
<p className="text-xs text-fg-muted">{t("notify.enableHint")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={prefs.enabled}
|
||||
onChange={(v) => void enable(v)}
|
||||
label={t("notify.enable")}
|
||||
/>
|
||||
</div>
|
||||
{permission === "denied" && (
|
||||
<p className="text-xs text-status-danger">{t("notify.blocked")}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
|
||||
<span className="mr-2 text-sm text-fg-primary">{t("notify.sound")}</span>
|
||||
<select
|
||||
aria-label={t("notify.sound")}
|
||||
value={prefs.sound}
|
||||
onChange={(e) => {
|
||||
const sound = e.target.value as NotifSound;
|
||||
update({ sound });
|
||||
playSound(sound);
|
||||
}}
|
||||
className="select py-1 text-xs"
|
||||
>
|
||||
{NOTIF_SOUNDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{ts(`notifications.sound.${k}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => playSound(prefs.sound)}
|
||||
disabled={prefs.sound === "none"}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
{t("notify.preview")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void test()}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
{t("notify.test")}
|
||||
</button>
|
||||
</div>
|
||||
{result && (
|
||||
<p role="status" className="text-xs text-fg-secondary">
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</StepHead>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* @file Onboarding.test.tsx
|
||||
* @description The first-run setup (docs/plans/onboarding): the account's
|
||||
* preferences are loaded into localStorage on sign-in and every later change
|
||||
* is sent back; the starting choices never hold bypass; the wizard shows only
|
||||
* for an account that is new to it, writes each pick like Settings does and
|
||||
* ends in the tour; the tour follows the real UI step by step.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const prefsMock = vi.fn();
|
||||
const saveMock = vi.fn();
|
||||
const onboardedMock = vi.fn();
|
||||
const lanesMock = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
me: {
|
||||
prefs: () => prefsMock(),
|
||||
savePrefs: (p: unknown) => saveMock(p),
|
||||
onboarded: () => onboardedMock(),
|
||||
},
|
||||
lanes: { list: () => lanesMock() },
|
||||
aiTools: {
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
list: async () => ({
|
||||
default: "claude",
|
||||
catalog: [],
|
||||
tools: [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
command: "claude",
|
||||
installed: true,
|
||||
enabled: true,
|
||||
},
|
||||
{ id: "codex", label: "Codex", command: "codex", installed: true, enabled: true },
|
||||
{ id: "kimi", label: "Kimi Code", command: "kimi", installed: false, enabled: true },
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../settings/PetSettings", () => ({
|
||||
PetSettings: () => <div data-testid="pet-settings" />,
|
||||
}));
|
||||
|
||||
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
|
||||
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
|
||||
import { OnboardingWizard } from "../OnboardingWizard";
|
||||
import { OnboardingTour, startTour } from "../OnboardingTour";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
__resetUserPrefs();
|
||||
__resetAiTools();
|
||||
prefsMock.mockReset();
|
||||
saveMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: null });
|
||||
onboardedMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: "2026-10-08T00:00:00Z" });
|
||||
lanesMock.mockReset().mockResolvedValue({ lanes: [], counts: {} });
|
||||
});
|
||||
|
||||
describe("account preferences", () => {
|
||||
it("loads the account's values, sends up this browser's, then every change", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
localStorage.setItem("agenthub.terminal.font", "maple");
|
||||
localStorage.setItem("theme", "dark");
|
||||
prefsMock.mockResolvedValue({ prefs: { theme: "light" }, onboardedAt: null });
|
||||
await hydrate();
|
||||
expect(localStorage.getItem("theme")).toBe("light");
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(saveMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ "agenthub.terminal.font": "maple" })
|
||||
);
|
||||
saveMock.mockClear();
|
||||
localStorage.setItem("agenthub.theme.dark", "darcula");
|
||||
localStorage.setItem("unrelated.key", "x");
|
||||
localStorage.removeItem("agenthub.pet");
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(saveMock).toHaveBeenCalledTimes(1);
|
||||
expect(saveMock).toHaveBeenCalledWith({
|
||||
"agenthub.theme.dark": "darcula",
|
||||
"agenthub.pet": null,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("never keeps bypass as the starting permission mode", () => {
|
||||
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
|
||||
expect(readDefaultMode()).toBe("auto");
|
||||
writeDefaultMode("bypassPermissions" as never);
|
||||
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
|
||||
writeDefaultMode("plan");
|
||||
expect(readDefaultMode()).toBe("plan");
|
||||
});
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
const s = useOnboarding();
|
||||
return <span data-testid="onboarded">{s.onboardedAt ?? "no"}</span>;
|
||||
}
|
||||
const renderWizard = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<OnboardingWizard />
|
||||
<Probe />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("the setup wizard", () => {
|
||||
it("does not show before the account's preferences load, nor once done", async () => {
|
||||
renderWizard();
|
||||
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
||||
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: "2026-01-01T00:00:00Z" });
|
||||
await act(() => hydrate());
|
||||
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
||||
});
|
||||
|
||||
it("skips itself for an account that already has a workspace", async () => {
|
||||
lanesMock.mockResolvedValue({ lanes: [{ id: 1 }], counts: {} });
|
||||
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
|
||||
renderWizard();
|
||||
await act(() => hydrate());
|
||||
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
||||
});
|
||||
|
||||
it("walks four steps, saving each pick, and ends in the tour", async () => {
|
||||
const user = userEvent.setup();
|
||||
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
|
||||
renderWizard();
|
||||
await act(() => hydrate());
|
||||
await screen.findByTestId("onboarding-wizard");
|
||||
expect(screen.getByText("Pick your default agent")).toBeInTheDocument();
|
||||
await user.click(await screen.findByTestId("onboarding-agent-codex"));
|
||||
expect(localStorage.getItem("agenthub.defaultTool")).toBe("codex");
|
||||
// A tool that is not installed is not offered until asked for, and never pickable.
|
||||
expect(screen.queryByText("Kimi Code")).toBeNull();
|
||||
await user.click(screen.getByText(/Show 1 more agents/));
|
||||
expect(screen.getByText("Kimi Code").closest("button")).toBeNull();
|
||||
await user.click(screen.getByRole("radio", { name: "Plan" }));
|
||||
expect(localStorage.getItem("agenthub.permissionMode")).toBe("plan");
|
||||
|
||||
await user.click(screen.getByTestId("onboarding-next"));
|
||||
expect(screen.getByText("Make it feel like home")).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId("onboarding-mode-light"));
|
||||
expect(localStorage.getItem("theme")).toBe("light");
|
||||
await user.click(screen.getByRole("button", { name: "Light theme: Canvas" }));
|
||||
expect(localStorage.getItem("agenthub.theme.light")).toBe("canvas");
|
||||
|
||||
await user.click(screen.getByTestId("onboarding-next"));
|
||||
expect(screen.getByTestId("pet-settings")).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId("onboarding-next"));
|
||||
expect(screen.getByText("Set up notifications")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /Add your first workspace/ }));
|
||||
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
||||
expect(localStorage.getItem("agenthub.tour")).toBe("add");
|
||||
});
|
||||
|
||||
it("is skipped for good with Skip setup", async () => {
|
||||
const user = userEvent.setup();
|
||||
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
|
||||
renderWizard();
|
||||
await act(() => hydrate());
|
||||
await user.click(await screen.findByRole("button", { name: "Skip setup" }));
|
||||
await waitFor(() => expect(screen.getByTestId("onboarded").textContent).not.toBe("no"));
|
||||
expect(onboardedMock).toHaveBeenCalled();
|
||||
expect(localStorage.getItem("agenthub.tour")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tour", () => {
|
||||
const mount = (html: string) => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = html;
|
||||
document.body.appendChild(host);
|
||||
return host;
|
||||
};
|
||||
// jsdom has no layout: give the targets a box.
|
||||
const boxed = () =>
|
||||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
|
||||
x: 10,
|
||||
y: 10,
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 100,
|
||||
height: 30,
|
||||
right: 110,
|
||||
bottom: 40,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
it("follows the real UI: add → folder → create → prompt", async () => {
|
||||
const spy = boxed();
|
||||
const user = userEvent.setup();
|
||||
const page = mount('<button data-testid="lane-empty">Add workspace</button>');
|
||||
try {
|
||||
startTour();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace"]}>
|
||||
<OnboardingTour />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
|
||||
// The dialog opened: the tour moves on by itself.
|
||||
const dialog = mount(
|
||||
'<div data-tour="add-lane"><div data-tour="add-lane-folder"></div><button data-tour="add-lane-create"></button></div>'
|
||||
);
|
||||
expect(await screen.findByText("Choose the project folder")).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId("onboarding-tour-next"));
|
||||
expect(screen.getByText("Create it")).toBeInTheDocument();
|
||||
// Created: the dialog is gone and the workspace's prompt box is there.
|
||||
dialog.remove();
|
||||
page.innerHTML = '<div data-testid="workspace-home-composer"></div>';
|
||||
expect(await screen.findByText("Start your first session")).toBeInTheDocument();
|
||||
expect(localStorage.getItem("agenthub.tour")).toBe("prompt");
|
||||
await user.click(screen.getByRole("button", { name: "Skip tour" }));
|
||||
expect(screen.queryByTestId("onboarding-tour")).toBeNull();
|
||||
expect(localStorage.getItem("agenthub.tour")).toBe("done");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("goes back to Add workspace when the dialog closes with nothing made", async () => {
|
||||
const spy = boxed();
|
||||
const page = mount('<button data-testid="lane-empty">Add workspace</button>');
|
||||
try {
|
||||
localStorage.setItem("agenthub.tour", "create");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace"]}>
|
||||
<OnboardingTour />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,7 @@ export function PaneChat({
|
||||
rows={2}
|
||||
slash={tool === "claude"}
|
||||
fileCwd={cwd}
|
||||
lane={runId}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("chat.hint")}</span>
|
||||
|
||||
@@ -163,6 +163,8 @@ interface PromptEditorProps {
|
||||
/** An image upload started (true) or settled (false): the caller holds its
|
||||
* send button meanwhile, or the prompt would go out without the image. */
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
/** The workspace (id, or a pane's run id) whose panes read the files. */
|
||||
lane?: number | string;
|
||||
}
|
||||
|
||||
/** An attached file: its path on the server's machine, and a local preview
|
||||
@@ -187,6 +189,7 @@ export function PromptEditor({
|
||||
onImagesChange,
|
||||
bare,
|
||||
onBusyChange,
|
||||
lane,
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@@ -325,7 +328,7 @@ export function PromptEditor({
|
||||
const picked = Array.from(list ?? []);
|
||||
if (picked.length === 0) return false;
|
||||
setUpload("uploading");
|
||||
Promise.all(picked.map((f) => api.run.dropFile(f)))
|
||||
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
|
||||
.then((saved) => {
|
||||
setUpload(null);
|
||||
const added = picked.map((f, i) => ({
|
||||
|
||||
@@ -192,7 +192,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
if (files.length === 0) return;
|
||||
setDrop("uploading");
|
||||
try {
|
||||
const saved = await Promise.all(files.map((f) => api.run.dropFile(f)));
|
||||
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees pasted paths rather than typed keystrokes.
|
||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||
|
||||
@@ -186,6 +186,7 @@ describe("PromptEditor — newlines and images", () => {
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
lane={7}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("see", images)}</output>
|
||||
</>
|
||||
@@ -195,7 +196,8 @@ describe("PromptEditor — newlines and images", () => {
|
||||
const img = new File(["x"], "shot.png", { type: "image/png" });
|
||||
fireEvent.paste(box(), { clipboardData: { files: [img] } });
|
||||
const thumb = await screen.findByRole("button", { name: "shot.png" });
|
||||
expect(api.run.dropFile).toHaveBeenCalledWith(img);
|
||||
// Saved where workspace 7's panes can read it.
|
||||
expect(api.run.dropFile).toHaveBeenCalledWith(img, 7);
|
||||
// The path is not typed into the prompt; it goes with it on send.
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const dropFileMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
|
||||
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
@@ -481,7 +481,8 @@ describe("TerminalView", () => {
|
||||
await waitFor(() =>
|
||||
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
|
||||
);
|
||||
expect(dropFileMock).toHaveBeenCalledWith(img);
|
||||
// The pane's run id: saved where that pane can read it.
|
||||
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
|
||||
* is painted by the same tokens as the app — no hex is duplicated here.
|
||||
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
|
||||
* **Run setup again** reopens the first-run wizard (`components/onboarding`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -21,6 +22,7 @@ import { PetSettings } from "./PetSettings";
|
||||
import { ChatUiSettings } from "./ChatUiSettings";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { resetOnboarding } from "../../lib/userPrefs";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
TERMINAL_FONTS,
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
|
||||
const THEME_LABELS: Record<string, string> = {
|
||||
export const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
"apple-dark": "Apple Dark",
|
||||
"apple-light": "Apple Light",
|
||||
@@ -44,7 +46,7 @@ const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||
{ mode: "system", Icon: Monitor },
|
||||
];
|
||||
|
||||
function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
export function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -221,6 +223,26 @@ export function AppearanceSettings() {
|
||||
</div>
|
||||
<PetSettings />
|
||||
</section>
|
||||
|
||||
<SettingsSection
|
||||
id="setup"
|
||||
title={t("onboarding:rerun.label")}
|
||||
description={t("onboarding:rerun.description")}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t("onboarding:rerun.label")}
|
||||
control={
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetOnboarding}
|
||||
data-testid="rerun-setup"
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("onboarding:rerun.button")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ export const i18nReady = i18n
|
||||
"splash",
|
||||
"lanes",
|
||||
"users",
|
||||
"onboarding",
|
||||
],
|
||||
defaultNS: "common",
|
||||
interpolation: { escapeValue: false },
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"brand": "AgentHub",
|
||||
"progress": "{{n}} of {{total}}",
|
||||
"skip": "Skip setup",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"finish": "Add your first workspace",
|
||||
"agent": {
|
||||
"eyebrow": "Welcome to AgentHub",
|
||||
"title": "Pick your default agent",
|
||||
"body": "AgentHub works with every CLI agent. Choose the one you'll reach for most. Switch any time.",
|
||||
"detected": "Detected on this machine · {{count}}",
|
||||
"none": "No AI tool is installed yet. An admin installs them in Settings → AI Tools; you can finish setup and pick one later.",
|
||||
"more": "Show {{count}} more agents",
|
||||
"less": "Show fewer",
|
||||
"notInstalled": "not installed",
|
||||
"permission": "Start new sessions in",
|
||||
"permissionHint": "Bypass permissions is never a default: it stays a red switch you turn on for one session.",
|
||||
"modes": {
|
||||
"auto": "Auto",
|
||||
"acceptEdits": "Accept edits",
|
||||
"default": "Ask first",
|
||||
"plan": "Plan"
|
||||
}
|
||||
},
|
||||
"look": {
|
||||
"title": "Make it feel like home",
|
||||
"body": "Pick the look you want to stare at for hours.",
|
||||
"modes": {
|
||||
"system": "System",
|
||||
"dark": "Dark",
|
||||
"light": "Light"
|
||||
},
|
||||
"modeHints": {
|
||||
"system": "Match OS",
|
||||
"dark": "Easy on the eyes",
|
||||
"light": "Bright & crisp"
|
||||
},
|
||||
"darkThemes": "Dark theme",
|
||||
"lightThemes": "Light theme",
|
||||
"language": "Language",
|
||||
"font": "Terminal font",
|
||||
"more": "More options — themes, fonts, the pet — in Settings → Appearance."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Pick a companion",
|
||||
"body": "A small pet sits in the corner and reacts to what your agents do. Hide it any time."
|
||||
},
|
||||
"notify": {
|
||||
"title": "Set up notifications",
|
||||
"body": "AgentHub tells you when agents are done or need you.",
|
||||
"enable": "Desktop notifications",
|
||||
"enableHint": "Your browser asks for permission once.",
|
||||
"blocked": "Notifications are blocked for this site — allow them in the browser's site settings.",
|
||||
"sound": "Notification sound",
|
||||
"preview": "Play",
|
||||
"test": "Send test notification"
|
||||
},
|
||||
"tour": {
|
||||
"progress": "Step {{n}} of {{total}}",
|
||||
"skip": "Skip tour",
|
||||
"next": "Next",
|
||||
"done": "Done",
|
||||
"goWorkspaces": "Go to Workspaces",
|
||||
"add": {
|
||||
"title": "Create your first workspace",
|
||||
"body": "A workspace is one project folder where your agents work. Click here to add one."
|
||||
},
|
||||
"folder": {
|
||||
"title": "Choose the project folder",
|
||||
"body": "Type a path or Browse to the folder of your project. A git repository gets a Changes panel too."
|
||||
},
|
||||
"create": {
|
||||
"title": "Create it",
|
||||
"body": "Pick the AI it starts on, then click Add workspace."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Start your first session",
|
||||
"body": "Tell the agent what to do and press Enter. AI, model and permission mode sit right here."
|
||||
},
|
||||
"session": {
|
||||
"title": "Your session is live",
|
||||
"body": "Watch it work and type into it like a terminal. Changes and Files are in the panel on the right; every session of this workspace is listed in the sidebar."
|
||||
},
|
||||
"finish": {
|
||||
"title": "You're all set",
|
||||
"body": "Run setup again or change any of it in Settings."
|
||||
}
|
||||
},
|
||||
"rerun": {
|
||||
"label": "First-run setup",
|
||||
"description": "Go through the welcome steps and the tour again.",
|
||||
"button": "Run setup again"
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
"bubbles": "Speech bubbles",
|
||||
"bubblesDesc": "Let the pet comment on your sessions in a bubble",
|
||||
"sizeDesc": "{{min}}–{{max}} px; the preview is capped at 120 px",
|
||||
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved in this browser.",
|
||||
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.",
|
||||
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
|
||||
"panel": {
|
||||
"label": "Sagi companion",
|
||||
@@ -189,7 +189,7 @@
|
||||
"alert": "Alert",
|
||||
"forType": "Sound for this type"
|
||||
},
|
||||
"scopeNote": "These notification settings are saved in this browser only. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.",
|
||||
"scopeNote": "These notification settings are saved to your account. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.",
|
||||
"insecureOrigin": "This page is served from {{origin}}, which is not a secure origin. Browsers only show notifications on HTTPS or localhost — open the dashboard through one of those to get them.",
|
||||
"unsupported": "This browser does not support notifications.",
|
||||
"deniedHelp": "Notifications are blocked for this site. To allow them again, open the site settings (the icon left of the address bar), set Notifications to Allow, then reload this page.",
|
||||
@@ -309,6 +309,7 @@
|
||||
"label": "Start AgentHub automatically",
|
||||
"desc_systemd": "Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.",
|
||||
"desc_launchd": "Run the dashboard as a LaunchAgent, so it starts at login without Claude Code being opened. Turning it off keeps the running server up.",
|
||||
"desc_systemd-system": "Run the dashboard as the system service agenthub.service (server mode, one Linux user per account), so it starts with the machine.",
|
||||
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
|
||||
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
|
||||
},
|
||||
@@ -662,7 +663,12 @@
|
||||
"test": "Test",
|
||||
"testing": "Testing…",
|
||||
"testOk": "answered — {{model}} is reachable with this key.",
|
||||
"testFailed": "no answer ({{status}}): {{message}}"
|
||||
"testFailed": "no answer ({{status}}): {{message}}",
|
||||
"personal": "Personal",
|
||||
"fieldScope": "Who can use it",
|
||||
"scopeShared": "Everyone (shared)",
|
||||
"scopePersonal": "Only me (personal)",
|
||||
"fieldScopeHint": "A shared profile's key never reaches anyone's browser. A personal one is listed, edited and used by you alone."
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "AI Tools",
|
||||
@@ -697,7 +703,7 @@
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"description": "Colour mode and theme. Saved in this browser only.",
|
||||
"description": "Colour mode and theme. Saved to your account, so every browser you sign in to gets them.",
|
||||
"mode": "Mode",
|
||||
"modes": {
|
||||
"dark": "Dark",
|
||||
@@ -882,7 +888,7 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"description": "The language of the whole dashboard, remembered in this browser.",
|
||||
"description": "The language of the whole dashboard, saved to your account.",
|
||||
"label": "Interface language",
|
||||
"names": {
|
||||
"en": "English",
|
||||
@@ -891,7 +897,7 @@
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Terminal font",
|
||||
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved in this browser only.",
|
||||
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved to your account.",
|
||||
"label": "Font",
|
||||
"names": {
|
||||
"jetbrains": "JetBrains Mono",
|
||||
@@ -1001,7 +1007,7 @@
|
||||
},
|
||||
"prefsBackup": {
|
||||
"title": "This browser's preferences",
|
||||
"description": "Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
|
||||
"description": "Theme, language, notification settings and pet follow your account; the sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
|
||||
"export": "Export preferences",
|
||||
"import": "Import preferences…",
|
||||
"importTitle": "Import preferences?",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"brand": "AgentHub",
|
||||
"progress": "{{n}} / {{total}}",
|
||||
"skip": "Bỏ qua thiết lập",
|
||||
"back": "Quay lại",
|
||||
"continue": "Tiếp tục",
|
||||
"finish": "Thêm workspace đầu tiên",
|
||||
"agent": {
|
||||
"eyebrow": "Chào mừng đến AgentHub",
|
||||
"title": "Chọn agent mặc định",
|
||||
"body": "AgentHub chạy được mọi CLI agent. Chọn cái bạn dùng nhiều nhất — đổi lúc nào cũng được.",
|
||||
"detected": "Đã có trên máy này · {{count}}",
|
||||
"none": "Chưa có AI tool nào được cài. Admin cài trong Cài đặt → AI Tools; bạn có thể hoàn tất thiết lập và chọn sau.",
|
||||
"more": "Xem thêm {{count}} agent",
|
||||
"less": "Thu gọn",
|
||||
"notInstalled": "chưa cài",
|
||||
"permission": "Session mới bắt đầu ở chế độ",
|
||||
"permissionHint": "Bypass permissions không bao giờ là mặc định: nó vẫn là công tắc đỏ bạn bật cho từng session.",
|
||||
"modes": {
|
||||
"auto": "Tự động",
|
||||
"acceptEdits": "Nhận sửa đổi",
|
||||
"default": "Hỏi trước",
|
||||
"plan": "Lập kế hoạch"
|
||||
}
|
||||
},
|
||||
"look": {
|
||||
"title": "Làm cho nó thật quen thuộc",
|
||||
"body": "Chọn giao diện bạn sẽ nhìn hàng giờ liền.",
|
||||
"modes": {
|
||||
"system": "Hệ thống",
|
||||
"dark": "Tối",
|
||||
"light": "Sáng"
|
||||
},
|
||||
"modeHints": {
|
||||
"system": "Theo hệ điều hành",
|
||||
"dark": "Dịu mắt",
|
||||
"light": "Sáng rõ"
|
||||
},
|
||||
"darkThemes": "Theme tối",
|
||||
"lightThemes": "Theme sáng",
|
||||
"language": "Ngôn ngữ",
|
||||
"font": "Font terminal",
|
||||
"more": "Thêm tuỳ chọn — theme, font, thú cưng — trong Cài đặt → Giao diện."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Chọn một người bạn đồng hành",
|
||||
"body": "Một thú cưng nhỏ ngồi ở góc màn hình và phản ứng với việc agent đang làm. Ẩn lúc nào cũng được."
|
||||
},
|
||||
"notify": {
|
||||
"title": "Thiết lập thông báo",
|
||||
"body": "AgentHub báo cho bạn khi agent xong việc hoặc cần bạn.",
|
||||
"enable": "Thông báo trên máy",
|
||||
"enableHint": "Trình duyệt sẽ hỏi quyền một lần.",
|
||||
"blocked": "Trang này đang bị chặn thông báo — cho phép trong cài đặt trang của trình duyệt.",
|
||||
"sound": "Âm thanh thông báo",
|
||||
"preview": "Nghe thử",
|
||||
"test": "Gửi thông báo thử"
|
||||
},
|
||||
"tour": {
|
||||
"progress": "Bước {{n}} / {{total}}",
|
||||
"skip": "Bỏ qua hướng dẫn",
|
||||
"next": "Tiếp",
|
||||
"done": "Xong",
|
||||
"goWorkspaces": "Mở Workspaces",
|
||||
"add": {
|
||||
"title": "Tạo workspace đầu tiên",
|
||||
"body": "Workspace là một thư mục dự án nơi các agent làm việc. Bấm vào đây để thêm."
|
||||
},
|
||||
"folder": {
|
||||
"title": "Chọn thư mục dự án",
|
||||
"body": "Gõ đường dẫn hoặc bấm Browse tới thư mục dự án. Repo git sẽ có thêm bảng Changes."
|
||||
},
|
||||
"create": {
|
||||
"title": "Tạo workspace",
|
||||
"body": "Chọn AI sẽ dùng, rồi bấm Thêm workspace."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Bắt đầu session đầu tiên",
|
||||
"body": "Viết việc cần agent làm rồi nhấn Enter. Chọn AI, model và chế độ quyền ngay tại đây."
|
||||
},
|
||||
"session": {
|
||||
"title": "Session đang chạy",
|
||||
"body": "Xem agent làm việc và gõ vào như một terminal. Changes và Files nằm ở bảng bên phải; mọi session của workspace này có trong thanh bên."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Xong rồi!",
|
||||
"body": "Chạy lại thiết lập hoặc đổi bất cứ lựa chọn nào trong Cài đặt."
|
||||
}
|
||||
},
|
||||
"rerun": {
|
||||
"label": "Thiết lập ban đầu",
|
||||
"description": "Đi lại các bước chào mừng và phần hướng dẫn.",
|
||||
"button": "Chạy lại thiết lập"
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
"bubbles": "Bong bóng lời thoại",
|
||||
"bubblesDesc": "Cho pet bình luận về các session của bạn trong bong bóng",
|
||||
"sizeDesc": "{{min}}–{{max}} px; bản xem trước tối đa 120 px",
|
||||
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu trong trình duyệt này.",
|
||||
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu vào tài khoản của bạn.",
|
||||
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.",
|
||||
"panel": {
|
||||
"label": "Trợ lý Sagi",
|
||||
@@ -189,7 +189,7 @@
|
||||
"alert": "Cảnh báo",
|
||||
"forType": "Âm thanh cho loại này"
|
||||
},
|
||||
"scopeNote": "Các cài đặt thông báo này chỉ lưu trong trình duyệt này. Quy tắc cảnh báo và webhook bên dưới áp dụng cho toàn máy chủ: mọi người dùng bảng điều khiển này đều chịu ảnh hưởng.",
|
||||
"scopeNote": "Các cài đặt thông báo này được lưu vào tài khoản của bạn. Quy tắc cảnh báo và webhook bên dưới áp dụng cho toàn máy chủ: mọi người dùng bảng điều khiển này đều chịu ảnh hưởng.",
|
||||
"insecureOrigin": "Trang này được phục vụ từ {{origin}}, không phải nguồn bảo mật. Trình duyệt chỉ hiện thông báo trên HTTPS hoặc localhost — hãy mở bảng điều khiển qua một trong hai cách đó.",
|
||||
"unsupported": "Trình duyệt này không hỗ trợ thông báo.",
|
||||
"deniedHelp": "Thông báo đang bị chặn cho trang này. Để cho phép lại, mở cài đặt trang (biểu tượng bên trái thanh địa chỉ), đặt Thông báo thành Cho phép, rồi tải lại trang.",
|
||||
@@ -309,6 +309,7 @@
|
||||
"label": "Tự động khởi động AgentHub",
|
||||
"desc_systemd": "Chạy dashboard bằng systemd --user service để nó tự bật khi khởi động máy, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
|
||||
"desc_launchd": "Chạy dashboard bằng LaunchAgent để nó tự bật khi đăng nhập, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
|
||||
"desc_systemd-system": "Chạy dashboard bằng system service agenthub.service (chế độ server, mỗi tài khoản một Linux user) để nó tự bật cùng máy.",
|
||||
"unavailable": "Không khả dụng ở đây: cần systemd --user trên Linux, hoặc phiên đăng nhập GUI trên macOS.",
|
||||
"foreign": "Đã có một service tên agenthub không do AgentHub tạo — AgentHub không đụng vào."
|
||||
},
|
||||
@@ -662,7 +663,12 @@
|
||||
"test": "Kiểm tra",
|
||||
"testing": "Đang kiểm tra…",
|
||||
"testOk": "đã trả lời — {{model}} dùng được với key này.",
|
||||
"testFailed": "không trả lời ({{status}}): {{message}}"
|
||||
"testFailed": "không trả lời ({{status}}): {{message}}",
|
||||
"personal": "Riêng",
|
||||
"fieldScope": "Ai được dùng",
|
||||
"scopeShared": "Mọi người (dùng chung)",
|
||||
"scopePersonal": "Chỉ tôi (riêng)",
|
||||
"fieldScopeHint": "Key của profile dùng chung không bao giờ tới trình duyệt của ai. Profile riêng chỉ mình bạn thấy, sửa và dùng."
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "AI Tools",
|
||||
@@ -697,7 +703,7 @@
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Giao diện",
|
||||
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này.",
|
||||
"description": "Chế độ màu và theme. Lưu vào tài khoản, nên mọi trình duyệt bạn đăng nhập đều dùng chúng.",
|
||||
"mode": "Chế độ",
|
||||
"modes": {
|
||||
"dark": "Tối",
|
||||
@@ -882,7 +888,7 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Ngôn ngữ",
|
||||
"description": "Ngôn ngữ của toàn bộ dashboard, được ghi nhớ trong trình duyệt này.",
|
||||
"description": "Ngôn ngữ của toàn bộ dashboard, được lưu vào tài khoản của bạn.",
|
||||
"label": "Ngôn ngữ giao diện",
|
||||
"names": {
|
||||
"en": "English",
|
||||
@@ -891,7 +897,7 @@
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Font terminal",
|
||||
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Chỉ lưu trong trình duyệt này.",
|
||||
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Lưu vào tài khoản của bạn.",
|
||||
"label": "Font",
|
||||
"names": {
|
||||
"jetbrains": "JetBrains Mono",
|
||||
@@ -1001,7 +1007,7 @@
|
||||
},
|
||||
"prefsBackup": {
|
||||
"title": "Tùy chọn của trình duyệt này",
|
||||
"description": "Giao diện, ngôn ngữ, cài đặt thông báo, thú cưng, thanh bên và bố cục không gian làm việc được lưu trong trình duyệt này. Lưu chúng ra tệp để chuyển sang trình duyệt khác, hoặc khôi phục từ tệp.",
|
||||
"description": "Giao diện, ngôn ngữ, cài đặt thông báo và thú cưng theo tài khoản của bạn; thanh bên và bố cục không gian làm việc được lưu trong trình duyệt này. Lưu chúng ra tệp để chuyển sang trình duyệt khác, hoặc khôi phục từ tệp.",
|
||||
"export": "Xuất tùy chọn",
|
||||
"import": "Nhập tùy chọn…",
|
||||
"importTitle": "Nhập tùy chọn?",
|
||||
|
||||
+27
-16
@@ -555,6 +555,8 @@ 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" | "developer" };
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||
export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | null };
|
||||
|
||||
/** GETs in flight, by path: concurrent callers of the same read (several
|
||||
* components mounting, a WS event fanning out to many listeners) share one
|
||||
@@ -675,13 +677,15 @@ 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) ─────────────────────────
|
||||
// ───────────────────────── The account's own preferences (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). */
|
||||
/** GET /api/me/prefs — this account's preferences and first-run flag. */
|
||||
prefs: () => request<UserPrefs>("/me/prefs"),
|
||||
/** PUT /api/me/prefs — merge; `null` deletes a key. */
|
||||
savePrefs: (prefs: Record<string, string | null>) =>
|
||||
request<MePrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
|
||||
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
@@ -2448,13 +2452,19 @@ export const api = {
|
||||
headers: { "Content-Type": file.type },
|
||||
}),
|
||||
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
|
||||
* name so the agent sees what it is. */
|
||||
dropFile: (file: File) =>
|
||||
request<{ path: string }>(`/run/drop-file?name=${encodeURIComponent(file.name)}`, {
|
||||
* name so the agent sees what it is. `lane` (a workspace id, or a pane's
|
||||
* run id) saves it where THAT workspace's panes can read it. */
|
||||
dropFile: (file: File, lane?: number | string | null) => {
|
||||
const laneId =
|
||||
typeof lane === "string" ? /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(lane)?.[1] : lane;
|
||||
const qs = new URLSearchParams({ name: file.name });
|
||||
if (laneId != null) qs.set("laneId", String(laneId));
|
||||
return request<{ path: string }>(`/run/drop-file?${qs.toString()}`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
}),
|
||||
});
|
||||
},
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
@@ -3600,6 +3610,10 @@ export interface AiProxy {
|
||||
label: string;
|
||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||
builtin: boolean;
|
||||
/** "shared": the host's (an admin's, in server mode), usable by everyone;
|
||||
* "personal": the signed-in user's own, named `u<id>_<slug>` — server mode
|
||||
* only, and nobody else lists it. */
|
||||
scope?: "shared" | "personal";
|
||||
/** Empty string for `native`, else the proxy's http(s) ROOT, without `/v1`. */
|
||||
baseUrl: string;
|
||||
/** Whether a key is stored. The key itself never leaves the server. */
|
||||
@@ -3651,6 +3665,9 @@ export interface AiProxyInput {
|
||||
models?: Partial<AiProxyModels>;
|
||||
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
|
||||
behavesAs?: string;
|
||||
/** Server mode, on create: an admin's profile is shared unless "personal";
|
||||
* a member's is always personal. Ignored in local mode. */
|
||||
scope?: "shared" | "personal";
|
||||
}
|
||||
|
||||
/** One markdown-based config artifact (skill/agent/command/output-style),
|
||||
@@ -4312,12 +4329,6 @@ 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;
|
||||
@@ -4528,7 +4539,7 @@ export interface RemoteSourceInput {
|
||||
* write, which it never touches. */
|
||||
export interface AutostartStatus {
|
||||
available: boolean;
|
||||
kind: "systemd" | "launchd";
|
||||
kind: "systemd" | "systemd-system" | "launchd";
|
||||
enabled: boolean;
|
||||
foreign: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @file sessionDefaults.ts
|
||||
* @description The account's own starting choices for new work, picked in the
|
||||
* first-run setup (docs/plans/onboarding) and synced per account like every
|
||||
* other preference (`lib/userPrefs.ts`): the AI a new workspace starts on, and
|
||||
* the permission mode a new Claude session starts in. Bypass is NEVER one of
|
||||
* them — it stays the per-session switch (CLAUDE.md), so `bypassPermissions`
|
||||
* is refused on read and on write.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { PermissionMode } from "./api";
|
||||
|
||||
const TOOL_KEY = "agenthub.defaultTool";
|
||||
const MODE_KEY = "agenthub.permissionMode";
|
||||
|
||||
/** The permission modes a default may be. */
|
||||
export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan"] as const;
|
||||
export type DefaultMode = (typeof DEFAULT_MODES)[number];
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function write(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
}
|
||||
|
||||
/** The account's own default AI tool id, or null (the install's default applies). */
|
||||
export const readDefaultTool = (): string | null => read(TOOL_KEY);
|
||||
export const writeDefaultTool = (id: string): void => write(TOOL_KEY, id);
|
||||
|
||||
/** The permission mode a new Claude session starts in ("auto" when none was picked). */
|
||||
export function readDefaultMode(): PermissionMode {
|
||||
const v = read(MODE_KEY);
|
||||
return (DEFAULT_MODES as readonly string[]).includes(v ?? "") ? (v as DefaultMode) : "auto";
|
||||
}
|
||||
export function writeDefaultMode(mode: DefaultMode): void {
|
||||
if ((DEFAULT_MODES as readonly string[]).includes(mode)) write(MODE_KEY, mode);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @file userPrefs.ts
|
||||
* @description An account's UI preferences, kept per ACCOUNT on the server
|
||||
* (`/api/me/prefs`, docs/plans/onboarding) while every component goes on
|
||||
* reading and writing localStorage as it always did. On sign-in `hydrate()`
|
||||
* copies the account's stored values into localStorage (the account wins),
|
||||
* sends up the ones this browser has and the account does not yet, and makes
|
||||
* the theme, language and pet re-read them. From then on a write to one of
|
||||
* `PREF_KEYS` through `localStorage.setItem`/`removeItem` is sent to the
|
||||
* server, batched. `useOnboarding()` tells the setup wizard whether this
|
||||
* account has been through it. Never throws: a server that cannot answer
|
||||
* leaves the browser's own values in charge. The same row also holds
|
||||
* `agenthub.chatUi`, which `lib/chatUi.ts` reads and writes on the server
|
||||
* directly (no localStorage copy).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { api } from "./api";
|
||||
import i18n from "../i18n";
|
||||
|
||||
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
||||
export const PREF_KEYS = [
|
||||
"theme",
|
||||
"agenthub.theme.dark",
|
||||
"agenthub.theme.light",
|
||||
"i18nextLng",
|
||||
"agenthub.terminal.font",
|
||||
"agenthub.pet",
|
||||
"agenthub.pet.size",
|
||||
"agent-dashboard-sagi-enabled",
|
||||
"agent-dashboard-sagi-muted",
|
||||
"agent-monitor-notifications",
|
||||
"agenthub.defaultTool",
|
||||
"agenthub.permissionMode",
|
||||
"agenthub.tour",
|
||||
] as const;
|
||||
const SYNCED = new Set<string>(PREF_KEYS);
|
||||
const PUSH_DELAY_MS = 400;
|
||||
|
||||
// `forced`: Settings asked for the setup again, whatever the account has.
|
||||
type State = { loaded: boolean; onboardedAt: string | null; forced: boolean };
|
||||
let state: State = { loaded: false, onboardedAt: null, forced: false };
|
||||
const listeners = new Set<() => void>();
|
||||
const setState = (next: Partial<State>) => {
|
||||
state = { ...state, ...next };
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
|
||||
let pending: Record<string, string | null> = {};
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let syncing = false;
|
||||
|
||||
function queue(key: string, value: string | null) {
|
||||
if (!syncing) return;
|
||||
pending[key] = value;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(flush, PUSH_DELAY_MS);
|
||||
}
|
||||
|
||||
/** Send what is queued now (also on page hide). */
|
||||
export function flush(): Promise<void> {
|
||||
clearTimeout(timer);
|
||||
const batch = pending;
|
||||
pending = {};
|
||||
if (!Object.keys(batch).length) return Promise.resolve();
|
||||
return api.me
|
||||
.savePrefs(batch)
|
||||
.then(() => undefined)
|
||||
.catch(() => {
|
||||
/* offline or signed out: the browser keeps its own copy */
|
||||
});
|
||||
}
|
||||
|
||||
let wrapped = false;
|
||||
function wrapStorage() {
|
||||
if (wrapped || typeof Storage === "undefined") return;
|
||||
wrapped = true;
|
||||
const proto = Storage.prototype;
|
||||
const setItem = proto.setItem;
|
||||
const removeItem = proto.removeItem;
|
||||
proto.setItem = function (key: string, value: string) {
|
||||
setItem.call(this, key, value);
|
||||
if (this === window.localStorage && SYNCED.has(key)) queue(key, String(value));
|
||||
};
|
||||
proto.removeItem = function (key: string) {
|
||||
removeItem.call(this, key);
|
||||
if (this === window.localStorage && SYNCED.has(key)) queue(key, null);
|
||||
};
|
||||
window.addEventListener("pagehide", () => void flush());
|
||||
}
|
||||
|
||||
function readLocal(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Make what reads these keys read them again. */
|
||||
function reapply(prefs: Record<string, string>) {
|
||||
window.dispatchEvent(new Event("agenthub-theme"));
|
||||
window.dispatchEvent(new Event("sagi:prefs"));
|
||||
const lng = prefs.i18nextLng;
|
||||
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the signed-in account's preferences into this browser and start
|
||||
* sending changes. Call once a login (or local mode) is known.
|
||||
*/
|
||||
export async function hydrate(): Promise<void> {
|
||||
syncing = false;
|
||||
pending = {};
|
||||
let answer;
|
||||
try {
|
||||
answer = await api.me.prefs();
|
||||
} catch {
|
||||
// No answer (an older server, a test with no API): no wizard, no sync —
|
||||
// the browser's own values stay in charge.
|
||||
return;
|
||||
}
|
||||
const up: Record<string, string> = {};
|
||||
for (const key of PREF_KEYS) {
|
||||
const theirs = answer.prefs[key];
|
||||
if (typeof theirs === "string") {
|
||||
try {
|
||||
window.localStorage.setItem(key, theirs);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
} else {
|
||||
const mine = readLocal(key);
|
||||
if (mine !== null) up[key] = mine;
|
||||
}
|
||||
}
|
||||
wrapStorage();
|
||||
syncing = true;
|
||||
reapply(answer.prefs);
|
||||
if (Object.keys(up).length) {
|
||||
pending = { ...up };
|
||||
void flush();
|
||||
}
|
||||
setState({ loaded: true, onboardedAt: answer.onboardedAt });
|
||||
}
|
||||
|
||||
/** The first-run setup is done or skipped, for this account. */
|
||||
export async function markOnboarded(): Promise<void> {
|
||||
await flush();
|
||||
const at = new Date().toISOString();
|
||||
setState({ onboardedAt: at, forced: false });
|
||||
try {
|
||||
const r = await api.me.onboarded();
|
||||
setState({ onboardedAt: r.onboardedAt || at });
|
||||
} catch {
|
||||
/* shown again next time — better than never saving the choices */
|
||||
}
|
||||
}
|
||||
|
||||
/** Show the setup again (Settings → System). */
|
||||
export function resetOnboarding(): void {
|
||||
setState({ onboardedAt: null, forced: true });
|
||||
}
|
||||
|
||||
const subscribe = (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
};
|
||||
|
||||
/** `{loaded, onboardedAt, forced}` for the account signed in now. */
|
||||
export function useOnboarding(): State {
|
||||
return useSyncExternalStore(subscribe, () => state);
|
||||
}
|
||||
|
||||
/** Tests only. */
|
||||
export function __resetUserPrefs(): void {
|
||||
state = { loaded: false, onboardedAt: null, forced: false };
|
||||
pending = {};
|
||||
syncing = false;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -21,6 +21,11 @@
|
||||
* A right-click on a profile row opens its Edit / Remove (plus Copy endpoint)
|
||||
* as a menu; the buttons stay.
|
||||
*
|
||||
* In server mode a profile is SHARED (an admin's, usable by everyone) or
|
||||
* PERSONAL (the signed-in user's own, badged "Personal"). A member edits only
|
||||
* their own and every profile they add is personal; an admin chooses when
|
||||
* adding; an analyst only reads.
|
||||
*
|
||||
* **Test** asks the endpoint itself, once as Claude Code would
|
||||
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
||||
* "Saved" and "works" are different claims, and the gap between them is only
|
||||
@@ -43,13 +48,15 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../compone
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
|
||||
const TIERS: (keyof AiProxyModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs"> & {
|
||||
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs" | "scope"> & {
|
||||
apiKey: string;
|
||||
behavesAs: string;
|
||||
models: Partial<AiProxyModels>;
|
||||
scope: "shared" | "personal";
|
||||
};
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
@@ -59,10 +66,17 @@ const emptyForm = (): FormState => ({
|
||||
model: "",
|
||||
models: {},
|
||||
behavesAs: "",
|
||||
scope: "shared",
|
||||
});
|
||||
|
||||
export function AiProxyConfig() {
|
||||
const { t } = useTranslation("settings");
|
||||
// Server mode's signed-in user; null in local mode, where the host owns
|
||||
// every (shared) profile.
|
||||
const { state: auth } = useAuth();
|
||||
const user = auth.mode === "server" ? auth.user : null;
|
||||
const isAdmin = !user || user.role === "admin";
|
||||
const canWrite = (item: AiProxy) => !item.builtin && (item.scope === "personal" || isAdmin);
|
||||
const [items, setItems] = useState<AiProxy[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<AiProxy | null>(null);
|
||||
@@ -131,6 +145,7 @@ export function AiProxyConfig() {
|
||||
model: item.model,
|
||||
models: { ...item.models },
|
||||
behavesAs: item.behavesAs ?? "",
|
||||
scope: item.scope ?? "shared",
|
||||
});
|
||||
setFormError(null);
|
||||
setOpen(true);
|
||||
@@ -187,6 +202,7 @@ export function AiProxyConfig() {
|
||||
behavesAs: form.behavesAs.trim(),
|
||||
};
|
||||
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
||||
if (user && isAdmin && !editing) body.scope = form.scope;
|
||||
|
||||
if (editing) await api.aiProxies.update(editing.name, body);
|
||||
else await api.aiProxies.create(body);
|
||||
@@ -253,13 +269,18 @@ export function AiProxyConfig() {
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item.name}
|
||||
onContextMenu={item.builtin ? undefined : (e) => ctx.open(e, item)}
|
||||
onContextMenu={canWrite(item) ? (e) => ctx.open(e, item) : undefined}
|
||||
className="border-t border-border"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
|
||||
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
|
||||
{item.label}
|
||||
{item.scope === "personal" && (
|
||||
<span className="rounded bg-accent/15 px-1.5 py-px text-[10px] font-normal text-accent">
|
||||
{t("aiProxy.personal")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{item.builtin && (
|
||||
<span className="mt-0.5 block text-[10px] text-fg-muted">
|
||||
@@ -277,7 +298,7 @@ export function AiProxyConfig() {
|
||||
{item.builtin ? "—" : item.hasKey ? t("aiProxy.keySet") : t("aiProxy.keyMissing")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{!item.builtin && (
|
||||
{canWrite(item) && (
|
||||
<span className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -349,6 +370,31 @@ export function AiProxyConfig() {
|
||||
value={form.label}
|
||||
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
|
||||
/>
|
||||
{user && !editing && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="proxy-scope">
|
||||
{t("aiProxy.fieldScope")}
|
||||
</label>
|
||||
{isAdmin ? (
|
||||
<select
|
||||
id="proxy-scope"
|
||||
value={form.scope}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, scope: e.target.value as FormState["scope"] }))
|
||||
}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
<option value="shared">{t("aiProxy.scopeShared")}</option>
|
||||
<option value="personal">{t("aiProxy.scopePersonal")}</option>
|
||||
</select>
|
||||
) : (
|
||||
<p id="proxy-scope" className="text-xs text-fg-primary">
|
||||
{t("aiProxy.scopePersonal")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("aiProxy.fieldScopeHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
<Field
|
||||
id="proxy-base-url"
|
||||
label={t("aiProxy.fieldBaseUrl")}
|
||||
|
||||
@@ -140,7 +140,8 @@ const MOVED: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Sections that only configure the host (ADMIN_WRITE_PREFIXES in server/lib/auth.js). */
|
||||
const ADMIN_SECTIONS = new Set(["data", "ai-proxy", "pricing"]);
|
||||
// AI Proxy is not here: every user keeps personal profiles (S3).
|
||||
const ADMIN_SECTIONS = new Set(["data", "pricing"]);
|
||||
|
||||
/** An unknown section: say so, then show Appearance. */
|
||||
function UnknownSection({ name }: { name: string }) {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file Tests for the AI Proxy settings page — listing profiles, creating one,
|
||||
* and the two rules that keep a key safe: the form never shows one, and an
|
||||
* edit that leaves the field blank must not send an empty key that would wipe
|
||||
* the stored one.
|
||||
* the stored one. In server mode: a developer or analyst edits only their
|
||||
* personal profiles and adds personal ones, an admin picks shared or personal.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -12,6 +13,12 @@ import userEvent from "@testing-library/user-event";
|
||||
import { AiProxyConfig } from "../AiProxyConfig";
|
||||
import { api } from "../../lib/api";
|
||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
|
||||
import type { AuthState } from "../../lib/api";
|
||||
|
||||
let authState: AuthState = { mode: "local" };
|
||||
vi.mock("../../components/AuthGate", () => ({
|
||||
useAuth: () => ({ state: authState, logout: async () => {} }),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
@@ -60,6 +67,7 @@ const nineRouter = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
authState = { mode: "local" };
|
||||
vi.mocked(api.aiProxies.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [native] });
|
||||
@@ -286,3 +294,70 @@ describe("AiProxyConfig — testing an endpoint before saving it", () => {
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AiProxyConfig in server mode", () => {
|
||||
const shared = { ...nineRouter, scope: "shared" as const };
|
||||
const mine = {
|
||||
...nineRouter,
|
||||
name: "u7_mine",
|
||||
label: "Mine",
|
||||
scope: "personal" as const,
|
||||
};
|
||||
const signIn = (role: "admin" | "developer" | "analyst") => {
|
||||
authState = { mode: "server", user: { id: 7, username: "mia", role } };
|
||||
};
|
||||
|
||||
it("lets a developer edit only their personal profile, badged Personal", async () => {
|
||||
signIn("developer");
|
||||
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared, mine] });
|
||||
render(<AiProxyConfig />);
|
||||
await screen.findByText("Mine");
|
||||
expect(screen.getByText("aiProxy.personal")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /aiProxy\.edit 9Router/ })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: /aiProxy\.edit Mine/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a developer's profile as personal, with no scope choice", async () => {
|
||||
signIn("developer");
|
||||
const user = userEvent.setup();
|
||||
render(<AiProxyConfig />);
|
||||
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).queryByRole("combobox")).toBeNull();
|
||||
expect(within(dialog).getByText("aiProxy.scopePersonal")).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Mine");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://x:1");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "m");
|
||||
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
|
||||
await waitFor(() => expect(api.aiProxies.create).toHaveBeenCalled());
|
||||
expect(vi.mocked(api.aiProxies.create).mock.calls[0]?.[0].scope).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets an admin choose a personal profile", async () => {
|
||||
signIn("admin");
|
||||
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared] });
|
||||
const user = userEvent.setup();
|
||||
render(<AiProxyConfig />);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /aiProxy\.edit 9Router/ })
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /aiProxy\.add/ }));
|
||||
await user.selectOptions(screen.getByLabelText("aiProxy.fieldScope"), "personal");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Boss");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://x:1");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "m");
|
||||
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
|
||||
await waitFor(() => expect(api.aiProxies.create).toHaveBeenCalled());
|
||||
expect(vi.mocked(api.aiProxies.create).mock.calls[0]?.[0].scope).toBe("personal");
|
||||
});
|
||||
|
||||
it("gives an analyst Add and Edit on personal profiles only", async () => {
|
||||
signIn("analyst");
|
||||
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared, mine] });
|
||||
render(<AiProxyConfig />);
|
||||
await screen.findByText("Mine");
|
||||
expect(screen.getByRole("button", { name: /aiProxy\.add/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /aiProxy\.edit 9Router/ })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: /aiProxy\.edit Mine/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9444,7 +9444,7 @@ exit 0
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.
|
||||
Theme, language, notification settings and pet follow your account; the sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
@@ -9609,7 +9609,7 @@ exports[`screen snapshots > Settings — notifications 1`] = `
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
These notification settings are saved in this browser only. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.
|
||||
These notification settings are saved to your account. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.
|
||||
</p>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
|
||||
Reference in New Issue
Block a user