fa156d6a59
Chat UI: - Each Claude/Codex session keeps its own chat/terminal view; Settings only sets how new sessions open. - Composer + menu: upload, project files and folders (#54, also dragged from the Files/Changes tree), context files, recent uploads, skills, plugins. - Activity side panel (progress, outputs, files read, uploads, memory, skills, connectors) with a file preview; edited-file counts per turn. - Permission-mode chip (Shift+Tab steps, read back from the pane: server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode). - Stop (Esc) while the main agent works; the status is checked per agent (with session_id /api/agents ignores its status filter) and re-read every 5 s while Stop shows. - Dropped images shown in the conversation (GET /api/run/drop/:name, the requester's own drop dir), full-size viewer. - A plain-text paste over 5,000 characters is attached as pasted-text.txt, so no prompt reaches tmux/argv limits (#68). Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the whole panel to a workspace's extra folder (#70). Server mode S3 (one Linux user per account) — experimental, verified with fake sudo plus a P6 managed-settings spike on a test account: - member tmux listing runs in the background (cached, 15 s => unknown); - transcript snapshot copy is async, chunked, temp file + rename; - Express Layer patch only on Express 4; - tmux capturePane/sendKey go to the member's own server. Docs: the multi-user design moved in as docs/plans/server-mode/README.md (the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
544 lines
19 KiB
TypeScript
544 lines
19 KiB
TypeScript
/**
|
|
* @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("/");
|
|
};
|
|
|
|
// 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>
|
|
);
|
|
}
|