From cdec800294562bb2af586c62d5de27de9e23a1e3 Mon Sep 17 00:00:00 2001 From: Gandy2025 Date: Tue, 4 Aug 2026 12:06:49 +0800 Subject: [PATCH] feat(web): refine onboarding goal flow --- .../__tests__/new-agent-dialog-extra.test.tsx | 8 + .../web/src/components/new-agent-dialog.tsx | 47 +--- .../__tests__/setup-hooks.test.tsx | 17 +- .../agent-setup/runtime-preference.ts | 20 ++ .../agent-setup/use-computer-connection.ts | 29 +-- .../__tests__/onboarding-preview.test.tsx | 133 +++++++++- .../pages/__tests__/page-ssr-smoke.test.tsx | 8 +- .../__tests__/preview-pages-extra.test.tsx | 26 -- .../__tests__/web-dom-interactions.test.tsx | 72 +++--- packages/web/src/pages/onboarding-preview.tsx | 109 +++++--- .../src/pages/onboarding-team-steps-mocks.tsx | 241 ------------------ .../pages/onboarding/__tests__/copy.test.ts | 50 ++-- packages/web/src/pages/onboarding/copy.ts | 96 +++---- .../step-connect-computer-dom.test.tsx | 17 +- .../__tests__/step-get-started-dom.test.tsx | 2 +- .../steps/step-connect-computer.tsx | 37 ++- .../onboarding/steps/step-create-agent.tsx | 140 +++++----- .../onboarding/steps/step-start-chat.tsx | 31 --- 18 files changed, 478 insertions(+), 605 deletions(-) create mode 100644 packages/web/src/features/agent-setup/runtime-preference.ts delete mode 100644 packages/web/src/pages/onboarding-team-steps-mocks.tsx diff --git a/packages/web/src/components/__tests__/new-agent-dialog-extra.test.tsx b/packages/web/src/components/__tests__/new-agent-dialog-extra.test.tsx index 1a0628a5e..42b54d423 100644 --- a/packages/web/src/components/__tests__/new-agent-dialog-extra.test.tsx +++ b/packages/web/src/components/__tests__/new-agent-dialog-extra.test.tsx @@ -114,6 +114,7 @@ function client(overrides: Partial = {}): HubClient { "claude-code-tui": capability("ok"), codex: capability("ok"), future: capability("ok"), + pi: capability("ok"), }, }; } @@ -263,6 +264,13 @@ describe("NewAgentDialog extra branches", () => { await waitForText(container, "gandy-macbook"); expect(document.body.textContent).toContain("Claude Code"); + const initialRuntimeInputs = [...document.body.querySelectorAll('input[name="runtime"]')]; + expect(initialRuntimeInputs.map((input) => input.closest("label")?.textContent)).toEqual([ + expect.stringContaining("Codex"), + expect.stringContaining("Claude Code"), + expect.stringContaining("Pi"), + ]); + expect(initialRuntimeInputs.find((input) => input.checked)?.closest("label")?.textContent).toContain("Codex"); await setValue(inputById("new-agent-display-name"), "Build Bot"); await waitForCondition(() => agentMocks.checkAgentNameAvailability.mock.calls.length > 0, "Expected probe"); await waitForText(container, "@build-bot"); diff --git a/packages/web/src/components/new-agent-dialog.tsx b/packages/web/src/components/new-agent-dialog.tsx index d1789f24c..58a4e7c40 100644 --- a/packages/web/src/components/new-agent-dialog.tsx +++ b/packages/web/src/components/new-agent-dialog.tsx @@ -19,6 +19,10 @@ import { listAgentTemplates } from "../api/agent-templates.js"; import { checkAgentNameAvailability, createAgent } from "../api/agents.js"; import { ApiError, api, type ValidationIssue } from "../api/client.js"; import { useAuth } from "../auth/auth-context.js"; +import { + orderRuntimesByPreference, + pickPreferredRuntime as pickPreferredRuntimeFromList, +} from "../features/agent-setup/runtime-preference.js"; import { useCopyFeedback } from "../lib/use-copy-feedback.js"; import { runVisibilityAwareInterval } from "../lib/visibility-interval.js"; import { slugify } from "../utils/agent-naming.js"; @@ -161,37 +165,6 @@ function asRuntimeProvider(provider: string): RuntimeProvider | null { return null; } -/** - * Pick the preferred runtime among the ones in `ok` state on a given - * client. Claude Code wins over Claude Code CLI which wins over Codex; - * if none of those is ok we fall back to whatever else the client reports - * as ok (still narrowed to a known RuntimeProvider), then `null`. - */ -function pickPreferredRuntime(caps: ClientCapabilities): RuntimeProvider | null { - if (caps["claude-code"]?.state === "ok") return "claude-code"; - // Keep the documented Claude Code → Claude Code CLI → Codex priority, but guard - // the TUI branch on the central switch: disabled today (short-circuits, so a - // stale `ok` snapshot is skipped and Codex wins), yet removing it from - // DISABLED_RUNTIME_PROVIDERS restores its priority over Codex in one line. - if (isRuntimeProviderEnabled("claude-code-tui") && caps["claude-code-tui"]?.state === "ok") return "claude-code-tui"; - if (caps.codex?.state === "ok") return "codex"; - // Same central-switch guard as the TUI line: a stale `ok` snapshot from a - // daemon must not auto-pick a provider that has since been disabled. - if (isRuntimeProviderEnabled("cursor") && caps.cursor?.state === "ok") return "cursor"; - if (isRuntimeProviderEnabled("grok") && caps.grok?.state === "ok") return "grok"; - if (isRuntimeProviderEnabled("opencode") && caps.opencode?.state === "ok") return "opencode"; - if (isRuntimeProviderEnabled("pi") && caps.pi?.state === "ok") return "pi"; - // Any other provider (incl. one still disabled in a stale snapshot) is only - // auto-picked when enabled. - for (const [provider, entry] of Object.entries(caps)) { - if (entry.state === "ok") { - const rt = asRuntimeProvider(provider); - if (rt && isRuntimeProviderEnabled(rt)) return rt; - } - } - return null; -} - function prettyRuntimeLabel(provider: RuntimeProvider): string { if (provider === "claude-code") return "Claude Code"; if (provider === "claude-code-tui") return "Claude Code CLI"; @@ -251,7 +224,7 @@ export function NewAgentDialog({ open, onOpenChange, onCreated, initialTemplateS // the team roster, which surprised users who expected new agents to be // personal until explicitly shared. const [visibility, setVisibility] = useState("private"); - const [runtime, setRuntime] = useState("claude-code"); + const [runtime, setRuntime] = useState("codex"); // Handle resolution. The slug follows the display name (auto-deduped on // collision); `resolvedHandle` is the winner. `manualHandle` is only used @@ -348,7 +321,7 @@ export function NewAgentDialog({ open, onOpenChange, onCreated, initialTemplateS if (open) { setDisplayName(""); setVisibility("private"); - setRuntime("claude-code"); + setRuntime("codex"); setResolvedHandle(""); setHandleState({ status: "idle" }); setManualHandle(""); @@ -616,7 +589,7 @@ export function NewAgentDialog({ open, onOpenChange, onCreated, initialTemplateS // selectable runtime, even if a stale snapshot still reports them `ok`. if (rt && isRuntimeProviderEnabled(rt)) out.push(rt); } - return out; + return orderRuntimesByPreference(out); }, [activeCapabilities]); // Realign the runtime selection whenever the picked client's capabilities @@ -625,10 +598,10 @@ export function NewAgentDialog({ open, onOpenChange, onCreated, initialTemplateS useEffect(() => { if (!activeCapabilities) return; setRuntime((prev) => { - if (activeCapabilities[prev]?.state === "ok") return prev; - return pickPreferredRuntime(activeCapabilities) ?? prev; + if (okRuntimes.includes(prev)) return prev; + return pickPreferredRuntimeFromList(okRuntimes) ?? prev; }); - }, [activeCapabilities]); + }, [activeCapabilities, okRuntimes]); // The handle that will actually be submitted: the auto-resolved one, or the // user's manual fallback (slugified) when no handle could be derived. diff --git a/packages/web/src/features/agent-setup/__tests__/setup-hooks.test.tsx b/packages/web/src/features/agent-setup/__tests__/setup-hooks.test.tsx index 6c78a6afc..c1b66e7dc 100644 --- a/packages/web/src/features/agent-setup/__tests__/setup-hooks.test.tsx +++ b/packages/web/src/features/agent-setup/__tests__/setup-hooks.test.tsx @@ -5,9 +5,9 @@ import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { MemoryRouter } from "react-router"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { orderRuntimesByPreference, pickPreferredRuntime } from "../runtime-preference.js"; import { useAgentCreation } from "../use-agent-creation.js"; -import type { ComputerConnection } from "../use-computer-connection.js"; -import { useComputerConnection } from "../use-computer-connection.js"; +import { type ComputerConnection, useComputerConnection } from "../use-computer-connection.js"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -114,6 +114,19 @@ afterEach(async () => { }); describe("shared setup hooks", () => { + it("uses one Codex-first runtime preference across setup surfaces", () => { + expect(orderRuntimesByPreference(["opencode", "claude-code", "future-provider", "codex"])).toEqual([ + "codex", + "claude-code", + "opencode", + "future-provider", + ]); + expect(pickPreferredRuntime(["claude-code", "codex", "opencode"])).toBe("codex"); + expect(pickPreferredRuntime(["claude-code", "opencode"])).toBe("claude-code"); + expect(pickPreferredRuntime(["opencode", "future-provider"])).toBe("opencode"); + expect(pickPreferredRuntime([])).toBeNull(); + }); + it("detects connected computers and picks a ready runtime without onboarding state", async () => { const latest = { current: null as ComputerConnection | null }; const client = { diff --git a/packages/web/src/features/agent-setup/runtime-preference.ts b/packages/web/src/features/agent-setup/runtime-preference.ts new file mode 100644 index 000000000..5a368398d --- /dev/null +++ b/packages/web/src/features/agent-setup/runtime-preference.ts @@ -0,0 +1,20 @@ +const RUNTIME_PREFERENCE = ["codex", "claude-code"] as const; + +/** + * Keep runtime choices consistent anywhere a member creates an agent: + * Codex first, Claude Code second, then every other ready option in the order + * reported by the connected computer. + */ +export function orderRuntimesByPreference(providers: readonly T[]): T[] { + const preferred = RUNTIME_PREFERENCE.filter((provider): provider is (typeof RUNTIME_PREFERENCE)[number] & T => + providers.includes(provider as T), + ); + const remaining = providers.filter( + (provider) => !RUNTIME_PREFERENCE.some((preferredProvider) => preferredProvider === provider), + ); + return [...preferred, ...remaining]; +} + +export function pickPreferredRuntime(providers: readonly T[]): T | null { + return orderRuntimesByPreference(providers)[0] ?? null; +} diff --git a/packages/web/src/features/agent-setup/use-computer-connection.ts b/packages/web/src/features/agent-setup/use-computer-connection.ts index 3b16da280..8f03abb74 100644 --- a/packages/web/src/features/agent-setup/use-computer-connection.ts +++ b/packages/web/src/features/agent-setup/use-computer-connection.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { type ConnectTokenResponse, getClientCapabilities, type HubClient, listClients } from "../../api/activity.js"; import { api } from "../../api/client.js"; import { runVisibilityAwareInterval } from "../../lib/visibility-interval.js"; +import { orderRuntimesByPreference, pickPreferredRuntime } from "./runtime-preference.js"; const CLIENT_DETECT_POLL_MS = 5_000; @@ -15,7 +16,7 @@ const CLIENT_DETECT_POLL_MS = 5_000; * the user pastes into their terminal). * 2. Poll `listClients()`; the most-recently-seen connected client wins. * 3. Once a client is connected, fetch its capabilities to learn which AI - * runtimes are ready, and auto-pick the best one (Claude Code → Codex). + * runtimes are ready, and auto-pick the best one (Codex → Claude Code). * * Pure presentation state is returned; the React step renders it. Polling * pauses while the tab is hidden (`runVisibilityAwareInterval`) and stops @@ -55,17 +56,14 @@ export type UseComputerConnectionOptions = { /** Silent auto-retries before surfacing a token-mint failure to the user. */ const TOKEN_MINT_ATTEMPTS = 3; const TOKEN_MINT_BACKOFF_MS = [600, 1500]; - -function pickPreferredRuntime(caps: ClientCapabilities): string | null { - const ok = (provider: string) => caps[provider]?.state === "ok"; - if (ok("claude-code")) return "claude-code"; - if (ok("codex")) return "codex"; - // Never fall back to a temporarily-disabled provider, even if a stale snapshot +function listReadyRuntimes(caps: ClientCapabilities): string[] { + // Never include a temporarily-disabled provider, even if a stale snapshot // still reports it `ok`. - const first = Object.entries(caps).find( - ([provider, entry]) => entry.state === "ok" && isRuntimeProviderEnabled(provider), + return orderRuntimesByPreference( + Object.entries(caps) + .filter(([provider, entry]) => entry.state === "ok" && isRuntimeProviderEnabled(provider)) + .map(([provider]) => provider), ); - return first ? first[0] : null; } function hasReportedCapabilities(caps: ClientCapabilities | null): caps is ClientCapabilities { @@ -218,16 +216,13 @@ export function useComputerConnection( useEffect(() => { setSelectedRuntime((prev) => { if (!activeCapabilities) return prev; - if (prev && activeCapabilities[prev]?.state === "ok") return prev; - return pickPreferredRuntime(activeCapabilities); + const ready = listReadyRuntimes(activeCapabilities); + if (prev && ready.includes(prev)) return prev; + return pickPreferredRuntime(ready); }); }, [activeCapabilities]); - const okRuntimes = activeCapabilities - ? Object.entries(activeCapabilities) - .filter(([provider, entry]) => entry.state === "ok" && isRuntimeProviderEnabled(provider)) - .map(([provider]) => provider) - : []; + const okRuntimes = activeCapabilities ? listReadyRuntimes(activeCapabilities) : []; const cliCommand = bootstrapCommand; diff --git a/packages/web/src/pages/__tests__/onboarding-preview.test.tsx b/packages/web/src/pages/__tests__/onboarding-preview.test.tsx index 16abeca14..bdd0e8ca3 100644 --- a/packages/web/src/pages/__tests__/onboarding-preview.test.tsx +++ b/packages/web/src/pages/__tests__/onboarding-preview.test.tsx @@ -95,7 +95,7 @@ describe("onboarding preview review surface", () => { "Create team", "Connect computer", "Create agent", - "Start chat", + "Meet your agent", ]); expect(adminFlow.some((scenario) => scenario.wizard?.step === "connect-code")).toBe(false); }); @@ -268,6 +268,135 @@ describe("onboarding preview review surface", () => { ).toBe(true); }); + it("keeps only the progressive concept experiments for both roles", async () => { + const { ONBOARDING_PREVIEW_SCENARIOS } = await import("../onboarding-preview.js"); + + const experimentIds = (role: "admin" | "invitee") => + ONBOARDING_PREVIEW_SCENARIOS.filter((scenario) => scenario.role === role && scenario.view === "experiments").map( + (scenario) => scenario.id, + ); + + expect(experimentIds("admin")).toEqual([ + "admin-concept-connect-computer", + "admin-concept-create-agent", + "admin-concept-start-chat", + ]); + expect(experimentIds("invitee")).toEqual([ + "inv-concept-connect-computer", + "inv-concept-create-agent", + "inv-concept-start-chat", + ]); + + const catalog = ONBOARDING_PREVIEW_SCENARIOS.flatMap((scenario) => [scenario.id, scenario.group]).join("\n"); + expect(catalog).not.toContain("Create-team experiments"); + expect(catalog).not.toContain("admin-team-steps"); + expect(catalog).not.toContain("admin-welcome-ceremonial"); + }); + + it("uses the accepted concept copy in both focused previews and the live flow", async () => { + const { OnboardingPreviewPage } = await import("../onboarding-preview.js"); + + window.history.replaceState( + null, + "", + "/preview/onboarding?role=admin&view=experiments&scenario=admin-concept-connect-computer", + ); + const connect = await renderDom( + + + , + ); + expect(connect.container.textContent).toContain( + "Install the First Tree app to connect this computer and detect what your agents can run.", + ); + expect(connect.container.textContent).not.toContain("does not run a task or open any project files"); + expect(connect.container.textContent).toContain("Run this command in your terminal"); + expect(connect.container.textContent).not.toContain("Or paste this into your AI coding tool"); + expect(connect.container.textContent).not.toContain("Or paste this to your Claude Code, Codex, or Cursor agent"); + await act(async () => connect.root.unmount()); + + window.history.replaceState( + null, + "", + "/preview/onboarding?role=admin&view=experiments&scenario=admin-concept-create-agent", + ); + const create = await renderDom( + + + , + ); + expect(create.container.textContent).toContain( + "Build your own group of agents for different work in this team. Let’s create your first one.", + ); + expect(create.container.textContent).toContain("This agent will run"); + expect(create.container.textContent).not.toContain("Each agent can use a different tool."); + expect(create.container.textContent).toContain("Claude Code"); + expect(create.container.textContent).toContain("Codex"); + expect(create.container.textContent).toContain("OpenCode"); + expect(create.container.textContent).toContain("Pi"); + expect( + create.container + .querySelector('input[name="onboarding-coding-agent"]:checked') + ?.closest("label")?.textContent, + ).toContain("Codex"); + const createText = create.container.textContent ?? ""; + expect(createText.indexOf("Name your agent")).toBeLessThan(createText.indexOf("This agent will run")); + expect(createText.indexOf("This agent will run")).toBeLessThan(createText.indexOf("Who can use it?")); + expect(createText.indexOf("Codex")).toBeLessThan(createText.indexOf("Claude Code")); + expect(createText.indexOf("Claude Code")).toBeLessThan(createText.indexOf("OpenCode")); + expect(createText.indexOf("OpenCode")).toBeLessThan(createText.indexOf("Pi")); + await act(async () => create.root.unmount()); + + window.history.replaceState( + null, + "", + "/preview/onboarding?role=admin&view=experiments&scenario=admin-concept-start-chat", + ); + const start = await renderDom( + + + , + ); + expect(start.container.textContent).toContain("Meet your agent"); + expect(start.container.textContent).toContain( + "Explore First Tree together, then choose what you’d like to try first.", + ); + expect(start.container.textContent).toContain("Start exploring"); + expect(start.container.textContent).not.toContain("Stay connected"); + expect(start.container.textContent).not.toContain("WeChat group"); + expect(start.container.textContent).not.toContain("Discord"); + await act(async () => start.root.unmount()); + + window.history.replaceState(null, "", "/preview/onboarding?role=admin&view=states&scenario=admin-ko-noproject"); + const liveStart = await renderDom( + + + , + ); + expect(liveStart.container.textContent).toContain("Meet your agent"); + expect(liveStart.container.textContent).toContain( + "Explore First Tree together, then choose what you’d like to try first.", + ); + expect(liveStart.container.textContent).toContain("Start exploring"); + expect(liveStart.container.textContent).not.toContain("Stay connected"); + await act(async () => liveStart.root.unmount()); + + window.history.replaceState(null, "", "/preview/onboarding?role=admin&view=flow&scenario=admin-ca-form"); + const live = await renderDom( + + + , + ); + expect(live.container.textContent).toContain( + "Build your own group of agents for different work in this team. Let’s create your first one.", + ); + expect(live.container.textContent).toContain("This agent will run"); + const liveText = live.container.textContent ?? ""; + expect(liveText.indexOf("Name your agent")).toBeLessThan(liveText.indexOf("This agent will run")); + expect(liveText.indexOf("This agent will run")).toBeLessThan(liveText.indexOf("Who can use it?")); + await act(async () => live.root.unmount()); + }); + it("does not repeat the BYO choice after the member selected a First Tree agent", async () => { authMock.memberships = [{}]; window.history.replaceState(null, "", "/preview/onboarding?role=invitee&view=flow&scenario=inv-ko-ready"); @@ -279,7 +408,7 @@ describe("onboarding preview review surface", () => { , ); - await waitForText(container, "Start your first Agent Chat"); + await waitForText(container, "Meet your agent"); expect(container.textContent).not.toContain("Use Team Context in your coding agent"); expect(container.textContent).not.toContain("Copy setup prompt"); expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); diff --git a/packages/web/src/pages/__tests__/page-ssr-smoke.test.tsx b/packages/web/src/pages/__tests__/page-ssr-smoke.test.tsx index 2402b5c47..dc6a0e474 100644 --- a/packages/web/src/pages/__tests__/page-ssr-smoke.test.tsx +++ b/packages/web/src/pages/__tests__/page-ssr-smoke.test.tsx @@ -1332,9 +1332,7 @@ describe("page SSR smoke coverage", () => { expect(await renderOnboardingStep(, { activeStep: "connect-code" })).toContain( "Loading your repos", ); - expect(await renderOnboardingStep(, { activeStep: "start-chat" })).toContain( - "Start your first Agent Chat", - ); + expect(await renderOnboardingStep(, { activeStep: "start-chat" })).toContain("Meet your agent"); expect( await renderOnboardingStep(, { activeStep: "start-chat", @@ -1342,14 +1340,14 @@ describe("page SSR smoke coverage", () => { treeBindingPlan: "createBinding", treeUrl: "", }), - ).toContain("Start your first Agent Chat"); + ).toContain("Meet your agent"); expect( await renderOnboardingStep(, { path: "invitee", activeStep: "start-chat", selectedRepoUrls: [], }), - ).toContain("Start your first Agent Chat"); + ).toContain("Meet your agent"); }); it("renders invite, GitHub App, settings, and layout surfaces", async () => { diff --git a/packages/web/src/pages/__tests__/preview-pages-extra.test.tsx b/packages/web/src/pages/__tests__/preview-pages-extra.test.tsx index 14e1f2d21..70fd5cdaf 100644 --- a/packages/web/src/pages/__tests__/preview-pages-extra.test.tsx +++ b/packages/web/src/pages/__tests__/preview-pages-extra.test.tsx @@ -14,7 +14,6 @@ import { CommandPalettePreviewPage } from "../command-palette-preview.js"; import { ContextTreePreviewPage } from "../context-tree-preview.js"; import { ConversationListPreviewPage } from "../conversation-list-preview.js"; import { MobilePreviewPage } from "../mobile-preview.js"; -import { MockTeamStepsA, MockTeamStepsB, MockWelcomeCeremonial } from "../onboarding-team-steps-mocks.js"; import { RequestDockPreviewPage } from "../request-dock-preview.js"; import { ResourcesPreviewPage } from "../resources-preview.js"; import { SettingsContextTreePreviewPage } from "../settings-context-tree-preview.js"; @@ -511,29 +510,4 @@ describe("extra preview pages", () => { await cleanupRendered(rendered); }); - - it("renders onboarding team-step mock variants and updates their editable names", async () => { - const list = await renderPreview(); - expect(text(list.container)).toContain("What's next"); - expect(text(list.container)).toContain("Install First Tree"); - expect(text(list.container)).toContain("Create your first agent"); - const listInput = list.container.querySelector("#mock-team"); - if (!listInput) throw new Error("MockTeamStepsA input missing"); - await setInputValue(listInput, "Renamed Team"); - expect(listInput.value).toBe("Renamed Team"); - await cleanupRendered(list); - - const oneLine = await renderPreview(); - expect(text(oneLine.container)).toContain("Next:"); - expect(text(oneLine.container)).toContain("Connect to GitHub"); - await cleanupRendered(oneLine); - - const ceremonial = await renderPreview(); - expect(text(ceremonial.container)).toContain("rename it freely"); - const ceremonialInput = ceremonial.container.querySelector("#mock-cer-team"); - if (!ceremonialInput) throw new Error("MockWelcomeCeremonial input missing"); - await setInputValue(ceremonialInput, "Ceremonial Team"); - expect(ceremonialInput.value).toBe("Ceremonial Team"); - await cleanupRendered(ceremonial); - }); }); diff --git a/packages/web/src/pages/__tests__/web-dom-interactions.test.tsx b/packages/web/src/pages/__tests__/web-dom-interactions.test.tsx index ed7752beb..4bf31dae1 100644 --- a/packages/web/src/pages/__tests__/web-dom-interactions.test.tsx +++ b/packages/web/src/pages/__tests__/web-dom-interactions.test.tsx @@ -881,12 +881,12 @@ describe("web DOM interaction coverage", () => { name: "deploy-bot", displayName: "Deploy Bot", clientId: "client-1", - runtimeProvider: "claude-code", + runtimeProvider: "codex", visibility: "private", organizationId: "org-1", }), ); - expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ uuid: "agent-created" }), "claude-code", 0); + expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ uuid: "agent-created" }), "codex", 0); await unmountRoot(root); }); @@ -2051,7 +2051,7 @@ describe("web DOM interaction coverage", () => { retry: vi.fn(), }, }); - await waitForText("Choose which local coding agent it uses", container); + await waitForText("This agent will run", container); const nameInput = container.querySelector("#onboarding-agent-name"); expect(nameInput).not.toBeNull(); @@ -2062,7 +2062,7 @@ describe("web DOM interaction coverage", () => { const runtimeInputs = Array.from( container.querySelectorAll('input[name="onboarding-coding-agent"]'), ); - await click(runtimeInputs[1] ?? null); + await click(runtimeInputs[0] ?? null); expect(setSelectedRuntime).toHaveBeenCalledWith("codex"); const visibilityInputs = Array.from( @@ -2171,7 +2171,7 @@ describe("web DOM interaction coverage", () => { setTreeUrl, markTreeAutoDetectDone, }); - await waitForText("Start your first Agent Chat", adminAutoDetect.container); + await waitForText("Meet your agent", adminAutoDetect.container); expect(markTreeAutoDetectDone).toHaveBeenCalled(); expect(setTreeBindingPlan).toHaveBeenCalledWith("useBoundTree"); expect(setTreeUrl).toHaveBeenCalledWith("https://github.com/acme/context-tree"); @@ -2186,9 +2186,9 @@ describe("web DOM interaction coverage", () => { treeBindingPlan: "useBoundTree", treeUrl: "https://github.com/acme/context-tree", }); - await waitForText("Start your first Agent Chat", adminExisting.container); - await click(findButton(adminExisting.container, "Start chat")); - await waitForText("Starting your agent", adminExisting.container); + await waitForText("Meet your agent", adminExisting.container); + await click(findButton(adminExisting.container, "Start exploring")); + await waitForText("Opening your first Chat", adminExisting.container); expect(agentApiMocks.listManagedAgents).toHaveBeenCalled(); expect(onboardingEventMocks.startOnboardingChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -2214,19 +2214,15 @@ describe("web DOM interaction coverage", () => { treeBindingPlan: "createBinding", treeUrl: "", }); - await waitForText("Start your first Agent Chat", adminNoProject.container); - expect(adminNoProject.container.textContent).toContain("Stay connected"); - expect(adminNoProject.container.textContent).toContain("Mobile app"); - expect(adminNoProject.container.textContent).toContain("Scan to install"); - expect(adminNoProject.container.textContent).toContain("WeChat group"); - expect(adminNoProject.container.textContent).toContain("Discord"); - const communityGrid = [...adminNoProject.container.querySelectorAll(".grid")].find((element) => - element.textContent?.includes("Mobile app"), + await waitForText("Meet your agent", adminNoProject.container); + expect(adminNoProject.container.textContent).toContain( + "Explore First Tree together, then choose what you’d like to try first.", ); - expect(communityGrid?.className).toContain("grid-cols-2"); - expect(communityGrid?.className).toContain("sm:grid-cols-3"); - expect(communityGrid?.querySelector('span[role="img"] svg')?.getAttribute("class")).toContain("h-20"); - await click(findButton(adminNoProject.container, "Start chat")); + expect(adminNoProject.container.textContent).not.toContain("Stay connected"); + expect(adminNoProject.container.textContent).not.toContain("Mobile app"); + expect(adminNoProject.container.textContent).not.toContain("WeChat group"); + expect(adminNoProject.container.textContent).not.toContain("Discord"); + await click(findButton(adminNoProject.container, "Start exploring")); expect(onboardingEventMocks.startOnboardingChat).toHaveBeenLastCalledWith( expect.objectContaining({ agentUuid: "agent-1", topic: "Get started with First Tree" }), ); @@ -2239,12 +2235,12 @@ describe("web DOM interaction coverage", () => { contextEnablementMocks.getContextEnablementHandoff.mockClear(); orgSettingsMocks.getContextTreeSetting.mockResolvedValueOnce({ repo: "", branch: null }); const inviteeNoTree = await renderOnboardingDom(, { path: "invitee", activeStep: "start-chat" }); - await waitForText("Start your first Agent Chat", inviteeNoTree.container); + await waitForText("Meet your agent", inviteeNoTree.container); expect(inviteeNoTree.container.textContent).not.toContain("Use with Claude Code or Codex"); expect(inviteeNoTree.container.textContent).not.toContain("Needs Admin"); expect(contextEnablementMocks.getContextEnablementHandoff).not.toHaveBeenCalled(); - await click(findButton(inviteeNoTree.container, "Start chat")); - await waitForText("Starting your agent", inviteeNoTree.container); + await click(findButton(inviteeNoTree.container, "Start exploring")); + await waitForText("Opening your first Chat", inviteeNoTree.container); expect(inviteeNoTree.flow.completeAndEnterChat).toHaveBeenCalled(); await unmountRoot(inviteeNoTree.root); @@ -2262,7 +2258,7 @@ describe("web DOM interaction coverage", () => { path: "invitee", activeStep: "start-chat", }); - await waitForText("Start your first Agent Chat", inviteeNoRepo.container); + await waitForText("Meet your agent", inviteeNoRepo.container); expect(inviteeNoRepo.container.textContent).not.toContain("Use with Claude Code or Codex"); expect(inviteeNoRepo.container.textContent).not.toContain("Needs Admin"); expect(contextEnablementMocks.getContextEnablementHandoff).not.toHaveBeenCalled(); @@ -2282,11 +2278,11 @@ describe("web DOM interaction coverage", () => { path: "invitee", activeStep: "start-chat", }); - await waitForText("Start your first Agent Chat", inviteeNoInstall.container); + await waitForText("Meet your agent", inviteeNoInstall.container); expect(inviteeNoInstall.container.textContent).not.toContain("Use with Claude Code or Codex"); expect(contextEnablementMocks.getContextEnablementHandoff).not.toHaveBeenCalled(); - expect(findButton(inviteeNoInstall.container, "Start your first Agent Chat")).toBeNull(); - await click(findButton(inviteeNoInstall.container, "Start chat")); + expect(findButton(inviteeNoInstall.container, "Start chat")).toBeNull(); + await click(findButton(inviteeNoInstall.container, "Start exploring")); expect(inviteeNoInstall.flow.completeAndEnterChat).toHaveBeenCalled(); await unmountRoot(inviteeNoInstall.root); @@ -2300,20 +2296,20 @@ describe("web DOM interaction coverage", () => { path: "invitee", activeStep: "start-chat", }); - await waitForText("Start your first Agent Chat", inviteeProbeFail.container); + await waitForText("Meet your agent", inviteeProbeFail.container); expect(inviteeProbeFail.container.textContent).not.toContain("Use with Claude Code or Codex"); expect(contextEnablementMocks.getContextEnablementHandoff).not.toHaveBeenCalled(); - expect(findButton(inviteeProbeFail.container, "Start your first Agent Chat")).toBeNull(); + expect(findButton(inviteeProbeFail.container, "Start chat")).toBeNull(); await unmountRoot(inviteeProbeFail.root); // Invitee · ready (tree + install) → a single launch, no repo selection. The // agent already inherits the team's recommended repos. contextEnablementMocks.getContextEnablementHandoff.mockClear(); const inviteeReady = await renderOnboardingDom(, { path: "invitee", activeStep: "start-chat" }); - await waitForText("Start your first Agent Chat", inviteeReady.container); + await waitForText("Meet your agent", inviteeReady.container); expect(inviteeReady.container.textContent).not.toContain("Use with Claude Code or Codex"); expect(contextEnablementMocks.getContextEnablementHandoff).not.toHaveBeenCalled(); - await click(findButton(inviteeReady.container, "Start chat")); + await click(findButton(inviteeReady.container, "Start exploring")); // Ready invitee also lands in a value-first work chat, not the tree setup // chat. The inherited team tree is context for orientation. expect(onboardingEventMocks.startOnboardingChat).toHaveBeenCalledWith( @@ -2341,12 +2337,12 @@ describe("web DOM interaction coverage", () => { treeBindingPlan: "createBinding", treeUrl: "", }); - await waitForText("Start your first Agent Chat", view.container); + await waitForText("Meet your agent", view.container); await click( ([...view.container.querySelectorAll("button")].find((b) => b.textContent?.includes("Start")) ?? null) as HTMLButtonElement | null, ); - await waitForText("Starting your agent", view.container); + await waitForText("Opening your first Chat", view.container); expect(onboardingEventMocks.startOnboardingChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -2407,12 +2403,12 @@ describe("web DOM interaction coverage", () => { treeBindingPlan: "useBoundTree", treeUrl: "https://github.com/acme/context-tree", }); - await waitForText("Start your first Agent Chat", view.container); + await waitForText("Meet your agent", view.container); await click( ([...view.container.querySelectorAll("button")].find((b) => b.textContent?.includes("Start")) ?? null) as HTMLButtonElement | null, ); - await waitForText("Starting your agent", view.container); + await waitForText("Opening your first Chat", view.container); // web is still granted → written; the stale repo is pruned → never written. expect(resourceMocks.confirmTeamRepositoriesForOrg).toHaveBeenCalledWith( "org-1", @@ -2436,7 +2432,7 @@ describe("web DOM interaction coverage", () => { treeBindingPlan: "useBoundTree", treeUrl: "https://github.com/acme/context-tree", }); - await waitForText("Start your first Agent Chat", view.container); + await waitForText("Meet your agent", view.container); await click( ([...view.container.querySelectorAll("button")].find((b) => b.textContent?.includes("Start")) ?? null) as HTMLButtonElement | null, @@ -2492,12 +2488,12 @@ describe("web DOM interaction coverage", () => { ); }, ); - await waitForText("Start your first Agent Chat", view.container); + await waitForText("Meet your agent", view.container); await click( ([...view.container.querySelectorAll("button")].find((b) => b.textContent?.includes("Start")) ?? null) as HTMLButtonElement | null, ); - await waitForText("Starting your agent", view.container); + await waitForText("Opening your first Chat", view.container); // Live read returned web + api → `gone` is pruned despite being in the cache. expect(githubMocks.listOrgGithubRepos).toHaveBeenCalled(); expect(resourceMocks.confirmTeamRepositoriesForOrg).toHaveBeenCalledWith( diff --git a/packages/web/src/pages/onboarding-preview.tsx b/packages/web/src/pages/onboarding-preview.tsx index 02e9e3c00..40cd8c148 100644 --- a/packages/web/src/pages/onboarding-preview.tsx +++ b/packages/web/src/pages/onboarding-preview.tsx @@ -19,7 +19,6 @@ import { StepGetStarted } from "./onboarding/steps/step-get-started.js"; import { StepStartChat } from "./onboarding/steps/step-start-chat.js"; import { StepTeam } from "./onboarding/steps/step-team.js"; import { getStepSequence, type OnboardingPath, type StepId } from "./onboarding/steps.js"; -import { MockTeamStepsA, MockTeamStepsB, MockWelcomeCeremonial } from "./onboarding-team-steps-mocks.js"; import { buildInviteeReadyBootstrap, buildTeamAgentStartBootstrap, @@ -238,20 +237,21 @@ const COMPUTER: Record< readyMulti: { connectedClient: HOST, capabilitiesLoaded: true, - okRuntimes: ["claude-code", "codex", "claude-code-tui"], - selectedRuntime: "claude-code", + okRuntimes: ["claude-code", "codex", "opencode", "pi"], + selectedRuntime: "codex", setSelectedRuntime: NOOP, cliCommand: SAMPLE_CLI, tokenError: null, retry: NOOP, }, - // A connected computer with only unsupported BYO surfaces. The real - // connect-computer step must keep Continue disabled and explain that Claude - // Code or Codex is still required. + // These tools can power First Tree agents, but the optional external Context + // handoff supports only Claude Code and Codex. This fixture exercises that + // narrower handoff capability without implying the tools themselves are + // unsupported by First Tree. unsupportedByo: { connectedClient: HOST, capabilitiesLoaded: true, - okRuntimes: ["cursor", "kimi"], + okRuntimes: ["cursor", "kimi-code"], selectedRuntime: null, setSelectedRuntime: NOOP, cliCommand: SAMPLE_CLI, @@ -563,7 +563,7 @@ type PreviewStepId = StepId | "connect-code"; const PREVIEW_VIEWS: Array<{ id: PreviewView; label: string; subtitle: string }> = [ { id: "flow", label: "Flow", subtitle: "Branch-complete journey with explicit destinations." }, { id: "states", label: "States", subtitle: "State inventory. Real components, mocked state." }, - { id: "experiments", label: "Experiments", subtitle: "Design experiments. Not production components." }, + { id: "experiments", label: "Experiments", subtitle: "Accepted changes, rendered with production components." }, ]; const DEFAULT_VIEW: PreviewView = "flow"; @@ -611,28 +611,40 @@ export const ONBOARDING_PREVIEW_SCENARIOS: Scenario[] = [ wizard: { step: "create-team" }, }, { - id: "admin-team-steps-a", - label: "Steps preview · A list", - group: "Create-team experiments", + id: "admin-concept-connect-computer", + label: "1 · Connect computer", + group: "Accepted concept flow", role: "admin", view: "experiments", - mockup: , + wizard: { + step: "connect-computer", + flow: { computer: COMPUTER.waiting }, + body: , + }, }, { - id: "admin-team-steps-b", - label: "Steps preview · B one-liner", - group: "Create-team experiments", + id: "admin-concept-create-agent", + label: "2 · Create agent", + group: "Accepted concept flow", role: "admin", view: "experiments", - mockup: , + wizard: { + step: "create-agent", + flow: { computer: COMPUTER.readyMulti, agentPhase: "idle" }, + body: , + }, }, { - id: "admin-welcome-ceremonial", - label: "Create team · ceremonial", - group: "Create-team experiments", + id: "admin-concept-start-chat", + label: "3 · Meet your agent", + group: "Accepted concept flow", role: "admin", view: "experiments", - mockup: , + wizard: { + step: "start-chat", + flow: { computer: COMPUTER.ready }, + body: , + }, }, { @@ -786,14 +798,14 @@ export const ONBOARDING_PREVIEW_SCENARIOS: Scenario[] = [ { id: "admin-ko-noproject", - label: "Start chat · no repo", - group: "Start-chat states", + label: "Meet your agent · no repo", + group: "Meet-your-agent states", role: "admin", wizard: { step: "start-chat", flow: { selectedRepoUrls: [] } }, }, { id: "admin-ko-new", - label: "Start chat", + label: "Meet your agent", group: "Admin flow", role: "admin", view: "flow", @@ -806,21 +818,21 @@ export const ONBOARDING_PREVIEW_SCENARIOS: Scenario[] = [ { id: "admin-ko-existing", label: "Existing (auto-detected)", - group: "Start-chat states", + group: "Meet-your-agent states", role: "admin", wizard: { step: "start-chat", flow: { selectedRepoUrls: [REPO_WEB] }, net: { contextTree: TREE_URL } }, }, { id: "admin-ko-checking", label: "Checking team setup", - group: "Start-chat states", + group: "Meet-your-agent states", role: "admin", wizard: { step: "start-chat", flow: { selectedRepoUrls: [REPO_WEB] }, net: { contextTree: "pending" } }, }, { id: "admin-ko-starting", label: "Starting…", - group: "Start-chat states", + group: "Meet-your-agent states", role: "admin", wizard: { step: "start-chat", @@ -960,7 +972,7 @@ export const ONBOARDING_PREVIEW_SCENARIOS: Scenario[] = [ }, { id: "inv-ko-ready", - label: "Start first chat", + label: "Meet your agent", group: "Recommended onboarding", role: "invitee", view: "flow", @@ -978,6 +990,43 @@ export const ONBOARDING_PREVIEW_SCENARIOS: Scenario[] = [ view: "flow", destination: , }, + { + id: "inv-concept-connect-computer", + label: "1 · Connect computer", + group: "Accepted concept flow", + role: "invitee", + view: "experiments", + wizard: { + step: "connect-computer", + flow: { computer: COMPUTER.waiting }, + body: , + }, + }, + { + id: "inv-concept-create-agent", + label: "2 · Create agent", + group: "Accepted concept flow", + role: "invitee", + view: "experiments", + wizard: { + step: "create-agent", + flow: { computer: COMPUTER.readyMulti, agentPhase: "idle" }, + body: , + }, + }, + { + id: "inv-concept-start-chat", + label: "3 · Meet your agent", + group: "Accepted concept flow", + role: "invitee", + view: "experiments", + wizard: { + step: "start-chat", + flow: { computer: COMPUTER.ready }, + net: { contextTree: TREE_URL, installExists: true, hasCodeRepository: true }, + body: , + }, + }, { id: "inv-workspace-team-pick", label: "Continue without · choose a Team agent", @@ -1242,7 +1291,7 @@ export const ONBOARDING_PREVIEW_SCENARIOS: Scenario[] = [ { id: "inv-ko-not-ready", label: "Team not ready · missing setup", - group: "First Tree chat states", + group: "Meet-your-agent states", role: "invitee", // Missing tree, missing GitHub install, or an uncertain probe all collapse to // the one not-ready screen; the invitee cannot fix those separately here. @@ -1253,7 +1302,7 @@ export const ONBOARDING_PREVIEW_SCENARIOS: Scenario[] = [ }, { id: "inv-start-chat-ready", - label: "Start chat", + label: "Meet your agent", group: "Invitee flow", role: "invitee", wizard: { @@ -1268,7 +1317,7 @@ export const ONBOARDING_PREVIEW_SCENARIOS: Scenario[] = [ { id: "inv-ko-starting", label: "Starting…", - group: "First Tree chat states", + group: "Meet-your-agent states", role: "invitee", wizard: { step: "start-chat", body: }, }, diff --git a/packages/web/src/pages/onboarding-team-steps-mocks.tsx b/packages/web/src/pages/onboarding-team-steps-mocks.tsx deleted file mode 100644 index c74a2ffc5..000000000 --- a/packages/web/src/pages/onboarding-team-steps-mocks.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { ArrowRight } from "lucide-react"; -import { type ReactNode, useState } from "react"; -import { FirstTreeLogo } from "../components/first-tree-logo.js"; -import { Button } from "../components/ui/button.js"; -import { Input } from "../components/ui/input.js"; - -/** - * DEV-only mockups for the team (welcome) step's "what's next" step preview - * above Get started — single column (the two-column split was dropped). Two - * formats for the user to compare: - * - MockTeamStepsA — compact numbered list under a "What's next" eyebrow. - * - MockTeamStepsB — a single muted one-liner with arrows. - * Also previews the proposed label change "What should we call your team?" → - * "Name your team" (consistent with create-agent's "Name your agent"). - */ - -const STEPS = ["Install First Tree", "Create your first agent", "Connect to GitHub"] as const; - -function Frame({ preview }: { preview: ReactNode }): ReactNode { - const [name, setName] = useState("Gandy's team"); - return ( -
-
- - - First Tree - - -
-
-
-
-

- Welcome to First Tree -

-

- You and your local coding agent (Claude Code, Codex) join a First Tree team to work together. -

-
-
- - setName(e.target.value)} maxLength={200} /> -
- {preview} -
- -
-
-
-
- ); -} - -/** A — compact numbered list. */ -export function MockTeamStepsA(): ReactNode { - return ( - -

- What's next -

-
    - {STEPS.map((s, i) => ( -
  1. - - {i + 1} - - - {s} - -
  2. - ))} -
- - } - /> - ); -} - -/** B — single muted one-liner with arrows. */ -export function MockTeamStepsB(): ReactNode { - return ( - - Next: - {STEPS.join(" → ")} -

- } - /> - ); -} - -/** - * Ceremonial welcome — a centered "this is a moment" treatment that builds - * anticipation: prominent brand mark, a warm headline, a payoff-teasing subline - * (your coding agent working alongside your team, in minutes), the single - * naming action, and a low-effort expectation ("3 quick steps · ~2 minutes"). - * Vertically centered, generous spacing, center-aligned hero. - */ -export function MockWelcomeCeremonial(): ReactNode { - const [name, setName] = useState("Gandy's team"); - return ( -
-
- - - First Tree - - -
-
- {/* Three deliberate zones with their own rhythm: HERO (brand + value), - ROADMAP (quiet "what's next"), ACTION (name + a restrained CTA). */} -
- {/* ── Hero ── (wide enough for the subtitle to sit on ONE line) */} - -

- Welcome to First Tree -

-

- You and your local coding agent (Claude Code, Codex) join a First Tree team to work together. -

- - {/* ── Roadmap ── quiet, refined: a faint eyebrow + delicate numerals - (no filled chips) so it reads as a light roadmap, not a heavy list. */} -

- What's next -

-
    - {STEPS.map((s, i) => ( -
  1. - - {i + 1} - - {s} -
  2. - ))} -
- - {/* ── Action ── (clear gap above separates it from orientation). The - CTA is auto-width + centered (a restrained pill, not a heavy - full-width black bar). */} -
- {/* Inline field: the preset team name as the value, with a muted - "← rename it freely" hint trailing it — signals editability on the - same line, no separate label row. The input sizes to its content - so the hint sits right after the name. */} - - -
-
-
-
- ); -} diff --git a/packages/web/src/pages/onboarding/__tests__/copy.test.ts b/packages/web/src/pages/onboarding/__tests__/copy.test.ts index ac6893d4f..ee068175d 100644 --- a/packages/web/src/pages/onboarding/__tests__/copy.test.ts +++ b/packages/web/src/pages/onboarding/__tests__/copy.test.ts @@ -47,7 +47,7 @@ describe("get-started progressive copy", () => { } expect(g.joinedTeam("Acme")).toBe("You've joined Acme"); expect(g.recommendedTitle).toBe("Set up your First Tree agent"); - expect(g.personalSteps).toEqual(["Connect computer", "Create agent", "Start first chat"]); + expect(g.personalSteps).toEqual(["Connect computer", "Create agent", "Meet your agent"]); expect(g.continueWithout).toBe("Continue without my own agent"); expect(g.runBy("Zhang Wei")).toBe("Run by Zhang Wei"); expect(g.teamAgentExecution("Zhang Wei")).toContain("Zhang Wei's connected computer"); @@ -64,9 +64,9 @@ describe("get-started progressive copy", () => { }); describe("onboarding vocabulary (connect-agent reframe)", () => { - // The reframe retires "runtime" from UI copy in favour of "coding agent" / - // the tool's own name. Guard against it creeping back into the two steps - // that used to say it. + // The reframe retires "runtime" from UI copy in favour of plain descriptions + // and the detected option's own name. Guard against it creeping back into + // the two steps that used to say it. it("connect-computer + create-agent copy never says 'runtime'", () => { const cc = COPY.connectComputer; const ca = COPY.createAgent; @@ -92,37 +92,39 @@ describe("onboarding vocabulary (connect-agent reframe)", () => { } }); - it("keeps the computer bridge provider-neutral and names the managed agent", () => { - expect(COPY.connectComputer.whyWaiting).toContain("local coding agent"); - expect(COPY.connectComputer.whyWaiting).not.toContain("Claude Code"); - expect(COPY.connectComputer.whyWaiting).not.toContain("Codex"); + it("explains the computer action without inventing a category users must learn", () => { + expect(COPY.connectComputer.whyWaiting).toBe( + "Install the First Tree app to connect this computer and detect what your agents can run.", + ); + expect(COPY.connectComputer.whyConnected).toBe(""); + expect(COPY.connectComputer.detectedLabel).toBe("Available on this computer"); expect(COPY.connectComputer.detectedBridge).toBe("Next, create your First Tree agent."); expect(STEP_COPY["create-agent"].title).toContain("First Tree agent"); - // The subtitle keeps the First Tree teammate distinct from the local - // provider selected in the field below. - expect(COPY.createAgent.subtitle).toContain("First Tree teammate"); - expect(COPY.createAgent.subtitle).toContain("local coding agent"); + expect(COPY.createAgent.subtitle).toBe( + "Build your own group of agents for different work in this team. Let’s create your first one.", + ); + expect(COPY.createAgent.codingAgentLabel).toBe("This agent will run"); }); - it("keeps the start-chat finale action-oriented and consistent", () => { - expect(COPY.startChat.newTitle).toBe("Start your first Agent Chat"); - expect(COPY.startChat.existingTitle).toBe("Start your first Agent Chat"); - expect(COPY.startChat.noProjectTitle).toBe("Start your first Agent Chat"); - expect(COPY.startChat.inviteeReadyTitle).toBe("Start your first Agent Chat"); - expect(COPY.invitee.notReadyTitle).toBe("Start your first Agent Chat"); + it("keeps the meet-your-agent finale action-oriented and consistent", () => { + expect(COPY.startChat.newTitle).toBe("Meet your agent"); + expect(COPY.startChat.existingTitle).toBe("Meet your agent"); + expect(COPY.startChat.noProjectTitle).toBe("Meet your agent"); + expect(COPY.startChat.inviteeReadyTitle).toBe("Meet your agent"); + expect(COPY.invitee.notReadyTitle).toBe("Meet your agent"); - expect(COPY.startChat.startBuilding).toBe("Start chat"); - expect(COPY.startChat.startExisting).toBe("Start chat"); - expect(COPY.startChat.startChatting).toBe("Start chat"); - expect(COPY.startChat.startWorking).toBe("Start chat"); - expect(COPY.invitee.startAnyway).toBe("Start chat"); + expect(COPY.startChat.startBuilding).toBe("Start exploring"); + expect(COPY.startChat.startExisting).toBe("Start exploring"); + expect(COPY.startChat.startChatting).toBe("Start exploring"); + expect(COPY.startChat.startWorking).toBe("Start exploring"); + expect(COPY.invitee.startAnyway).toBe("Start exploring"); }); it("shows one plain launch subtitle across every start-chat state", () => { // The finale intentionally reads the same regardless of role or team/tree // state: that state is invisible to the user (Context Tree is introduced // later, in chat), so the subtitle stays a single plain launch line. - const launch = "Your agent is ready. Delegate work, follow progress, and review results with your team."; + const launch = "Explore First Tree together, then choose what you’d like to try first."; expect(COPY.startChat.noProjectBody).toBe(launch); expect(COPY.startChat.inviteeReadyBody).toBe(launch); expect(COPY.invitee.notReadyBody).toBe(launch); diff --git a/packages/web/src/pages/onboarding/copy.ts b/packages/web/src/pages/onboarding/copy.ts index 0b05253f4..5724215d5 100644 --- a/packages/web/src/pages/onboarding/copy.ts +++ b/packages/web/src/pages/onboarding/copy.ts @@ -9,9 +9,9 @@ * agent journey. Only after they explicitly continue without one do Team-agent * quick start and external Context access appear. BYO gives the coding agent * one self-contained prompt that connects the computer and enables Team - * Context without creating a First Tree agent or completing onboarding. A - * local coding agent is a provider, never something silently added to First - * Tree Chat. + * Context without creating a First Tree agent or completing onboarding. In + * the recommended path, the detected executable is named directly instead of + * introducing a category term users must learn before creating an agent. * "repo" stays (GitHub access / start-chat can still involve repos, and * "project" is ambiguous next to GitHub's own "Projects"). "binding" and * other deep internals still never leak. @@ -70,11 +70,11 @@ export const STEP_COPY: Record = { }, }; -/** One plain launch line for every start-chat finale — admin or invitee, team - * ready or not. It teaches the product value without leaking Team/Tree - * readiness or implying that a personal Claude Code / Codex conversation is - * joining First Tree Chat. */ -const START_CHAT_LAUNCH_WHY = "Your agent is ready. Delegate work, follow progress, and review results with your team."; +/** One plain exploration line for every first-chat finale — admin or invitee, + * team ready or not. The page introduces the managed First Tree agent without + * exposing repo / Context Tree readiness or turning Chat into the concept the + * member has to understand first. */ +const START_CHAT_LAUNCH_WHY = "Explore First Tree together, then choose what you’d like to try first."; /** Shared phrases reused across steps so wording stays consistent. */ export const COPY = { @@ -180,21 +180,16 @@ export const COPY = { // The Client connects the computer and lets a managed First Tree agent run // there. It does not itself enable Team Context inside a personal provider // session, so keep that separate in the user-facing mental model. - whyWaiting: "A background app that lets First Tree agents run here using a local coding agent.", - whyConnected: "A background app that lets First Tree agents run here using a local coding agent.", - // Two install paths (waiting state): run the bare command in a terminal, or - // paste a ready prompt to the coding agent the user already has — the prompt - // wraps the command in a "please run this" line so the agent executes it - // instead of just explaining a bare command. + whyWaiting: "Install the First Tree app to connect this computer and detect what your agents can run.", + // Once connected, the status row and detected options carry the result. + // Repeating the install explanation would describe work the user has + // already completed, so the connected state has no separate lead sentence. + whyConnected: "", + // One canonical install path: run the server-authored command in a terminal. terminalBoxLabel: "Run this command in your terminal", - agentBoxLabel: "Or paste this to your Claude Code, Codex, or Cursor agent", - agentPromptPrefix: "Help me install First Tree by running the command below:", - // Quiet caption naming the nested coding-agent list, so the indented rows - // read as "found ON this computer" (the relationship the nesting implies) - // rather than as an unlabelled cluster. Count-aware so a single detection - // doesn't read as a plural label. - detectedLabel: (count: number) => - count === 1 ? "Coding agent on this computer" : "Coding agents on this computer", + // Quiet caption naming the nested executable list without asking the user + // to learn another category term such as "AI tool" or "coding agent". + detectedLabel: "Available on this computer", // Bridge below the detected-agents list → the next step (create-agent). detectedBridge: "Next, create your First Tree agent.", waiting: "Waiting for your computer…", @@ -203,8 +198,8 @@ export const COPY = { // connected (so no "Your computer is connected, but…" lead-in), and this is a // live polling state (so the dropped "it'll appear here automatically" tail is // implied — a detected agent just shows up). Problem + fix only. - noRuntime: "No coding agent found yet. Install one (like Claude Code) and sign in.", - detecting: "Looking for coding agents on it…", + noRuntime: "Nothing your agents can run was found yet. Install Codex, Claude Code, or another supported option.", + detecting: "Detecting what your agents can run…", /** Token-mint failure (POST /me/connect-tokens threw, after silent retries). Calm + recoverable: the auto-retry handles transient blips, so by the time this shows it's worth a manual Try again. */ @@ -213,18 +208,13 @@ export const COPY = { }, /** create-agent states */ createAgent: { - // A First Tree agent is the managed teammate; the local coding - // agent is the provider it uses on this computer. Keep both nouns visible - // so onboarding never implies that a personal provider conversation joins - // First Tree Chat. - subtitle: "A First Tree teammate you can delegate work to. It runs here using your local coding agent.", - // Coding-agent picker (moved here from connect-computer): always a list, even - // for one, default-selected to Claude Code when present. Verb-leading to - // match the imperative `nameLabel` ("Name your agent") below; "local" keeps - // the subtitle's vocabulary and frames the pick as the user's own - // machine-side tool — connect-computer already showed which machine, so no - // "Detected on " sub-label is repeated here. - codingAgentLabel: "Choose which local coding agent it uses", + // Establish the durable product model first: this is the user's first of + // potentially several agents in the current Team. The selected executable + // is explained immediately below, where the choice is made. + subtitle: "Build your own group of agents for different work in this team. Let’s create your first one.", + // The detected executables define how this First Tree agent runs, without + // collapsing the managed teammate identity into a renamed local tool. + codingAgentLabel: "This agent will run", // Amber "not ready" badge beside the label when the computer dropped — so the // disabled picker reads AS unavailable (action needed: reconnect) at a glance, // not just a quietly greyed pill. @@ -274,47 +264,39 @@ export const COPY = { // admin · new tree (the default — the team has none yet). `newWhy`/`existingWhy` // are only read by the dormant repo-aware branch (StepConnectCode is out of the // live sequence); kept as functions so that call site's shape is unchanged. - newTitle: "Start your first Agent Chat", + newTitle: "Meet your agent", newWhy: (_repoCount: number): string => START_CHAT_LAUNCH_WHY, - startBuilding: "Start chat", + startBuilding: "Start exploring", // admin · the team already has a Context Tree (re-run / second admin / // CLI-bound). Detected silently; also part of the dormant repo-aware branch. - existingTitle: "Start your first Agent Chat", + existingTitle: "Meet your agent", existingWhy: (_repoCount: number): string => START_CHAT_LAUNCH_WHY, - startExisting: "Start chat", + startExisting: "Start exploring", // admin · no repo connected (the live default path). - noProjectTitle: "Start your first Agent Chat", + noProjectTitle: "Meet your agent", noProjectBody: START_CHAT_LAUNCH_WHY, - startChatting: "Start chat", + startChatting: "Start exploring", // invitee · ready (team has a tree + a GitHub connection). The agent inherits // the team's recommended repos automatically, so there is nothing to select. - inviteeReadyTitle: "Start your first Agent Chat", + inviteeReadyTitle: "Meet your agent", inviteeReadyBody: START_CHAT_LAUNCH_WHY, - startWorking: "Start chat", + startWorking: "Start exploring", // shared launch transition - starting: "Starting your agent…", - - /** Heading of the community footer under the launch CTA (every finale). - * The channel cards themselves live in components/community-channels.tsx - * (shared with the top-bar SupportMenu), so only the onboarding-surface - * heading is copy here. */ - community: { - title: "Stay connected", - }, + starting: "Opening your first Chat…", }, /** Invitee not-ready (blocked-on-admin) state. The not-ready screen covers * both "no Context Tree" and "no GitHub connection" — the invitee can't act * on either, and it advances on its own once the admin finishes. */ invitee: { - notReadyTitle: "Start your first Agent Chat", + notReadyTitle: "Meet your agent", notReadyBody: START_CHAT_LAUNCH_WHY, // The primary action on the not-ready screen — start a simple first chat now // instead of waiting on the team. - startAnyway: "Start chat", + startAnyway: "Start exploring", }, /** Progressive Member entry: one recommended personal-agent path first, * then Team-agent and external Context access after explicit continuation. */ @@ -323,7 +305,7 @@ export const COPY = { recommendedTitle: "Set up your First Tree agent", recommendedWhy: "Create your own agent for ongoing work with your team.", computerReady: "Your computer is connected. Next, create your agent.", - personalSteps: ["Connect computer", "Create agent", "Start first chat"], + personalSteps: ["Connect computer", "Create agent", "Meet your agent"], continueWithout: "Continue without my own agent", personal: { cta: "Set up my agent", @@ -376,7 +358,7 @@ export const COPY = { /** failure recovery, shared */ errors: { generic: "Something went wrong. Try again in a moment.", - chatFailed: "Couldn't start the first task. Try again.", + chatFailed: "Couldn't open your first Chat. Try again.", agentFailed: "Couldn't add your agent to the team — please try again.", noAgent: "We couldn't find your agent. Go back a step and add one.", }, diff --git a/packages/web/src/pages/onboarding/steps/__tests__/step-connect-computer-dom.test.tsx b/packages/web/src/pages/onboarding/steps/__tests__/step-connect-computer-dom.test.tsx index 721fc7991..8921a4d85 100644 --- a/packages/web/src/pages/onboarding/steps/__tests__/step-connect-computer-dom.test.tsx +++ b/packages/web/src/pages/onboarding/steps/__tests__/step-connect-computer-dom.test.tsx @@ -120,8 +120,16 @@ describe("StepConnectComputer", () => { const container = await renderStep(value); + expect(container.textContent).toContain( + "Install the First Tree app to connect this computer and detect what your agents can run.", + ); + expect(container.textContent).toContain("Run this command in your terminal"); expect(container.textContent).toContain("https://download.first-tree.ai/releases/prod/install.sh"); expect(container.textContent).toContain("~/.local/bin/first-tree login abc123"); + expect( + [...container.querySelectorAll("button")].filter((button) => button.textContent?.includes("Copy")), + ).toHaveLength(1); + expect(container.textContent).not.toContain("Or paste this"); expect(container.textContent).not.toContain("npm install"); expect(container.textContent).not.toContain("Node.js"); await click(buttonByText(container, "Copy")); @@ -158,23 +166,26 @@ describe("StepConnectComputer", () => { const detecting = await renderStep(flow({ computer: computer({ connectedClient }) })); expect(detecting.textContent).toContain("workstation"); - expect(detecting.textContent).toContain("Looking for coding agents"); + expect(detecting.textContent).toContain("Detecting what your agents can run"); + expect(detecting.textContent).not.toContain("Install the First Tree app"); expect(buttonByText(detecting, "Continue")?.disabled).toBe(true); const noRuntime = await renderStep( flow({ computer: computer({ connectedClient, capabilitiesLoaded: true, okRuntimes: [] }) }), ); - expect(noRuntime.textContent).toContain("No coding agent found yet"); + expect(noRuntime.textContent).toContain("Nothing your agents can run was found yet"); const goNext = vi.fn(); const ready = await renderStep( flow({ goNext, - computer: computer({ connectedClient, capabilitiesLoaded: true, okRuntimes: ["codex", "claude-code"] }), + computer: computer({ connectedClient, capabilitiesLoaded: true, okRuntimes: ["claude-code", "codex"] }), }), ); expect(ready.textContent).toContain("Codex"); expect(ready.textContent).toContain("Claude Code"); + expect(ready.textContent).toContain("Available on this computer"); + expect((ready.textContent ?? "").indexOf("Codex")).toBeLessThan((ready.textContent ?? "").indexOf("Claude Code")); expect(buttonByText(ready, "Continue")?.disabled).toBe(false); await click(buttonByText(ready, "Continue")); expect(goNext).toHaveBeenCalledTimes(1); diff --git a/packages/web/src/pages/onboarding/steps/__tests__/step-get-started-dom.test.tsx b/packages/web/src/pages/onboarding/steps/__tests__/step-get-started-dom.test.tsx index 741425bef..e8e3227fc 100644 --- a/packages/web/src/pages/onboarding/steps/__tests__/step-get-started-dom.test.tsx +++ b/packages/web/src/pages/onboarding/steps/__tests__/step-get-started-dom.test.tsx @@ -193,7 +193,7 @@ describe("StepGetStarted", () => { expect(container.textContent).toContain("You've joined Acme"); expect(container.textContent).toContain("Set up your First Tree agent"); expect(container.textContent).toContain("Connect computer"); - expect(container.textContent).toContain("Start first chat"); + expect(container.textContent).toContain("Meet your agent"); expect(container.textContent).not.toContain("Pick a team agent"); expect(container.textContent).not.toContain("Use the Context Tree in Claude Code or Codex"); expect(mocks.listAgents).not.toHaveBeenCalled(); diff --git a/packages/web/src/pages/onboarding/steps/step-connect-computer.tsx b/packages/web/src/pages/onboarding/steps/step-connect-computer.tsx index d5487ab7e..8c9d2ff35 100644 --- a/packages/web/src/pages/onboarding/steps/step-connect-computer.tsx +++ b/packages/web/src/pages/onboarding/steps/step-connect-computer.tsx @@ -1,5 +1,6 @@ import { ArrowRight } from "lucide-react"; import { Button } from "../../../components/ui/button.js"; +import { orderRuntimesByPreference } from "../../../features/agent-setup/runtime-preference.js"; import { runtimeProviderLabel } from "../../clients/cards/shared/providers.js"; import { COPY } from "../copy.js"; import { CommandBox, FlowHint, StatusRow } from "../flow-ui.js"; @@ -7,10 +8,9 @@ import { useOnboardingFlow } from "../onboarding-flow.js"; /** * Install the First Tree client (a small background app) on the user's computer. - * Two install paths: run the command block in a terminal, OR paste a ready prompt to - * the coding agent the user already has (Claude Code / Codex) and let it install. - * We poll until the computer shows up, then list the coding agents detected on it - * (read-only — picking which one to use moves to the next step, create-agent). + * The user runs the server-authored command in a terminal. We poll until the + * computer shows up, then list what First Tree agents can run on it (read-only + * — choosing one moves to the next step, create-agent). * * No "Need help?" disclosure / example terminal: the normal state is just the * server-provided command(s) + status. @@ -21,17 +21,16 @@ export function StepConnectComputer() { const noRuntime = !!connectedClient && capabilitiesLoaded && okRuntimes.length === 0; const ready = !!connectedClient && okRuntimes.length > 0; - - // Box 2 hands the SAME command to the user's coding agent as a paste-able - // prompt, with a natural-language "please run this" wrapper so the agent - // actually executes it (a bare command pasted in might only get explained). - const agentPrompt = cliCommand ? `${COPY.connectComputer.agentPromptPrefix}\n${cliCommand}` : null; + const orderedRuntimes = orderRuntimesByPreference(okRuntimes); + const stepBody = connectedClient ? COPY.connectComputer.whyConnected : COPY.connectComputer.whyWaiting; return (
-

- {connectedClient ? COPY.connectComputer.whyConnected : COPY.connectComputer.whyWaiting} -

+ {stepBody ? ( +

+ {stepBody} +

+ ) : null} {!connectedClient ? ( tokenError ? ( @@ -42,21 +41,15 @@ export function StepConnectComputer() { {COPY.connectComputer.tokenErrorTitle} ) : ( - <> +

{COPY.connectComputer.terminalBoxLabel}

-
-

- {COPY.connectComputer.agentBoxLabel} -

- -
- +
) ) : ( <> @@ -97,10 +90,10 @@ export function StepConnectComputer() { {/* Names the nested group so the relationship is stated, not only implied by the indent. */}

- {COPY.connectComputer.detectedLabel(okRuntimes.length)} + {COPY.connectComputer.detectedLabel}

- {okRuntimes.map((r) => ( + {orderedRuntimes.map((r) => (
{ if (selectedRuntime && okRuntimes.includes(selectedRuntime)) return; - const next = okRuntimes.find((r) => r === "claude-code") ?? okRuntimes[0]; + const next = pickPreferredRuntime(okRuntimes); if (next) setSelectedRuntime(next); }, [okRuntimes, selectedRuntime, setSelectedRuntime]); - const okProviders = okRuntimes.flatMap((p) => { + const okProviders = orderRuntimesByPreference(okRuntimes).flatMap((p) => { const provider = asRuntimeProvider(p); return provider ? [provider] : []; }); @@ -217,10 +218,66 @@ export function StepCreateAgent() { }); }; + const toolPicker = + displayProviders.length > 0 ? ( +
+ + {COPY.createAgent.codingAgentLabel} + {/* Prominent amber "Not ready" badge when the computer dropped — makes + the disabled pill read as unavailable (reconnect needed), not just + quietly greyed. */} + {!connected && ( + + + )} + +
+ {displayProviders.map((provider) => ( + setSelectedRuntime(provider)} + disabled={!connected} + > + {PROVIDER_LABEL[provider]} + + ))} +
+
+ ) : null; + return (
- {/* Collapsed-model subtitle: the agent you create IS your local coding - agent given a team identity — no two-layer "powered by" framing. */}

{COPY.createAgent.subtitle}

@@ -258,66 +315,9 @@ export function StepCreateAgent() { {COPY.createAgent.templateIntentUnavailable} )} - {/* Coding agent — always a list (even for one), default Claude Code. + {/* Coding agent — always a list (even for one), default Codex when available. Stays visible (disabled) when the computer drops, so the field never vanishes from under the user. */} - {displayProviders.length > 0 && ( -
- - {COPY.createAgent.codingAgentLabel} - {/* Prominent amber "Not ready" badge when the computer dropped — makes - the disabled pill read as unavailable (reconnect needed), not just - quietly greyed. */} - {!connected && ( - - - )} - -
- {displayProviders.map((provider) => ( - setSelectedRuntime(provider)} - disabled={!connected} - > - {PROVIDER_LABEL[provider]} - - ))} -
-
- )} -
+ {toolPicker} +
Who can use it? diff --git a/packages/web/src/pages/onboarding/steps/step-start-chat.tsx b/packages/web/src/pages/onboarding/steps/step-start-chat.tsx index 665f441fb..d2ba5a583 100644 --- a/packages/web/src/pages/onboarding/steps/step-start-chat.tsx +++ b/packages/web/src/pages/onboarding/steps/step-start-chat.tsx @@ -7,7 +7,6 @@ import { getGithubAppInstallationExists } from "../../../api/github-app.js"; import type { OnboardingFailureReason } from "../../../api/onboarding-events.js"; import { getContextTreeSetting } from "../../../api/org-settings.js"; import { listTeamResourcesForOrg } from "../../../api/resources.js"; -import { CommunityChannels } from "../../../components/community-channels.js"; import { Button } from "../../../components/ui/button.js"; import { readCampaignActionHandoffFlag, writeCampaignActionHandoffFlag } from "../../../utils/onboarding-flags.js"; import { getCampaign } from "../../quickstart/campaigns.js"; @@ -252,7 +251,6 @@ function AdminStartChat() {
-
); @@ -283,7 +281,6 @@ function AdminStartChat() {
- ); @@ -421,7 +418,6 @@ function InviteeReady() { - ); @@ -489,7 +485,6 @@ function InviteeNotReady() { - ); @@ -500,29 +495,3 @@ function InviteeNotReady() { function StartingState() { return ; } - -/** - * "Stay connected" — the mobile install and community cards as a footer under - * the launch CTA. Rendered only in the - * stable finale bodies (never during loading/starting transitions), separated - * from the primary action by a hairline so it reads as a footer and can't - * compete with "Start chat". - */ -function CommunityBlock() { - return ( -
- - {COPY.startChat.community.title} - - -
- ); -}