Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ function client(overrides: Partial<HubClient> = {}): HubClient {
"claude-code-tui": capability("ok"),
codex: capability("ok"),
future: capability("ok"),
pi: capability("ok"),
},
};
}
Expand Down Expand Up @@ -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<HTMLInputElement>('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");
Expand Down
47 changes: 10 additions & 37 deletions packages/web/src/components/new-agent-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<AgentVisibility>("private");
const [runtime, setRuntime] = useState<RuntimeProvider>("claude-code");
const [runtime, setRuntime] = useState<RuntimeProvider>("codex");

// Handle resolution. The slug follows the display name (auto-deduped on
// collision); `resolvedHandle` is the winner. `manualHandle` is only used
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 = {
Expand Down
20 changes: 20 additions & 0 deletions packages/web/src/features/agent-setup/runtime-preference.ts
Original file line number Diff line number Diff line change
@@ -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<T extends string>(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<T extends string>(providers: readonly T[]): T | null {
return orderRuntimesByPreference(providers)[0] ?? null;
}
29 changes: 12 additions & 17 deletions packages/web/src/features/agent-setup/use-computer-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down
133 changes: 131 additions & 2 deletions packages/web/src/pages/__tests__/onboarding-preview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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(
<MemoryRouter>
<OnboardingPreviewPage />
</MemoryRouter>,
);
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(
<MemoryRouter>
<OnboardingPreviewPage />
</MemoryRouter>,
);
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<HTMLInputElement>('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(
<MemoryRouter>
<OnboardingPreviewPage />
</MemoryRouter>,
);
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(
<MemoryRouter>
<OnboardingPreviewPage />
</MemoryRouter>,
);
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(
<MemoryRouter>
<OnboardingPreviewPage />
</MemoryRouter>,
);
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");
Expand All @@ -279,7 +408,7 @@ describe("onboarding preview review surface", () => {
</MemoryRouter>,
);

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();
Expand Down
Loading
Loading