diff --git a/packages/web/src/app.tsx b/packages/web/src/app.tsx index 6e32e648d..9674b9c04 100644 --- a/packages/web/src/app.tsx +++ b/packages/web/src/app.tsx @@ -108,6 +108,14 @@ const OnboardingPreviewPage = import.meta.env.DEV ? lazy(() => import("./pages/onboarding-preview.js").then((module) => ({ default: module.OnboardingPreviewPage }))) : null; +const OpenTagOnboardingPreviewPage = import.meta.env.DEV + ? lazy(() => + import("./pages/opentag-onboarding-preview.js").then((module) => ({ + default: module.OpenTagOnboardingPreviewPage, + })), + ) + : null; + const OnboardingOrientationPreviewPage = import.meta.env.DEV ? lazy(() => import("./pages/onboarding-orientation-preview.js").then((module) => ({ @@ -412,6 +420,16 @@ export function App() { } /> ) : null} + {OpenTagOnboardingPreviewPage ? ( + + + + } + /> + ) : null} {OnboardingOrientationPreviewPage ? ( = { + focus: { + number: 1, + group: "Create agent", + railTitle: "Shape your agent", + railDetail: "Focus & name", + title: "What should your agent do?", + lead: "Choose the kind of work you want to delegate. We'll use the matching template as its starting setup, and you can refine it later.", + }, + computer: { + number: 2, + group: "Create agent", + railTitle: "Set up its runtime", + railDetail: "Computer & coding agent", + title: "Connect your computer", + lead: "Connect or choose the computer where your agent will work. We'll use an available coding agent on it.", + }, + feishu: { + number: 3, + group: "Start in Feishu", + railTitle: "Add it to Feishu", + railDetail: "Connect its bot", + title: "Add OpenTag to Feishu", + lead: "Create the bot, then confirm it in Feishu.", + }, + "first-task": { + number: 4, + group: "Start in Feishu", + railTitle: "Start working together", + railDetail: "First task", + title: "Send your first task", + lead: "Send the bot a private message, or @mention it in a group. This page updates automatically.", + }, +}; + +const FOCUS_OPTIONS = [ + { + slug: "team-assistant", + name: "Team Assistant", + tagline: "For team questions, decisions, and follow-through.", + example: "Summarize today's decisions for the wider team", + }, + { + slug: "software-engineer", + name: "Software Engineer", + tagline: "For debugging, code review, and implementation planning.", + example: "Turn the requirements from this discussion into an implementation plan.", + }, + { + slug: "researcher", + name: "Researcher", + tagline: "For evidence gathering, comparison, and decision support.", + example: "Research this market and identify the main competitors and differentiators.", + }, +] as const; + +const DEFAULT_STATE: Record = { + focus: "ready", + computer: "no-computer", + feishu: "ready", + "first-task": "waiting", +}; + +// Opaque deterministic fixture for the server-authored `bootstrapCommand`. +// The Preview passes it through unchanged; production OpenTag receives the +// real release-channel-aware value from `useComputerConnection`. +const PREVIEW_SERVER_BOOTSTRAP_COMMAND = + "curl -fsSL https://download.first-tree.ai/releases/prod/install.sh | sh\n" + + "~/.local/bin/first-tree login ft_preview_only"; +const PREVIEW_FEISHU_URL = "https://open.feishu.cn/app/preview-only-registration"; +const PREVIEW_FIRST_USE_CHAT_ID = "preview-first-feishu-task"; + +const PREVIEW_COMPUTERS = [ + previewComputer("preview-macbook", "Gandy's MacBook", "darwin", "2026-08-13T10:00:00.000Z"), + previewComputer("preview-studio", "Team Studio", "linux", "2026-08-13T09:00:00.000Z"), +]; + +const PREVIEW_CODING_AGENTS: Readonly> = { + "preview-macbook": ["claude-code", "codex"], + "preview-studio": ["claude-code"], +}; + +function previewComputer(id: string, hostname: string, os: string, lastSeenAt: string): HubClient { + return { + id, + userId: null, + status: "connected", + authState: "ok", + binName: "first-tree", + sdkVersion: null, + hostname, + os, + agentCount: 0, + connectedAt: lastSeenAt, + lastSeenAt, + capabilities: {}, + }; +} +export function OpenTagOnboardingPreviewPage(): ReactElement { + const [searchParams, setSearchParams] = useSearchParams(); + const step = parseStep(searchParams.get("step")); + const state = parseState(step, searchParams.get("state")); + const viewport = parseViewport(searchParams.get("viewport")); + + useEffect(() => { + const previousTitle = document.title; + document.title = "OpenTag onboarding preview"; + return () => { + document.title = previousTitle; + }; + }, []); + + const setPreview = (next: { step?: PreviewStep; state?: PreviewState; viewport?: PreviewViewport }): void => { + const nextStep = next.step ?? step; + const nextState = next.state ?? (next.step ? DEFAULT_STATE[nextStep] : state); + setSearchParams({ step: nextStep, state: nextState, viewport: next.viewport ?? viewport }, { replace: true }); + }; + + return ( +
+
+ + setPreview({ step: nextStep })} + onStateChange={(nextState) => setPreview({ state: nextState })} + /> + +
+ setPreview({ step: nextStep })} + onStateChange={(nextState) => setPreview({ state: nextState })} + onViewportChange={(nextViewport) => setPreview({ viewport: nextViewport })} + /> +
+ ); +} + +function PreviewShell({ + step, + state, + viewport, + children, +}: { + step: PreviewStep; + state: PreviewState; + viewport: PreviewViewport; + children: ReactNode; +}): ReactElement { + const meta = STEP_META[step]; + const pageCopy = + step === "first-task" && state === "completed" + ? { + title: "Your first task is ready", + lead: "Keep the conversation in Feishu. You can follow the work and its full history here.", + } + : meta; + const mobile = viewport === "mobile"; + return ( +
+
+ + +
+ +
+ {mobile ? : } +
+

+ Step {meta.number} of 4 · {meta.group} +

+

+ {pageCopy.title} +

+

+ {pageCopy.lead} +

+ {children} +
+
+
+ ); +} + +function DesktopRail({ step }: { step: PreviewStep }): ReactElement { + return ( + + ); +} + +function RailGroup({ + label, + steps, + activeStep, +}: { + label: string; + steps: readonly PreviewStep[]; + activeStep: PreviewStep; +}): ReactElement { + return ( +
+

+ {label} +

+
    + {steps.map((candidate) => { + const meta = STEP_META[candidate]; + const active = candidate === activeStep; + const complete = meta.number < STEP_META[activeStep].number; + return ( +
  1. + + + + {meta.railTitle} + + + {meta.railDetail} + + +
  2. + ); + })} +
+
+ ); +} + +function MobileProgress({ activeIndex }: { activeIndex: number }): ReactElement { + return ( +
+ {PREVIEW_STEPS.map((step, index) => ( +
+ ); +} + +function StepContent({ + step, + state, + viewport, + onAdvance, + onStateChange, +}: { + step: PreviewStep; + state: PreviewState; + viewport: PreviewViewport; + onAdvance: (step: PreviewStep) => void; + onStateChange: (state: PreviewState) => void; +}): ReactElement { + switch (step) { + case "focus": + return onAdvance("computer")} />; + case "computer": + return ( + onAdvance("feishu")} + onStateChange={onStateChange} + /> + ); + case "feishu": + return state === "provisioning" ? ( + + ) : ( + onStateChange("provisioning")} /> + ); + case "first-task": + return state === "completed" ? : ; + } +} + +function FocusAndNameStep({ mobile, onAdvance }: { mobile: boolean; onAdvance: () => void }): ReactElement { + const [focus, setFocus] = useState<(typeof FOCUS_OPTIONS)[number]["slug"]>("team-assistant"); + const [name, setName] = useState("gandy2025 assistant"); + const selected = FOCUS_OPTIONS.find((option) => option.slug === focus) ?? FOCUS_OPTIONS[0]; + return ( +
+
+ Work focus + {FOCUS_OPTIONS.map((option) => ( + setFocus(option.slug)} + > + + + {option.name} + {option.slug === "team-assistant" ? ( + + Best for most teams + + ) : null} + + + {option.tagline} + + + + ))} +
+ + {selected.example} + +
+ + setName(event.target.value)} /> +
+ +
+ +
+
+ ); +} + +function ComputerStep({ + state, + mobile, + onAdvance, + onStateChange, +}: { + state: ComputerPreviewState; + mobile: boolean; + onAdvance: () => void; + onStateChange: (state: PreviewState) => void; +}): ReactElement { + if (state === "no-computer" || state === "command-error") { + return ( + onStateChange("no-computer")} /> + ); + } + + const computers = state === "multiple-computers" ? PREVIEW_COMPUTERS : PREVIEW_COMPUTERS.slice(0, 1); + return ( + + ); +} + +function ComputerRecoveryStep({ commandError, onRetry }: { commandError: boolean; onRetry: () => void }): ReactElement { + return ( +
+
+

+ Connect your computer +

+

+ Connect the computer where you want this agent to work. +

+ {commandError ? ( + + We couldn't prepare the setup command. + + ) : ( + <> +

+ Open Terminal on that computer and run: +

+ + + )} +
+ {commandError ? ( + + ) : ( + + )} + +
+ ); +} + +function ComputerSelectionStep({ + computers, + capabilitiesLoaded, + noCodingAgent, + mobile, + onAdvance, +}: { + computers: HubClient[]; + capabilitiesLoaded: boolean; + noCodingAgent: boolean; + mobile: boolean; + onAdvance: () => void; +}): ReactElement { + const clientIds = useMemo(() => computers.map((computer) => computer.id), [computers]); + const [currentClientId, setCurrentClientId] = useState(null); + const [explicitClientId, setExplicitClientId] = useState(null); + const selectedClientId = resolveComputerSelection({ + clientIds, + currentClientId, + explicitlySelectedClientId: explicitClientId, + requireExplicitWhenMultiple: true, + }); + const selectedComputer = computers.find((computer) => computer.id === selectedClientId) ?? null; + const orderedCodingAgents = useMemo( + () => + selectedClientId && capabilitiesLoaded && !noCodingAgent + ? orderRuntimeProvidersByPreference(PREVIEW_CODING_AGENTS[selectedClientId] ?? []) + : [], + [capabilitiesLoaded, noCodingAgent, selectedClientId], + ); + const [currentCodingAgent, setCurrentCodingAgent] = useState(null); + const [codingAgentSelectionIsManual, setCodingAgentSelectionIsManual] = useState(false); + const codingAgentSelection = resolveRuntimeSelection({ + currentRuntime: currentCodingAgent, + selectionIsManual: codingAgentSelectionIsManual, + readyRuntimes: orderedCodingAgents, + }); + + useEffect(() => { + setCurrentClientId((current) => (current === selectedClientId ? current : selectedClientId)); + }, [selectedClientId]); + + useEffect(() => { + setCurrentCodingAgent((current) => + current === codingAgentSelection.runtime ? current : codingAgentSelection.runtime, + ); + setCodingAgentSelectionIsManual((current) => + current === codingAgentSelection.selectionIsManual ? current : codingAgentSelection.selectionIsManual, + ); + }, [codingAgentSelection.runtime, codingAgentSelection.selectionIsManual]); + + const selectedCodingAgent = codingAgentSelection.runtime; + const canCreate = + !!selectedClientId && + !!selectedCodingAgent && + orderedCodingAgents.includes(selectedCodingAgent) && + capabilitiesLoaded; + + return ( +
+
+ + {computers.length > 1 ? "Choose a computer" : "Computer"} + + {computers.length > 1 ? ( + { + setExplicitClientId(clientId); + setCurrentClientId(clientId); + }} + id="preview-opentag-runtime-computer" + /> + ) : selectedComputer ? ( + + ) : null} +
+ +
+ + Coding agent + + {selectedComputer ? ( + !capabilitiesLoaded ? ( +

+ Checking available coding agents… +

+ ) : orderedCodingAgents.length === 0 ? ( + + No supported coding agent was found on this computer. Install one, then we'll check again. + + ) : ( +
+ {orderedCodingAgents.map((provider) => ( + { + setCurrentCodingAgent(provider); + setCodingAgentSelectionIsManual(true); + }} + > + {runtimeProviderLabel(provider)} + + ))} +
+ ) + ) : null} +
+ + gandy2025 assistant · Team Assistant +
+ +
+
+ ); +} + +function FeishuReadyStep({ mobile, onConnect }: { mobile: boolean; onConnect: () => void }): ReactElement { + return ( +
+
+ +
+
+ ); +} + +function FeishuProvisioningStep(): ReactElement { + return ( +
+ + +
+ ); +} + +function PreviewFeishuRegistrationQr(): ReactElement { + return ( +
+ +
+ Scan with Feishu and confirm creating the bot. This page updates automatically. +
+ +
+ ); +} + +function FirstTaskWaitingStep(): ReactElement { + return ( +
+ +
+ ); +} + +function FirstTaskCompletedStep(): ReactElement { + return ( +
+ + +
+ ); +} + +function LabeledDetail({ label, children }: { label: string; children: ReactNode }): ReactElement { + return ( +
+

+ {label} +

+

+ {children} +

+
+ ); +} + +function OpenTagBrand(): ReactElement { + return ( + + + ); +} + +function PreviewStatusRow({ state, label }: { state: "waiting" | "ok"; label: ReactNode }): ReactElement { + return ( +
+ {state === "ok" ? ( +
+ ); +} + +function PreviewController({ + step, + state, + viewport, + onStepChange, + onStateChange, + onViewportChange, +}: { + step: PreviewStep; + state: PreviewState; + viewport: PreviewViewport; + onStepChange: (step: PreviewStep) => void; + onStateChange: (state: PreviewState) => void; + onViewportChange: (viewport: PreviewViewport) => void; +}): ReactElement { + return ( + + ); +} + +function PreviewSelect({ + label, + value, + onChange, + children, +}: { + label: string; + value: string; + onChange: (value: string) => void; + children: ReactNode; +}): ReactElement { + return ( + + ); +} + +function parseStep(value: string | null): PreviewStep { + return PREVIEW_STEPS.find((step) => step === value) ?? "focus"; +} + +function parseState(step: PreviewStep, value: string | null): PreviewState { + return STATE_OPTIONS[step].find((state) => state.value === value)?.value ?? DEFAULT_STATE[step]; +} + +function parseComputerState(value: PreviewState): ComputerPreviewState { + return STATE_OPTIONS.computer.find((state) => state.value === value)?.value ?? "no-computer"; +} + +function parseViewport(value: string | null): PreviewViewport { + return value === "mobile" ? "mobile" : "desktop"; +}