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:
2026-10-09 14:57:10 +07:00
parent b7f3735b29
commit 84e2a9f0ac
123 changed files with 12051 additions and 605 deletions
+15
View File
@@ -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 })} />;
+4
View File
@@ -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
+12 -2
View File
@@ -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();
}
});
});
+1
View File
@@ -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>
+4 -1
View File
@@ -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) => ({
+1 -1
View File
@@ -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>
);
}