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.
240 lines
8.7 KiB
TypeScript
240 lines
8.7 KiB
TypeScript
/**
|
|
* @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 `data-tour` marks in AddLaneModal and
|
|
* FolderBrowseModal. 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: {
|
|
// The sidebar's +; folded to a rail, its tree icon (which unfolds it).
|
|
target: () =>
|
|
shown('[data-testid="workspace-tree-add"]') || shown('[data-testid="sidebar-tree-rail"]'),
|
|
next: () => (modalOpen() ? "folder" : null),
|
|
},
|
|
folder: {
|
|
// The folder browser while it is open: it sits over the field.
|
|
target: () => $('[data-tour="folder-browse"]') || $('[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();
|
|
let r = el ? el.getBoundingClientRect() : null;
|
|
// The folder field's suggestions hang below it: light them too.
|
|
const list = el?.querySelector("[data-cwd-suggestions]")?.getBoundingClientRect();
|
|
if (r && list) {
|
|
const top = Math.min(r.top, list.top);
|
|
const left = Math.min(r.left, list.left);
|
|
r = new DOMRect(
|
|
left,
|
|
top,
|
|
Math.max(r.right, list.right) - left,
|
|
Math.max(r.bottom, list.bottom) - top
|
|
);
|
|
}
|
|
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
|
|
);
|
|
}
|