diff --git a/packages/server/src/__tests__/oauth-bootstrap.test.ts b/packages/server/src/__tests__/oauth-bootstrap.test.ts index 542da6d20..13a25eb35 100644 --- a/packages/server/src/__tests__/oauth-bootstrap.test.ts +++ b/packages/server/src/__tests__/oauth-bootstrap.test.ts @@ -8,7 +8,7 @@ import { authIdentities } from "../db/schema/auth-identities.js"; import { members } from "../db/schema/members.js"; import { organizations } from "../db/schema/organizations.js"; import { findOrCreateUserFromExternalAccount } from "../services/auth-identity.js"; -import { completeExternalAccountBootstrap } from "../services/oauth-bootstrap.js"; +import { completeExternalAccountBootstrap, shouldPreserveSoloSignupNext } from "../services/oauth-bootstrap.js"; import { uuidv7 } from "../uuid.js"; import { useTestApp } from "./helpers.js"; @@ -105,6 +105,24 @@ describe("provider-neutral OAuth bootstrap", () => { expect(result).toMatchObject({ joinPath: "solo", next: "/", teamCreated: true }); }); + it("preserves a strict Agent Template intent across solo signup", async () => { + const app = getApp(); + const account = await findOrCreateUserFromExternalAccount( + app.db, + googleExternalProfile({ sub: "google-template-intent-subject", name: "Template Intent User" }), + ); + const next = "/templates/pr-engineer?use=1"; + + const result = await completeExternalAccountBootstrap(app.db, account, { + next, + allowedOrganizationId: null, + ip: null, + userAgent: null, + }); + + expect(result).toMatchObject({ joinPath: "solo", next, teamCreated: true }); + }); + it("serializes concurrent first sign-ins into one personal team graph", async () => { const app = getApp(); const databaseUrl = process.env.DATABASE_URL ?? ""; @@ -224,3 +242,28 @@ describe("provider-neutral OAuth bootstrap", () => { } }); }); + +describe("shouldPreserveSoloSignupNext", () => { + it("keeps the known-campaign quickstart next", () => { + expect( + shouldPreserveSoloSignupNext("/quickstart?campaign=production-scan&repo=https%3A%2F%2Fexample.com%2Fr"), + ).toBe(true); + }); + + it("preserves only the strict Template intent URL", () => { + expect(shouldPreserveSoloSignupNext("/templates/pr-engineer?use=1")).toBe(true); + // Invalid slug. + expect(shouldPreserveSoloSignupNext("/templates/PR_Engineer?use=1")).toBe(false); + // Extra query parameters. + expect(shouldPreserveSoloSignupNext("/templates/pr-engineer?use=1&campaign=production-scan")).toBe(false); + // Missing / wrong intent flag. + expect(shouldPreserveSoloSignupNext("/templates/pr-engineer")).toBe(false); + expect(shouldPreserveSoloSignupNext("/templates/pr-engineer?use=0")).toBe(false); + // Fragment. + expect(shouldPreserveSoloSignupNext("/templates/pr-engineer?use=1#details")).toBe(false); + // Anything else, including ordinary deep links. + expect(shouldPreserveSoloSignupNext("/settings/github")).toBe(false); + expect(shouldPreserveSoloSignupNext("/")).toBe(false); + expect(shouldPreserveSoloSignupNext("/templates")).toBe(false); + }); +}); diff --git a/packages/server/src/services/oauth-bootstrap.ts b/packages/server/src/services/oauth-bootstrap.ts index 04f774a65..7af5bef25 100644 --- a/packages/server/src/services/oauth-bootstrap.ts +++ b/packages/server/src/services/oauth-bootstrap.ts @@ -1,4 +1,4 @@ -import { isKnownLandingCampaignSlug } from "@first-tree/shared"; +import { isKnownLandingCampaignSlug, parseAgentTemplateIntentPath } from "@first-tree/shared"; import { eq } from "drizzle-orm"; import type { Database } from "../db/connection.js"; import { users } from "../db/schema/users.js"; @@ -120,6 +120,11 @@ export async function completeExternalAccountBootstrap( } export function shouldPreserveSoloSignupNext(next: string): boolean { + // A canonical Agent Template "use" intent survives solo signup so the new + // member lands back on the Template they picked. Parsing is strict (exact + // pathname, schema slug, sole `use=1` query, no fragment) so this never + // widens into a general deep-link preservation. + if (parseAgentTemplateIntentPath(next) !== null) return true; const parsed = new URL(next, "http://first-tree.local"); return parsed.pathname === "/quickstart" && isKnownLandingCampaignSlug(parsed.searchParams.get("campaign")); } diff --git a/packages/shared/src/__tests__/agent-template-intent.test.ts b/packages/shared/src/__tests__/agent-template-intent.test.ts new file mode 100644 index 000000000..688e9c196 --- /dev/null +++ b/packages/shared/src/__tests__/agent-template-intent.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { agentTemplateIntentPath, parseAgentTemplateIntentPath } from "../agent-template-intent.js"; + +describe("agentTemplateIntentPath", () => { + it("builds the canonical intent URL", () => { + expect(agentTemplateIntentPath("pr-engineer")).toBe("/templates/pr-engineer?use=1"); + }); +}); + +describe("parseAgentTemplateIntentPath", () => { + it("accepts the strict canonical intent URL", () => { + expect(parseAgentTemplateIntentPath("/templates/pr-engineer?use=1")).toBe("pr-engineer"); + }); + + it("round-trips the builder output", () => { + expect(parseAgentTemplateIntentPath(agentTemplateIntentPath("docs-writer"))).toBe("docs-writer"); + }); + + it("rejects an invalid slug", () => { + expect(parseAgentTemplateIntentPath("/templates/PR_Engineer?use=1")).toBeNull(); + expect(parseAgentTemplateIntentPath("/templates/-leading-dash?use=1")).toBeNull(); + expect(parseAgentTemplateIntentPath("/templates/?use=1")).toBeNull(); + expect(parseAgentTemplateIntentPath(`/templates/${"a".repeat(101)}?use=1`)).toBeNull(); + }); + + it("rejects extra or missing query parameters", () => { + expect(parseAgentTemplateIntentPath("/templates/pr-engineer")).toBeNull(); + expect(parseAgentTemplateIntentPath("/templates/pr-engineer?use=0")).toBeNull(); + expect(parseAgentTemplateIntentPath("/templates/pr-engineer?use=1&campaign=x")).toBeNull(); + expect(parseAgentTemplateIntentPath("/templates/pr-engineer?campaign=x&use=1")).toBeNull(); + expect(parseAgentTemplateIntentPath("/templates/pr-engineer?use=1&use=1")).toBeNull(); + }); + + it("rejects a fragment", () => { + expect(parseAgentTemplateIntentPath("/templates/pr-engineer?use=1#section")).toBeNull(); + }); + + it("rejects nested paths and other routes", () => { + expect(parseAgentTemplateIntentPath("/templates/pr-engineer/extra?use=1")).toBeNull(); + expect(parseAgentTemplateIntentPath("/quickstart?use=1")).toBeNull(); + expect(parseAgentTemplateIntentPath("/")).toBeNull(); + }); + + it("rejects absolute and protocol-relative URLs, including same-origin spellings", () => { + expect(parseAgentTemplateIntentPath("https://evil.example/templates/pr-engineer?use=1")).toBeNull(); + expect(parseAgentTemplateIntentPath("//evil.example/templates/pr-engineer?use=1")).toBeNull(); + // Same-origin absolute / protocol-relative forms are valid URLs but not + // the canonical relative intent. + expect(parseAgentTemplateIntentPath("http://first-tree.local/templates/pr-engineer?use=1")).toBeNull(); + expect(parseAgentTemplateIntentPath("//first-tree.local/templates/pr-engineer?use=1")).toBeNull(); + }); + + it("rejects URL-normalized but non-canonical spellings", () => { + // Trailing separator the URL parser would drop. + expect(parseAgentTemplateIntentPath("/templates/pr-engineer?use=1&")).toBeNull(); + // Percent-encoded characters that decode to the same params. + expect(parseAgentTemplateIntentPath("/templates/pr-engineer?%75se=1")).toBeNull(); + expect(parseAgentTemplateIntentPath("/templates/%70r-engineer?use=1")).toBeNull(); + // Backslash the WHATWG parser would fold into a slash. + expect(parseAgentTemplateIntentPath("/templates\\pr-engineer?use=1")).toBeNull(); + // Trailing slash on the path. + expect(parseAgentTemplateIntentPath("/templates/pr-engineer/?use=1")).toBeNull(); + }); + + it("rejects path traversal and encoded separators", () => { + expect(parseAgentTemplateIntentPath("/templates/../settings?use=1")).toBeNull(); + expect(parseAgentTemplateIntentPath("/templates/a%2Fb?use=1")).toBeNull(); + }); + + it("never throws on garbage input", () => { + expect(parseAgentTemplateIntentPath("")).toBeNull(); + expect(parseAgentTemplateIntentPath("not a url at all:::")).toBeNull(); + }); +}); diff --git a/packages/shared/src/agent-template-intent.ts b/packages/shared/src/agent-template-intent.ts new file mode 100644 index 000000000..e1ad82eb9 --- /dev/null +++ b/packages/shared/src/agent-template-intent.ts @@ -0,0 +1,56 @@ +import { type AgentTemplateSlug, agentTemplateSlugSchema } from "./schemas/agent-template.js"; + +/** + * Canonical "use this Template" intent URL: `/templates/?use=1`. + * + * One URL shape is shared by every surface that carries the intent across a + * login round-trip — the public detail CTA, the OAuth `next` parameter, and + * the server's solo-signup `next` preservation (`shouldPreserveSoloSignupNext`). + * This builder is the ONLY producer of a valid intent URL; the parser below + * accepts exactly this output and nothing else. + */ +export function agentTemplateIntentPath(slug: AgentTemplateSlug): string { + return `/templates/${slug}?use=1`; +} + +/** + * Parse a candidate redirect target into its Template intent slug, or `null` + * when the value is anything other than the strict canonical intent URL. + * + * Two gates, in order: + * 1. Structural — parses as a same-origin relative URL with an exact + * `/templates/` pathname, a schema-valid slug, `use=1` as the ONLY + * query parameter, and no fragment. + * 2. Exact-canonical — the RAW input must be byte-identical to the + * builder's output for the extracted slug. This rejects absolute and + * protocol-relative spellings of the same origin, URL-normalized forms + * (trailing `&`, encoded characters, backslashes, duplicate separators), + * and anything else that is merely "equivalent" but not canonical. + * + * Never throws — an unparseable or non-canonical value is simply not an + * intent. + */ +export function parseAgentTemplateIntentPath(next: string): AgentTemplateSlug | null { + let parsed: URL; + try { + parsed = new URL(next, "http://first-tree.local"); + } catch { + return null; + } + // Anything that resolved against a different origin (absolute or + // protocol-relative input) is not a same-app relative intent URL. + if (parsed.origin !== "http://first-tree.local") return null; + if (parsed.hash !== "") return null; + const match = /^\/templates\/([^/]+)$/.exec(parsed.pathname); + const slug = match?.[1]; + if (!slug) return null; + const params = [...parsed.searchParams.entries()]; + if (params.length !== 1 || params[0]?.[0] !== "use" || params[0]?.[1] !== "1") return null; + const validated = agentTemplateSlugSchema.safeParse(slug); + if (!validated.success) return null; + // Only the builder's exact output is an intent — no normalization-tolerant + // spellings, so OAuth `next` preservation can never widen into a general + // deep-link or open-redirect channel. + if (next !== agentTemplateIntentPath(validated.data)) return null; + return validated.data; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 794ffbdf3..c3e599fff 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,6 +6,8 @@ export { type BriefingFingerprint, findAssembledBriefingFingerprint, } from "./agent-briefing-guard.js"; +// -- Canonical Agent Template "use" intent URL -- +export { agentTemplateIntentPath, parseAgentTemplateIntentPath } from "./agent-template-intent.js"; export { type CanonicalGitRepoIdentity, CONTEXT_TREE_PROVIDERS, diff --git a/packages/web/src/__tests__/brand-foreground-token.test.ts b/packages/web/src/__tests__/brand-foreground-token.test.ts new file mode 100644 index 000000000..bbdd0dce4 --- /dev/null +++ b/packages/web/src/__tests__/brand-foreground-token.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8"); + +/** + * Brand-foreground token contract (WCAG AA on the brand green). + * + * The near-white `--fg-on-vivid` measures ~2.2:1 on `--brand`, so brand-filled + * primary actions must use the near-black `--fg-on-brand` instead. This test + * pins the token wiring — the alias, and the `.landing-marketing` scope where + * `--primary` is re-pointed at `--brand` — so a future edit cannot silently + * re-pair a marketing primary action with white-on-green. Real contrast + * verification (axe) stays with the shipped-surface QA gate; this guards the + * semantic binding, not a hardcoded page hex. + */ +describe("brand foreground token contract", () => { + it("defines a dedicated on-brand foreground distinct from --fg-on-vivid", () => { + expect(indexCss).toMatch(/--fg-on-brand:\s*oklch\(0\.17 0 0\)/); + expect(indexCss).toMatch(/--fg-on-vivid:\s*oklch\(0\.985 0 0\)/); + }); + + it("exposes the brand foreground as a Tailwind color alias", () => { + expect(indexCss).toContain("--color-brand-foreground: var(--fg-on-brand);"); + }); + + it("points the marketing scope's primary-on at the brand foreground", () => { + const marketingScope = indexCss.slice(indexCss.indexOf(".landing-marketing {")); + const primaryOn = marketingScope.indexOf("--primary-on:"); + expect(primaryOn).toBeGreaterThan(-1); + const declaration = marketingScope.slice(primaryOn, marketingScope.indexOf(";", primaryOn) + 1); + expect(declaration).toBe("--primary-on: var(--fg-on-brand);"); + }); + + it("keeps --fg-on-vivid available for the other vivid surfaces", () => { + // Avatars/badges/overlays still use the near-white on-color; only the + // brand-green pair moved. + expect(indexCss).toContain("color: var(--fg-on-vivid);"); + }); +}); diff --git a/packages/web/src/api/agent-templates.ts b/packages/web/src/api/agent-templates.ts index ad8a4f3a7..265d05ca0 100644 --- a/packages/web/src/api/agent-templates.ts +++ b/packages/web/src/api/agent-templates.ts @@ -1,4 +1,10 @@ -import type { AgentResourcesOutput, AgentTemplatePublicList, UpdateAgentTemplates } from "@first-tree/shared"; +import type { + AgentResourcesOutput, + AgentTemplatePublicList, + AgentTemplatePublicTemplate, + AgentTemplateSlug, + UpdateAgentTemplates, +} from "@first-tree/shared"; import { api } from "./client.js"; /** Public-safe official Template catalog (no private component data). */ @@ -6,6 +12,15 @@ export function listAgentTemplates(): Promise { return api.get("/agent-templates"); } +/** + * Public-safe Template detail by slug. Available anonymously — the shared API + * client simply sends no Authorization header when the visitor is logged out. + * Never returns component payloads, only `AgentTemplatePublicTemplate`. + */ +export function getAgentTemplate(slug: AgentTemplateSlug): Promise { + return api.get(`/agent-templates/${encodeURIComponent(slug)}`); +} + /** Full replace-set write of an Agent's adopted Templates. */ export function updateAgentTemplates(agentId: string, body: UpdateAgentTemplates): Promise { return api.patch(`/agents/${encodeURIComponent(agentId)}/templates`, body); diff --git a/packages/web/src/app.tsx b/packages/web/src/app.tsx index 3728c9402..f0d46c6d3 100644 --- a/packages/web/src/app.tsx +++ b/packages/web/src/app.tsx @@ -43,6 +43,8 @@ import { SettingsResourcesPage } from "./pages/settings/resources.js"; import { SettingsSetupPage } from "./pages/settings/setup.js"; import { SettingsLayout } from "./pages/settings.js"; import { TeamPage } from "./pages/team/index.js"; +import { TemplateDetailPage } from "./pages/templates/template-detail-page.js"; +import { TemplateLibraryPage } from "./pages/templates/template-library-page.js"; import { WorkspacePage } from "./pages/workspace/index.js"; const queryClient = new QueryClient({ @@ -204,6 +206,12 @@ export function App() { {/* Public: the connect-code install popup lands here to auto-close. */} } /> } /> + {/* Public official Template Library + detail. `/templates/:slug?use=1` + is the canonical use-intent URL; the detail page itself routes + logged-out visitors through /login and signed-in members into + onboarding or an explicit Team choice. */} + } /> + } /> } /> } /> {ContextPreviewPage ? ( diff --git a/packages/web/src/auth/__tests__/auth-context-provider.test.tsx b/packages/web/src/auth/__tests__/auth-context-provider.test.tsx index af4e254f6..12e0a4869 100644 --- a/packages/web/src/auth/__tests__/auth-context-provider.test.tsx +++ b/packages/web/src/auth/__tests__/auth-context-provider.test.tsx @@ -150,6 +150,12 @@ beforeEach(() => { container = null; vi.clearAllMocks(); apiMocks.getStoredTokens.mockReturnValue(null); + // Realistic token store: adopting/storing tokens makes them readable, so + // post-adoption requests capture the NEW session's subject — generation + // and subject guards are then both exercised for real. + apiMocks.setStoredTokens.mockImplementation((tokens: { accessToken: string; refreshToken: string }) => { + apiMocks.getStoredTokens.mockReturnValue(tokens); + }); apiMocks.apiGet.mockResolvedValue({ user: { id: "user-1", username: "gandy", displayName: "Gandy", avatarUrl: null }, memberships: MEMBERSHIPS, @@ -430,4 +436,491 @@ describe("AuthProvider", () => { expect(latestAuth?.isAuthenticated).toBe(true); expect(latestAuth?.meLoaded).toBe(true); }); + + it("rejects and rolls back to the confirmed org when the post-switch /me fails, then allows retry", async () => { + apiMocks.getStoredTokens.mockReturnValue({ + accessToken: tokenWithPayload({ sub: "user-1" }), + refreshToken: "refresh", + }); + await renderAuth(); + // Initial /me settled on the authoritative org-1. + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + + // The post-switch /me is a transport failure: the switch must reject and + // every optimistic write must roll back to org-1. + apiMocks.apiGet.mockRejectedValueOnce(new Error("offline")); + // The rejection handler is attached inside act and swallows the error, so + // act observes the FULLY settled switch (rollback included) instead of + // rethrowing early, and every state update stays inside the act boundary. + let switchError: unknown = null; + await act(async () => { + await latestAuth?.selectOrganization("org-2").catch((error: unknown) => { + switchError = error; + }); + }); + expect(switchError).toBeInstanceOf(Error); + expect((switchError as Error).message).toBe("offline"); + + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + expect(localStorage.getItem("first-tree:selectedOrganizationId:user-1")).toBe("org-1"); + expect(apiMocks.setApiSelectedOrganizationId).toHaveBeenLastCalledWith("org-1"); + + // Retry with a healthy /me confirms the target. + await act(async () => { + await latestAuth?.selectOrganization("org-2"); + }); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-2"); + expect(localStorage.getItem("first-tree:selectedOrganizationId:user-1")).toBe("org-2"); + expect(apiMocks.setApiSelectedOrganizationId).toHaveBeenLastCalledWith("org-2"); + }); + + it("keeps initial-load /me failures fail-soft", async () => { + apiMocks.apiGet.mockRejectedValueOnce(new Error("offline")); + apiMocks.getStoredTokens.mockReturnValue({ accessToken: "access", refreshToken: "refresh" }); + + // renderAuth's initial effect fetch swallows the failure — no rejection, + // meLoaded still flips so the app shell never hangs. + await renderAuth(); + expect(latestAuth?.meLoaded).toBe(true); + expect(latestAuth?.currentMembership).toBeNull(); + }); + + it("does not resurrect the old org when the switch fails through a 401 logout", async () => { + apiMocks.getStoredTokens.mockReturnValue({ + accessToken: tokenWithPayload({ sub: "user-1" }), + refreshToken: "refresh", + }); + await renderAuth(); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + + // Mirror request()'s final-401: tokens cleared + auth:logout dispatched + // BEFORE the rejection reaches selectOrganization's rollback path. + apiMocks.apiGet.mockImplementationOnce(async () => { + apiMocks.getStoredTokens.mockReturnValue(null); + window.dispatchEvent(new CustomEvent("auth:logout")); + throw new Error("unauthorized"); + }); + let switchError: unknown = null; + await act(async () => { + await latestAuth?.selectOrganization("org-2").catch((error: unknown) => { + switchError = error; + }); + }); + expect(switchError).toBeInstanceOf(Error); + + // Logout owns the final state: authenticated false, no membership/org, + // and the API override was cleared by logout — the rollback must NOT + // have written the old org back afterwards. + expect(latestAuth?.isAuthenticated).toBe(false); + expect(latestAuth?.currentMembership).toBeNull(); + expect(latestAuth?.organizationId).toBeNull(); + expect(apiMocks.setApiSelectedOrganizationId).toHaveBeenLastCalledWith(null); + }); + + it("discards a successful /me that lands after logout", async () => { + apiMocks.getStoredTokens.mockReturnValue({ + accessToken: tokenWithPayload({ sub: "user-1" }), + refreshToken: "refresh", + }); + let resolveOldMe!: (value: unknown) => void; + apiMocks.apiGet.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOldMe = resolve; + }), + ); + await renderAuth(); // the initial session's /me stays in flight + + await act(async () => { + window.dispatchEvent(new CustomEvent("auth:logout")); + }); + expect(latestAuth?.isAuthenticated).toBe(false); + + // The old request finally SUCCEEDS — it must mutate nothing: no user, + // no memberships, no org, no API override, and the loading gate stays + // with the logged-out session. + await act(async () => { + resolveOldMe({ + user: { id: "user-1", username: "gandy", displayName: "Gandy", avatarUrl: null }, + memberships: MEMBERSHIPS, + defaultOrganizationId: "org-1", + onboarding: { step: "completed" }, + }); + }); + await flush(); + + expect(latestAuth?.isAuthenticated).toBe(false); + expect(latestAuth?.user).toBeNull(); + expect(latestAuth?.currentMembership).toBeNull(); + expect(latestAuth?.organizationId).toBeNull(); + expect(latestAuth?.meLoaded).toBe(false); + expect(apiMocks.setApiSelectedOrganizationId).toHaveBeenLastCalledWith(null); + }); + + it("keeps session B authoritative when an older session's /me lands later", async () => { + apiMocks.getStoredTokens.mockReturnValue({ + accessToken: tokenWithPayload({ sub: "user-1" }), + refreshToken: "refresh", + }); + let resolveOldMe!: (value: unknown) => void; + apiMocks.apiGet.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOldMe = resolve; + }), + ); + // Session B's /me payload for every later request. + apiMocks.apiGet.mockResolvedValue({ + user: { id: "user-2", username: "other", displayName: "Other", avatarUrl: null }, + memberships: MEMBERSHIPS, + defaultOrganizationId: "org-1", + onboarding: { step: "completed", dismissedAt: null, completedAt: "2026-05-01T00:00:00.000Z" }, + }); + await renderAuth(); // session A's /me stays in flight + + await act(async () => { + await latestAuth?.adoptTokens({ accessToken: tokenWithPayload({ sub: "user-2" }), refreshToken: "refresh-2" }); + }); + expect(latestAuth?.user?.id).toBe("user-2"); + + await act(async () => { + resolveOldMe({ + user: { id: "user-1", username: "gandy", displayName: "Gandy", avatarUrl: null }, + memberships: [], + defaultOrganizationId: null, + onboarding: { step: "connect" }, + }); + }); + await flush(); + + expect(latestAuth?.user?.id).toBe("user-2"); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + }); + + it("rejects a switch whose /me succeeds only after logout, mutating nothing", async () => { + apiMocks.getStoredTokens.mockReturnValue({ + accessToken: tokenWithPayload({ sub: "user-1" }), + refreshToken: "refresh", + }); + await renderAuth(); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + + let resolveSwitchMe!: (value: unknown) => void; + apiMocks.apiGet.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSwitchMe = resolve; + }), + ); + let switchError: unknown = null; + await act(async () => { + const settled = latestAuth?.selectOrganization("org-2").catch((error: unknown) => { + switchError = error; + }); + window.dispatchEvent(new CustomEvent("auth:logout")); + // A SUCCESS arrives, but for the pre-logout session — discarded, and + // the switch rejects without rolling anything into the logged-out state. + resolveSwitchMe({ + user: { id: "user-1", username: "gandy", displayName: "Gandy", avatarUrl: null }, + memberships: MEMBERSHIPS, + defaultOrganizationId: "org-1", + onboarding: { step: "completed" }, + }); + await settled; + }); + + expect(switchError).toBeInstanceOf(Error); + expect(latestAuth?.isAuthenticated).toBe(false); + expect(latestAuth?.currentMembership).toBeNull(); + expect(latestAuth?.organizationId).toBeNull(); + expect(apiMocks.setApiSelectedOrganizationId).toHaveBeenLastCalledWith(null); + }); + + it("treats logout plus relogin as the same subject as a new session for stale responses", async () => { + apiMocks.getStoredTokens.mockReturnValue({ + accessToken: tokenWithPayload({ sub: "user-1" }), + refreshToken: "refresh", + }); + let resolveOldMe!: (value: unknown) => void; + apiMocks.apiGet.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOldMe = resolve; + }), + ); + await renderAuth(); // old session's /me stays in flight + + await act(async () => { + window.dispatchEvent(new CustomEvent("auth:logout")); + }); + // Relogin as the SAME subject — still a new generation; its /me applies. + await act(async () => { + await latestAuth?.adoptTokens({ accessToken: tokenWithPayload({ sub: "user-1" }), refreshToken: "refresh-new" }); + }); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + + const apiOrgCallsBefore = apiMocks.setApiSelectedOrganizationId.mock.calls.length; + // The pre-logout response finally lands with poisoned content — it must + // be discarded even though the subject matches the live session. + await act(async () => { + resolveOldMe({ + user: { id: "user-1", username: "gandy", displayName: "Gandy", avatarUrl: null }, + memberships: [], + defaultOrganizationId: null, + onboarding: { step: "connect" }, + }); + }); + await flush(); + + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + expect(latestAuth?.user?.id).toBe("user-1"); + expect(apiMocks.setApiSelectedOrganizationId.mock.calls.length).toBe(apiOrgCallsBefore); + }); + + it("adoptTokens applies its own authoritative /me even when the auth effect starts a second one", async () => { + // Unauthenticated mount: no initial fetch. Every /me is deferred so we + // can settle the adopt's awaited request while the effect's same-session + // second request is still pending. + const deferred: Array<(value: unknown) => void> = []; + apiMocks.apiGet.mockImplementation( + () => + new Promise((resolve) => { + deferred.push(resolve); + }), + ); + await renderAuth(); + + await act(async () => { + const adopt = latestAuth?.adoptTokens({ + accessToken: tokenWithPayload({ sub: "user-2" }), + refreshToken: "refresh-2", + }); + // Let the adopt's awaited /me start AND the isAuthenticated effect fire + // its second same-session /me, then settle the awaited one with B's + // authoritative payload. + await Promise.resolve(); + deferred[0]?.({ + user: { id: "user-2", username: "other", displayName: "Other", avatarUrl: null }, + memberships: MEMBERSHIPS, + defaultOrganizationId: "org-1", + onboarding: { step: "completed" }, + }); + await adopt; + }); + + // The adopt promise returned only after REAL B authority was applied — + // no bootstrap gap, and the gate came from the live request. + expect(latestAuth?.user?.id).toBe("user-2"); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + expect(latestAuth?.meLoaded).toBe(true); + + // The effect's second request settles later in the same session — it + // applies cleanly instead of erroring or tearing B down. + await act(async () => { + deferred[1]?.({ + user: { id: "user-2", username: "other", displayName: "Other", avatarUrl: null }, + memberships: MEMBERSHIPS, + defaultOrganizationId: "org-1", + onboarding: { step: "completed" }, + }); + }); + expect(latestAuth?.user?.id).toBe("user-2"); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + }); + + it("never rolls back a switch that a concurrent same-session refresh already confirmed", async () => { + apiMocks.getStoredTokens.mockReturnValue({ + accessToken: tokenWithPayload({ sub: "user-1" }), + refreshToken: "refresh", + }); + await renderAuth(); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + + const deferred: Array<{ resolve: (value: unknown) => void; reject: (reason: unknown) => void }> = []; + apiMocks.apiGet.mockImplementation( + () => + new Promise((resolve, reject) => { + deferred.push({ resolve, reject }); + }), + ); + const mePayload = { + user: { id: "user-1", username: "gandy", displayName: "Gandy", avatarUrl: null }, + memberships: MEMBERSHIPS, + defaultOrganizationId: "org-1", + onboarding: { step: "completed" }, + }; + + let switchError: unknown = null; + let switchDone = false; + await act(async () => { + const settled = latestAuth?.selectOrganization("org-2").then( + () => { + switchDone = true; + }, + (error: unknown) => { + switchError = error; + }, + ); + void latestAuth?.refreshMe(); + // The unrelated refresh CONFIRMS org-2 first; the switch's own request + // then fails. The confirmed snapshot must win — no rollback, no false + // failure. + deferred[1]?.resolve(mePayload); + deferred[0]?.reject(new Error("offline")); + await settled; + }); + + expect(switchError).toBeNull(); + expect(switchDone).toBe(true); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-2"); + expect(apiMocks.setApiSelectedOrganizationId).toHaveBeenLastCalledWith("org-2"); + }); + + it("keeps a switch confirmed by its own request when a concurrent refresh settles later", async () => { + apiMocks.getStoredTokens.mockReturnValue({ + accessToken: tokenWithPayload({ sub: "user-1" }), + refreshToken: "refresh", + }); + await renderAuth(); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + + const deferred: Array<(value: unknown) => void> = []; + apiMocks.apiGet.mockImplementation( + () => + new Promise((resolve) => { + deferred.push(resolve); + }), + ); + const mePayload = { + user: { id: "user-1", username: "gandy", displayName: "Gandy", avatarUrl: null }, + memberships: MEMBERSHIPS, + defaultOrganizationId: "org-1", + onboarding: { step: "completed" }, + }; + + let switchDone = false; + await act(async () => { + const settled = latestAuth?.selectOrganization("org-2").then(() => { + switchDone = true; + }); + void latestAuth?.refreshMe(); + // The switch's own request confirms org-2 first; the refresh settles + // afterwards with the same authoritative snapshot. + deferred[0]?.(mePayload); + deferred[1]?.(mePayload); + await settled; + }); + + expect(switchDone).toBe(true); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-2"); + expect(apiMocks.setApiSelectedOrganizationId).toHaveBeenLastCalledWith("org-2"); + }); + + it("rejects a failed re-confirmation of the already-current org instead of faking success", async () => { + apiMocks.getStoredTokens.mockReturnValue({ + accessToken: tokenWithPayload({ sub: "user-1" }), + refreshToken: "refresh", + }); + await renderAuth(); + // A is already the confirmed org — but a NEW failed /me must not borrow + // that old confirmation to succeed. + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + + apiMocks.apiGet.mockRejectedValueOnce(new Error("offline")); + let switchError: unknown = null; + await act(async () => { + await latestAuth?.selectOrganization("org-1").catch((error: unknown) => { + switchError = error; + }); + }); + + expect(switchError).toBeInstanceOf(Error); + expect((switchError as Error).message).toBe("offline"); + // The ordinary rollback keeps every surface on the confirmed org A. + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + expect(localStorage.getItem("first-tree:selectedOrganizationId:user-1")).toBe("org-1"); + expect(apiMocks.setApiSelectedOrganizationId).toHaveBeenLastCalledWith("org-1"); + }); + + it("exposes /me authority separately from the fail-soft loaded gate", async () => { + apiMocks.getStoredTokens.mockReturnValue({ + accessToken: tokenWithPayload({ sub: "user-1" }), + refreshToken: "refresh", + }); + apiMocks.apiGet.mockRejectedValueOnce(new Error("offline")); + await renderAuth(); + + // Initial transport failure: fail-soft shell opens, but no authoritative + // snapshot exists — meLoaded true, authority false. + expect(latestAuth?.meLoaded).toBe(true); + expect(latestAuth?.meAuthoritative).toBe(false); + expect(latestAuth?.currentMembership).toBeNull(); + + // A successful retry establishes authority with the exact memberships/org. + await act(async () => { + await latestAuth?.refreshMe(); + }); + expect(latestAuth?.meAuthoritative).toBe(true); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + }); + + it("rejects a switch satisfied only by a refresh begun before the attempt", async () => { + apiMocks.getStoredTokens.mockReturnValue({ + accessToken: tokenWithPayload({ sub: "user-1" }), + refreshToken: "refresh", + }); + await renderAuth(); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + + const deferred: Array<{ resolve: (value: unknown) => void; reject: (reason: unknown) => void }> = []; + apiMocks.apiGet.mockImplementation( + () => + new Promise((resolve, reject) => { + deferred.push({ resolve, reject }); + }), + ); + const mePayload = { + user: { id: "user-1", username: "gandy", displayName: "Gandy", avatarUrl: null }, + memberships: MEMBERSHIPS, + defaultOrganizationId: "org-1", + onboarding: { step: "completed" }, + }; + + let switchError: unknown = null; + await act(async () => { + // A refresh that BEGAN before the switch attempt. + void latestAuth?.refreshMe(); + await Promise.resolve(); + const settled = latestAuth?.selectOrganization("org-2").catch((error: unknown) => { + switchError = error; + }); + // The pre-attempt refresh resolves first and happens to settle the + // mutable target; the switch-owned request then fails. The pre-attempt + // request must NOT satisfy this switch. + deferred[0]?.resolve(mePayload); + deferred[1]?.reject(new Error("offline")); + await settled; + }); + + expect(switchError).toBeInstanceOf(Error); + // Full rollback to the prior confirmed Team — no borrowed authority. + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + expect(localStorage.getItem("first-tree:selectedOrganizationId:user-1")).toBe("org-1"); + expect(apiMocks.setApiSelectedOrganizationId).toHaveBeenLastCalledWith("org-1"); + + // The hidden rollback baseline must also be A: a SECOND failed switch + // (no confirming request at all) must roll back to A again — never to + // the pre-attempt refresh's incidentally settled B. + apiMocks.apiGet.mockRejectedValueOnce(new Error("offline")); + let secondError: unknown = null; + await act(async () => { + await latestAuth?.selectOrganization("org-2").catch((error: unknown) => { + secondError = error; + }); + }); + expect(secondError).toBeInstanceOf(Error); + expect(latestAuth?.currentMembership?.organizationId).toBe("org-1"); + expect(localStorage.getItem("first-tree:selectedOrganizationId:user-1")).toBe("org-1"); + expect(apiMocks.setApiSelectedOrganizationId).toHaveBeenLastCalledWith("org-1"); + }); }); diff --git a/packages/web/src/auth/auth-context.tsx b/packages/web/src/auth/auth-context.tsx index 49474e175..3e70f1db3 100644 --- a/packages/web/src/auth/auth-context.tsx +++ b/packages/web/src/auth/auth-context.tsx @@ -55,6 +55,15 @@ type AuthContextValue = { meLoaded: boolean; user: MeUser | null; memberships: MeMembership[]; + /** + * `true` once an authoritative live `/me` snapshot has been fully applied + * in this session. Distinct from `meLoaded`: an initial transport failure + * flips `meLoaded` (fail-soft shell) but leaves this false. Resets on + * logout and every new login/adopted-token session. Flows that need real + * Team authority (e.g. the Template use-intent) must wait for this, not + * just `meLoaded`. + */ + meAuthoritative: boolean; /** * Currently selected membership — drives `organizationId / memberId / role * / agentId` and the admin gate. Initialized from @@ -143,12 +152,16 @@ type AuthContextValue = { */ adoptTokens: (tokens: { accessToken: string; refreshToken: string }) => Promise; /** - * Switch the active organization view. Pure client-side state — the - * /orgs/:orgId/* routes themselves probe membership in real time on - * every request, so a stale or unauthorized selection just yields a - * clean 403 from the next API call. Does NOT re-issue tokens; it does - * signal the org-scoped admin WebSocket to reconnect against the new - * org (`ADMIN_WS_ORG_CHANGED_EVENT`). + * Switch the active organization view. The org-scoped routes probe + * membership in real time on every request, and the post-switch `/me` is + * the switch's confirmation authority: this promise REJECTS when that + * `/me` cannot be fetched. On such a transport failure every optimistic + * write (React selection, per-user persisted org, API override, admin WS + * target, and any cache written during the optimistic window) is rolled + * back to the pre-switch confirmed org before the rejection propagates — + * callers must handle the rejection (inline error / retry affordance). + * Does NOT re-issue tokens; it does signal the org-scoped admin WebSocket + * to reconnect against the new org (`ADMIN_WS_ORG_CHANGED_EVENT`). */ selectOrganization: (organizationId: string) => Promise; /** @@ -217,6 +230,14 @@ function writeSelectedOrgId(userId: string | null, value: string | null): void { } } +/** Marker for a /me response from a stale session or identity (discarded, zero mutation). */ +class StaleMeError extends Error { + constructor() { + super("stale /me response discarded"); + this.name = "StaleMeError"; + } +} + export function AuthProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const [isAuthenticated, setIsAuthenticated] = useState(() => !!getStoredTokens()); @@ -234,6 +255,38 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [onboardingStep, setOnboardingStep] = useState<"connect" | "create_agent" | "completed" | null>(null); const [onboardingDismissedAt, setOnboardingDismissedAt] = useState(null); const [onboardingCompletedAt, setOnboardingCompletedAt] = useState(null); + // Selection mirrors for event handlers (closures can't read fresh React + // state). `selectedOrgIdRef` tracks the CURRENT selection, including an + // optimistic switch target. `confirmedOrgIdRef` advances ONLY when a /me + // has authoritatively settled the selection — it is the rollback baseline, + // so an unconfirmed optimistic target can never become one. + const selectedOrgIdRef = useRef(selectedOrgId); + const confirmedOrgIdRef = useRef(null); + // Auth session generation: advances on logout and whenever a new + // authenticated session starts (login/adoptTokens). A /me captured under an + // older generation is stale even when the SUBJECT matches — logout plus + // relogin as the same user is still a new session. Token refresh does not + // advance it (same session, only the raw token changed). + const sessionGenRef = useRef(0); + // Monotonic /me request-start identity. Each loadMe captures its id AND the + // selected-org identity at request start; a successful live confirmation + // records {requestId, requestStartOrg, settledOrg}. selectOrganization + // captures a watermark before its optimistic write, so a "concurrent /me + // already confirmed the target" shortcut can require a confirmation whose + // REQUEST BEGAN after this attempt — a pre-attempt refresh can never + // satisfy it, even if it resolves later and settles the mutable target. + const meRequestIdRef = useRef(0); + const lastLiveConfirmRef = useRef<{ + requestId: number; + requestStartOrg: string | null; + settledOrg: string | null; + } | null>(null); + // True only after an authoritative live /me snapshot was fully applied in + // this session. An initial transport failure may still flip `meLoaded` + // (fail-soft app shell) but leaves this false; reset on logout and every + // new login/adopted-token session. A later refresh failure does not erase + // an already-authoritative snapshot. + const [meAuthoritative, setMeAuthoritative] = useState(false); // Stays false until the first fetchMe settles. Unauthenticated visitors // never need /me, so the gate also flips for them via the unauth branch // below — RequireAuth only blocks the loading frame when the user IS @@ -246,6 +299,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { const logout = useCallback(() => { clearStoredTokens(); + // New generation FIRST: any in-flight /me from the old session becomes + // stale before its state is even considered. + sessionGenRef.current += 1; // Keep the persisted last-used org (no writeSelectedOrgId(null) here) so a // returning sign-in lands back in the org this user left rather than their // most-recently-joined one. It's stored per-user (keyed by the token's @@ -263,17 +319,37 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(null); setMemberships([]); setSelectedOrgId(null); + selectedOrgIdRef.current = null; + confirmedOrgIdRef.current = null; setOnboardingStep(null); setOnboardingDismissedAt(null); setOnboardingCompletedAt(null); setDocsEnabled(false); setMeLoaded(false); + setMeAuthoritative(false); setSwitchingOrg(null); }, [queryClient]); - const fetchMe = useCallback(async () => { + const loadMe = useCallback(async () => { + // Throws on transport failure. `fetchMe` wraps this with the fail-soft + // catch for initial load / manual refresh; `selectOrganization` consumes + // the rejection directly because the post-switch /me is the switch's + // confirmation authority. + const generation = sessionGenRef.current; + const subject = userIdFromToken(); + const requestId = ++meRequestIdRef.current; + const requestStartOrg = selectedOrgIdRef.current; try { const data = await api.get("/me"); + // A stale SUCCESS must mutate nothing: the session moved on (logout, + // login/adoptTokens — even with the same subject) or the identity + // changed. Checked before ANY React state, ref, localStorage, API-org + // override, cache, or WS write. Concurrent same-session requests are + // deliberately NOT sequenced here — they carry the same session's + // authoritative snapshot, and a global /me scheduler is out of scope. + if (generation !== sessionGenRef.current || userIdFromToken() !== subject) { + throw new StaleMeError(); + } setUser(data.user ?? null); const ms = data.memberships ?? []; setMemberships(ms); @@ -294,36 +370,53 @@ export function AuthProvider({ children }: { children: ReactNode }) { // membership: (1) the in-memory selection, (2) this user's persisted // last-used org — survives logout so a returning user lands back in the // org they left — then (3) /me's `defaultOrganizationId` (most-recent), - // (4) the first active membership. + // (4) the first active membership. A successful /me is the ONLY place + // the confirmed-org baseline advances. const userId = data.user?.id ?? null; - setSelectedOrgId((prev) => { - const isMember = (id: string | null): id is string => !!id && ms.some((m) => m.organizationId === id); - const prevValid = isMember(prev) ? prev : null; - const stored = readSelectedOrgId(userId); - const storedValid = isMember(stored) ? stored : null; - const candidate = prevValid ?? storedValid; - if (candidate) { - writeSelectedOrgId(userId, candidate); - setApiSelectedOrganizationId(candidate); - return candidate; - } - const fallback = data.defaultOrganizationId ?? ms[0]?.organizationId ?? null; - writeSelectedOrgId(userId, fallback); - setApiSelectedOrganizationId(fallback); - return fallback; - }); - } catch { - // If /me fails, the UI falls back to hiding admin features. + const prev = selectedOrgIdRef.current; + const isMember = (id: string | null): id is string => !!id && ms.some((m) => m.organizationId === id); + const prevValid = isMember(prev) ? prev : null; + const stored = readSelectedOrgId(userId); + const storedValid = isMember(stored) ? stored : null; + const settled = prevValid ?? storedValid ?? data.defaultOrganizationId ?? ms[0]?.organizationId ?? null; + selectedOrgIdRef.current = settled; + confirmedOrgIdRef.current = settled; + writeSelectedOrgId(userId, settled); + setApiSelectedOrganizationId(settled); + setSelectedOrgId(settled); + // The authoritative snapshot is fully applied — record the live + // confirmation with its request-start identity (a pre-attempt request + // can never satisfy a later switch's confirmation shortcut) and mark + // this session's /me authority as established. + lastLiveConfirmRef.current = { requestId, requestStartOrg, settledOrg: settled }; + setMeAuthoritative(true); } finally { - // Always flip the gate — even on error — so RequireAuth doesn't hang - // the dashboard forever if /me is briefly unreachable. - setMeLoaded(true); + // Flip the gate only for the LIVE session (generation + subject, + // matching the success guard) — a request discarded for identity + // mismatch must not re-open the dashboard after a logout/new-session + // takeover. The gate still flips on ordinary same-session errors so + // RequireAuth doesn't hang the dashboard forever if /me is briefly + // unreachable. + if (generation === sessionGenRef.current && userIdFromToken() === subject) setMeLoaded(true); } }, []); + const fetchMe = useCallback(async () => { + // Initial load and manual refreshes stay fail-soft: if /me fails, the UI + // falls back to hiding admin features. + try { + await loadMe(); + } catch { + // Swallowed by design for non-switch reads. + } + }, [loadMe]); + const login = useCallback( async (username: string, password: string) => { const tokens = await loginApi(username, password); + // A new authenticated session starts — even for the same subject. + sessionGenRef.current += 1; + setMeAuthoritative(false); setStoredTokens({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }); setIsAuthenticated(true); await fetchMe(); @@ -333,6 +426,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { const adoptTokens = useCallback( async (tokens: { accessToken: string; refreshToken: string }) => { + // A new authenticated session starts — even for the same subject. + sessionGenRef.current += 1; + setMeAuthoritative(false); setStoredTokens(tokens); setIsAuthenticated(true); await fetchMe(); @@ -342,9 +438,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { const selectOrganization = useCallback( async (organizationId: string) => { - // Pure client-side switch — the /orgs/:orgId/* routes probe - // membership in real time on every request, so a stale or - // unauthorized selection just yields a clean 403 from the next call. + // The post-switch /me confirms the switch. Capture the session + // generation, the subject marker, the last CONFIRMED org (never the + // optimistic target), and the /me request watermark up front. + const sessionGeneration = sessionGenRef.current; + const sessionMarker = userIdFromToken(); + const previousOrgId = confirmedOrgIdRef.current; + const attemptRequestWatermark = meRequestIdRef.current; // Persist under the current user's key (token `sub`) so the selection // is restored only for this account. writeSelectedOrgId(userIdFromToken(), organizationId); @@ -357,10 +457,54 @@ export function AuthProvider({ children }: { children: ReactNode }) { // the next render refetches with the new prefix so a non-default org // never reuses the previous selection's data. queryClient.clear(); + selectedOrgIdRef.current = organizationId; setSelectedOrgId(organizationId); - await fetchMe(); + try { + await loadMe(); + } catch (error) { + // Session moved on mid-flight — logout (a final-401 clears tokens and + // dispatches auth:logout BEFORE throwing), a new login/adoptTokens, + // or an identity change. Logout / the new session owns the final + // state: reject WITHOUT rolling anything back into it. The marker + // pair (generation + subject) means an ordinary token refresh + // mid-switch does not masquerade as an identity change, while + // logout + relogin as the same subject still counts as a new session. + if (sessionGenRef.current !== sessionGeneration || userIdFromToken() !== sessionMarker) throw error; + // Only a live /me whose REQUEST BEGAN after this attempt — and whose + // request-start and settled targets both equal the exact Team — can + // satisfy this switch (e.g. a manual refresh that started after the + // optimistic write and confirmed the target). A refresh begun BEFORE + // the attempt never satisfies it, even when it resolves later and + // happens to settle the mutable target; a pre-existing confirmed org + // never turns a new failed request into success. + const confirm = lastLiveConfirmRef.current; + if ( + confirm && + confirm.requestId > attemptRequestWatermark && + confirm.requestStartOrg === organizationId && + confirm.settledOrg === organizationId + ) { + return; + } + // Ordinary transport failure within the SAME live session: /me never + // confirmed the target, so roll back the React selection, the + // per-user persisted org, the API override, the admin WS target, and + // the HIDDEN rollback baseline — a rejected pre-attempt response is + // not authority for this switch and must not survive as the next + // attempt's `previousOrgId`. Also drop anything cached against the + // unconfirmed target during the optimistic window. The rejection + // lets the caller surface a recoverable error. + selectedOrgIdRef.current = previousOrgId; + confirmedOrgIdRef.current = previousOrgId; + writeSelectedOrgId(userIdFromToken(), previousOrgId); + setApiSelectedOrganizationId(previousOrgId); + window.dispatchEvent(new CustomEvent(ADMIN_WS_ORG_CHANGED_EVENT)); + queryClient.clear(); + setSelectedOrgId(previousOrgId); + throw error; + } }, - [fetchMe, queryClient], + [loadMe, queryClient], ); const currentMembership = useMemo(() => { @@ -540,6 +684,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { value={{ isAuthenticated, meLoaded, + meAuthoritative, user, memberships, currentMembership, diff --git a/packages/web/src/components/__tests__/new-agent-dialog-initial-template.test.tsx b/packages/web/src/components/__tests__/new-agent-dialog-initial-template.test.tsx new file mode 100644 index 000000000..92f8f8884 --- /dev/null +++ b/packages/web/src/components/__tests__/new-agent-dialog-initial-template.test.tsx @@ -0,0 +1,456 @@ +// @vitest-environment happy-dom + +import type { Agent, AgentTemplatePublicTemplate } from "@first-tree/shared"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, type ReactNode, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { HubClient } from "../../api/activity.js"; +import { NewAgentDialog } from "../new-agent-dialog.js"; +import { ToastProvider } from "../ui/toast.js"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const activityMocks = vi.hoisted(() => ({ + getClientCapabilities: vi.fn(), + listClients: vi.fn(), +})); + +const agentMocks = vi.hoisted(() => ({ + checkAgentNameAvailability: vi.fn(), + createAgent: vi.fn(), +})); + +const templateMocks = vi.hoisted(() => ({ + listAgentTemplates: vi.fn(), + getAgentTemplate: vi.fn(), + updateAgentTemplates: vi.fn(), +})); + +const authMock = vi.hoisted(() => ({ + value: { + organizationId: "org-1", + refreshMe: vi.fn(async () => undefined), + }, +})); + +vi.mock("../../api/activity.js", () => activityMocks); +vi.mock("../../api/agents.js", async (importOriginal) => ({ + ...(await importOriginal()), + ...agentMocks, +})); +vi.mock("../../api/agent-templates.js", () => templateMocks); +vi.mock("../../analytics.js", async (importOriginal) => ({ + ...(await importOriginal()), + trackEvent: vi.fn(), +})); +vi.mock("../../api/client.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + api: { ...actual.api, post: vi.fn(async () => ({ token: "t", bootstrapCommand: "cmd", expiresIn: 60 })) }, + }; +}); +vi.mock("../../auth/auth-context.js", () => ({ + AuthProvider: ({ children }: { children: ReactNode }) => children, + useAuth: () => authMock.value, +})); +vi.mock("../../lib/visibility-interval.js", () => ({ + runVisibilityAwareInterval: (tick: () => void | Promise) => { + void tick(); + return () => undefined; + }, +})); + +const NOW = "2026-07-30T12:00:00.000Z"; + +let root: Root | null = null; +let container: HTMLElement | null = null; +let queryClient: QueryClient | null = null; + +function capability() { + return { state: "ok" as const, available: true, sdkVersion: "1.0.0", detectedAt: NOW }; +} + +function client(): HubClient { + return { + id: "client-1", + userId: "user-self", + status: "connected", + authState: "ok", + binName: "first-tree-dev", + sdkVersion: "0.5.0", + hostname: "dev-macbook", + os: "darwin", + agentCount: 0, + connectedAt: NOW, + lastSeenAt: NOW, + capabilities: { "claude-code": capability() }, + }; +} + +function template(id: string, name: string): AgentTemplatePublicTemplate { + return { + id, + slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"), + name, + status: "active", + public: { + tagline: `Tagline of ${name}`, + purpose: `Purpose of ${name}`, + targetUsers: `Users of ${name}`, + userValue: `Value of ${name}`, + instructionsSummary: "summary", + toolsAndSkillsSummary: `Tools of ${name}`, + }, + updatedAt: NOW, + replacement: null, + }; +} + +const TEMPLATE_A = template("0190f000-0000-7000-8000-000000000001", "PR Engineer"); +const TEMPLATE_B = template("0190f000-0000-7000-8000-000000000002", "Docs Writer"); + +function createdAgent(): Agent { + return { + uuid: "agent-created-1", + name: "build-bot", + displayName: "Build Bot", + type: "agent", + managerId: "member-self", + visibility: "private", + avatarColorToken: null, + avatarImageUrl: null, + status: "active", + organizationId: "org-1", + delegateMention: null, + inboxId: "inbox-1", + metadata: {}, + source: "portal", + clientId: "client-1", + runtimeProvider: "claude-code", + runtimeState: "idle", + createdAt: NOW, + updatedAt: NOW, + }; +} + +function Harness({ initialTemplateSlug }: { initialTemplateSlug?: string }) { + const [open, setOpen] = useState(true); + return ( + <> + + + undefined} + initialTemplateSlug={initialTemplateSlug} + /> + + ); +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function waitForText(text: string, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (document.body.textContent?.includes(text)) return; + await flush(); + } + throw new Error(`Expected text "${text}". Body: ${document.body.textContent ?? ""}`); +} + +async function waitForCondition(predicate: () => boolean, message: string, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await flush(); + } + throw new Error(message); +} + +async function renderHarness(initialTemplateSlug?: string): Promise { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + queryClient = client; + await act(async () => { + root?.render( + + + + + , + ); + }); + await flush(); +} + +async function rerenderHarness(initialTemplateSlug?: string): Promise { + if (!queryClient) throw new Error("renderHarness must run first"); + const client = queryClient; + await act(async () => { + root?.render( + + + + + , + ); + }); + await flush(); +} + +async function click(element: Element | null): Promise { + if (!element) throw new Error("Expected element to click"); + await act(async () => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + await flush(); +} + +async function setValue(element: HTMLInputElement, value: string): Promise { + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flush(); +} + +function buttonByText(text: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll("button")].find((el) => el.textContent?.trim() === text); + if (!button) throw new Error(`Missing button "${text}". Body: ${document.body.textContent ?? ""}`); + return button; +} + +async function fillNameAndSubmit(name: string): Promise { + const input = document.body.querySelector("#new-agent-display-name"); + if (!input) throw new Error("missing display name input"); + await setValue(input, name); + await click(buttonByText("Create")); +} + +describe("NewAgentDialog initial Template", () => { + beforeEach(() => { + vi.clearAllMocks(); + activityMocks.listClients.mockResolvedValue([client()]); + activityMocks.getClientCapabilities.mockResolvedValue({ capabilities: client().capabilities }); + agentMocks.checkAgentNameAvailability.mockResolvedValue({ available: true }); + agentMocks.createAgent.mockResolvedValue(createdAgent()); + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + container = null; + queryClient = null; + document.body.innerHTML = ""; + }); + + it("preselects the intent template once the async catalog resolves", async () => { + let resolveCatalog!: (value: { templates: AgentTemplatePublicTemplate[] }) => void; + templateMocks.listAgentTemplates.mockImplementation( + () => + new Promise((resolve) => { + resolveCatalog = resolve; + }), + ); + await renderHarness("pr-engineer"); + // Catalog still pending — nothing preselected yet, no crash. + expect(document.body.textContent).not.toContain("Tagline of PR Engineer"); + + await act(async () => { + resolveCatalog({ templates: [TEMPLATE_A, TEMPLATE_B] }); + }); + await waitForText("Tagline of PR Engineer"); + + await fillNameAndSubmit("Build Bot"); + await waitForCondition(() => agentMocks.createAgent.mock.calls.length === 1, "create not called"); + const body = agentMocks.createAgent.mock.calls[0]?.[0] as Record; + expect(body.templateIds).toEqual([TEMPLATE_A.id]); + }); + + it("blocks submission while the explicit intent is still resolving", async () => { + let resolveCatalog!: (value: { templates: AgentTemplatePublicTemplate[] }) => void; + templateMocks.listAgentTemplates.mockImplementation( + () => + new Promise((resolve) => { + resolveCatalog = resolve; + }), + ); + await renderHarness("pr-engineer"); + await waitForText("Resolving your template…"); + + // Everything else is ready, yet Create stays disabled while the explicit + // intent is unresolved — a hung lookup must not become a plain Agent. + const input = document.body.querySelector("#new-agent-display-name"); + if (!input) throw new Error("missing display name input"); + await setValue(input, "Build Bot"); + const createButton = buttonByText("Create"); + expect(createButton.disabled).toBe(true); + + // The handler itself is guarded too: a programmatic form submit (Enter / + // requestSubmit-style) must not reach the Agent API either. + const form = document.body.querySelector("form"); + if (!form) throw new Error("missing form"); + await act(async () => { + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + await flush(); + expect(agentMocks.createAgent).not.toHaveBeenCalled(); + + // Resolution unblocks the normal path. + await act(async () => { + resolveCatalog({ templates: [TEMPLATE_A, TEMPLATE_B] }); + }); + await waitForText("Tagline of PR Engineer"); + expect(buttonByText("Create").disabled).toBe(false); + }); + + it("unlocks plain create on the explicit pending Remove, and a late response never reapplies", async () => { + let resolveCatalog!: (value: { templates: AgentTemplatePublicTemplate[] }) => void; + templateMocks.listAgentTemplates.mockImplementation( + () => + new Promise((resolve) => { + resolveCatalog = resolve; + }), + ); + await renderHarness("pr-engineer"); + await waitForText("Resolving your template…"); + + await click(buttonByText("Create without it")); + expect(document.body.textContent).not.toContain("Resolving your template…"); + await fillNameAndSubmit("Build Bot"); + await waitForCondition(() => agentMocks.createAgent.mock.calls.length === 1, "create not called"); + const body = agentMocks.createAgent.mock.calls[0]?.[0] as Record; + expect(body.templateIds).toBeUndefined(); + + // The late catalog response must not reapply or re-block the removed intent. + await act(async () => { + resolveCatalog({ templates: [TEMPLATE_A, TEMPLATE_B] }); + }); + await flush(); + await flush(); + expect(document.body.textContent).not.toContain("Tagline of PR Engineer"); + expect(document.body.textContent).not.toContain("Resolving your template…"); + }); + + it("treats a failed catalog lookup as definitively unavailable and allows plain create", async () => { + templateMocks.listAgentTemplates.mockRejectedValue(new Error("network down")); + await renderHarness("pr-engineer"); + await waitForText("no longer available"); + expect(document.body.textContent).not.toContain("Resolving your template…"); + + await fillNameAndSubmit("Build Bot"); + await waitForCondition(() => agentMocks.createAgent.mock.calls.length === 1, "create not called"); + const body = agentMocks.createAgent.mock.calls[0]?.[0] as Record; + expect(body.templateIds).toBeUndefined(); + }); + + it("never re-applies the intent after the user removes it", async () => { + templateMocks.listAgentTemplates.mockResolvedValue({ templates: [TEMPLATE_A, TEMPLATE_B] }); + await renderHarness("pr-engineer"); + await waitForText("Tagline of PR Engineer"); + + await click(buttonByText("Remove")); + expect(document.body.textContent).toContain("Choose a template"); + // More effect passes (clients poll etc.) must not resurrect the preselect. + await flush(); + await flush(); + expect(document.body.textContent).toContain("Choose a template"); + expect(document.body.textContent).not.toContain("Tagline of PR Engineer"); + }); + + it("degrades safely when the intent slug is no longer an active template", async () => { + templateMocks.listAgentTemplates.mockResolvedValue({ templates: [TEMPLATE_B] }); + await renderHarness("pr-engineer"); + await waitForText("no longer available"); + + await fillNameAndSubmit("Build Bot"); + await waitForCondition(() => agentMocks.createAgent.mock.calls.length === 1, "create not called"); + const body = agentMocks.createAgent.mock.calls[0]?.[0] as Record; + // No stale id is submitted. + expect(body.templateIds).toBeUndefined(); + }); + + it("does not leak the previous intent into a later ordinary open", async () => { + templateMocks.listAgentTemplates.mockResolvedValue({ templates: [TEMPLATE_A, TEMPLATE_B] }); + await renderHarness("pr-engineer"); + await waitForText("Tagline of PR Engineer"); + + // Close, drop the intent prop, reopen — an ordinary open starts clean. + await click(buttonByText("harness-close")); + await rerenderHarness(undefined); + await click(buttonByText("harness-open")); + await flush(); + await waitForCondition( + () => templateMocks.listAgentTemplates.mock.calls.length >= 2, + "catalog not refetched on reopen", + ); + await flush(); + expect(document.body.textContent).toContain("Choose a template"); + expect(document.body.textContent).not.toContain("Tagline of PR Engineer"); + }); + + it("never preselects from the previous open's catalog when the template retired between opens", async () => { + // First open: the slug is active and preselected. + templateMocks.listAgentTemplates.mockResolvedValueOnce({ templates: [TEMPLATE_A, TEMPLATE_B] }); + await renderHarness("pr-engineer"); + await waitForText("Tagline of PR Engineer"); + + // Close, then reopen with the SAME intent slug — but the Template has + // since retired, so the fresh catalog no longer carries it. + templateMocks.listAgentTemplates.mockResolvedValueOnce({ templates: [TEMPLATE_B] }); + await click(buttonByText("harness-close")); + await click(buttonByText("harness-open")); + await waitForText("no longer available"); + expect(document.body.textContent).not.toContain("Tagline of PR Engineer"); + + // Submission must not contain the stale id. + await fillNameAndSubmit("Build Bot"); + await waitForCondition(() => agentMocks.createAgent.mock.calls.length === 1, "create not called"); + const body = agentMocks.createAgent.mock.calls[0]?.[0] as Record; + expect(body.templateIds).toBeUndefined(); + }); + + it("ignores a late catalog response from a previous open", async () => { + // First open's fetch hangs; the user closes and reopens. The second + // fetch resolves fast with a catalog WITHOUT the slug; the first fetch + // then resolves late WITH it — it must not pollute the new open. + let resolveFirst!: (value: { templates: AgentTemplatePublicTemplate[] }) => void; + templateMocks.listAgentTemplates.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + await renderHarness("pr-engineer"); + await click(buttonByText("harness-close")); + templateMocks.listAgentTemplates.mockResolvedValueOnce({ templates: [TEMPLATE_B] }); + await click(buttonByText("harness-open")); + await waitForText("no longer available"); + + await act(async () => { + resolveFirst({ templates: [TEMPLATE_A, TEMPLATE_B] }); + }); + await flush(); + await flush(); + // Still the second open's catalog: no PR Engineer preselect, notice kept. + expect(document.body.textContent).toContain("no longer available"); + expect(document.body.textContent).not.toContain("Tagline of PR Engineer"); + }); +}); diff --git a/packages/web/src/components/new-agent-dialog.tsx b/packages/web/src/components/new-agent-dialog.tsx index 9152f9a72..13d5a2189 100644 --- a/packages/web/src/components/new-agent-dialog.tsx +++ b/packages/web/src/components/new-agent-dialog.tsx @@ -216,6 +216,15 @@ type Props = { open: boolean; onOpenChange: (open: boolean) => void; onCreated: (agent: Agent, runtimeProvider: RuntimeProvider, templateCount: number) => void; + /** + * Optional public Template slug to preselect (e.g. from the canonical + * `/templates/:slug?use=1` intent). Applied ONCE per dialog open, after the + * catalog resolves — later manual add/remove is never overwritten, and an + * ordinary open without the prop starts clean. A slug that is no longer an + * active Template degrades to an explanatory notice; no stale id is ever + * submitted. + */ + initialTemplateSlug?: string; }; type AvailabilityState = @@ -228,7 +237,7 @@ type AvailabilityState = // derive a usable handle and the fallback input is shown. type HandleState = { status: "idle" } | { status: "checking" } | { status: "ok" } | { status: "manual" }; -export function NewAgentDialog({ open, onOpenChange, onCreated }: Props) { +export function NewAgentDialog({ open, onOpenChange, onCreated, initialTemplateSlug }: Props) { const queryClient = useQueryClient(); const { refreshMe, organizationId } = useAuth(); const [displayName, setDisplayName] = useState(""); @@ -280,19 +289,51 @@ export function NewAgentDialog({ open, onOpenChange, onCreated }: Props) { const [templateCatalogLoaded, setTemplateCatalogLoaded] = useState(false); const [selectedTemplateIds, setSelectedTemplateIds] = useState([]); const [templatePickerOpen, setTemplatePickerOpen] = useState(false); + // The caller-supplied initial Template is applied exactly once per open, + // bound to the CURRENT open generation's CURRENT catalog response. A reopen + // bumps `openGenRef` before the apply effect can read the previous open's + // catalog state, so a Template retired between two opens can never be + // preselected (or submitted) from stale state. + // + // Explicit-intent state machine: while `pending` (the slug has not yet been + // resolved against this open's catalog), submission is BLOCKED with a + // visible resolving state — a slow/hung lookup must never silently convert + // an explicit adoption into a plain Agent. `removed` is the user's explicit + // escape: plain creation unlocks and a late response can never reapply. + // `unavailable` (retired/vanished/failed lookup) explains and also unlocks + // plain creation; nothing stale is submitted in any state. + type InitialTemplateState = "idle" | "pending" | "applied" | "unavailable" | "removed"; + const openGenRef = useRef(0); + const wasOpenRef = useRef(false); + const [catalogGen, setCatalogGen] = useState(0); + const initialAppliedForGenRef = useRef(0); + const [initialTemplateState, setInitialTemplateState] = useState("idle"); useEffect(() => { - if (!open) return; + if (!open) { + wasOpenRef.current = false; + return; + } + // One generation per closed→open transition. This effect is declared + // before the reset and apply effects, so the bump is visible to them in + // the same commit. + if (!wasOpenRef.current) { + wasOpenRef.current = true; + openGenRef.current += 1; + } + const gen = openGenRef.current; let cancelled = false; void listAgentTemplates() .then((res) => { if (cancelled) return; setTemplateCatalog(res.templates.filter((template) => template.status === "active")); + setCatalogGen(gen); setTemplateCatalogLoaded(true); }) .catch(() => { if (cancelled) return; setTemplateCatalog([]); + setCatalogGen(gen); setTemplateCatalogLoaded(true); }); return () => { @@ -321,10 +362,35 @@ export function NewAgentDialog({ open, onOpenChange, onCreated }: Props) { setClientErrors({}); setTemplateCatalog([]); setTemplateCatalogLoaded(false); + setCatalogGen(0); setSelectedTemplateIds([]); setTemplatePickerOpen(false); + setInitialTemplateState(initialTemplateSlug ? "pending" : "idle"); + } + }, [open, resetTokenCopy, initialTemplateSlug]); + + // Resolve the caller-supplied initial Template slug against the catalog + // exactly once per open generation. The catalog only carries ACTIVE + // Templates, so a retired/vanished slug simply has no match — degrade to a + // notice instead of submitting a stale id. The generation guard means a + // reopen never applies the previous open's catalog, the applied guard means + // a late or repeated response never overwrites the user's later manual + // add/remove, and the removed guard means the user's explicit + // create-without-it choice can never be re-blocked by a late response. + useEffect(() => { + if (!open || !templateCatalogLoaded || !initialTemplateSlug) return; + if (catalogGen === 0 || catalogGen !== openGenRef.current) return; + if (initialAppliedForGenRef.current === catalogGen) return; + if (initialTemplateState === "removed") return; + initialAppliedForGenRef.current = catalogGen; + const match = templateCatalog.find((template) => template.slug === initialTemplateSlug); + if (match) { + setSelectedTemplateIds([match.id]); + setInitialTemplateState("applied"); + } else { + setInitialTemplateState("unavailable"); } - }, [open, resetTokenCopy]); + }, [open, templateCatalogLoaded, templateCatalog, catalogGen, initialTemplateSlug, initialTemplateState]); const baseSlug = useMemo(() => slugify(displayName), [displayName]); const hasDisplay = displayName.trim().length > 0; @@ -671,6 +737,10 @@ export function NewAgentDialog({ open, onOpenChange, onCreated }: Props) { if (Object.keys(errs).length > 0) return; if (!handleReady) return; if (!pickedClientId) return; + // An explicit Template intent that has not resolved yet must not silently + // become a plain create — guard the handler itself, not just the button + // (Enter / programmatic submit bypasses the disabled attribute). + if (initialTemplateState === "pending") return; // Defense in depth: the Create button is disabled when the picked client // has no ok runtime or when the current selection isn't ok on it. Guard // here too so a button-disabled bypass (browser quirk, Enter while a @@ -686,6 +756,7 @@ export function NewAgentDialog({ open, onOpenChange, onCreated }: Props) { displayName.trim().length > 0 && handleReady && !createMut.isPending && + initialTemplateState !== "pending" && !!pickedClientId && okRuntimes.length > 0 && okRuntimes.includes(runtime); @@ -854,6 +925,26 @@ export function NewAgentDialog({ open, onOpenChange, onCreated }: Props) { {/* Optional official Template responsibilities. Hidden entirely when the catalog is empty or failed to load — the plain create path stays byte-identical in those cases. */} + {initialTemplateState === "pending" && ( +
+

+ Resolving your template… +

+ {/* Explicit escape hatch: the user may always choose plain + creation instead of waiting for the lookup. Once removed, a + late response can never reapply or re-block the intent. */} + +
+ )} + {initialTemplateState === "unavailable" && ( +

+ {templateCatalog.length > 0 + ? "The template you started from is no longer available — pick another responsibility below, or create from scratch." + : "The template you started from is no longer available — you can still create your agent from scratch."} +

+ )} {templateCatalogLoaded && templateCatalog.length > 0 && (
diff --git a/packages/web/src/components/ui/__tests__/button-variants.test.ts b/packages/web/src/components/ui/__tests__/button-variants.test.ts index 7f2ece00f..d1b78236b 100644 --- a/packages/web/src/components/ui/__tests__/button-variants.test.ts +++ b/packages/web/src/components/ui/__tests__/button-variants.test.ts @@ -47,8 +47,13 @@ describe("buttonVariants — filled variants keep their text color at every size } } - it("cta @ sm retains its on-vivid text color", () => { - const out = buttonVariants({ variant: "cta", size: "sm" }); - expect(out).toContain("text-[color:var(--fg-on-vivid)]"); - }); + for (const size of ["sm", "xs", "default"] as const) { + it(`cta @ ${size} binds the brand-specific foreground, never --fg-on-vivid`, () => { + const out = buttonVariants({ variant: "cta", size }); + // Near-white on the brand green fails WCAG AA (measured 2.2:1); the + // brand-foreground alias keeps the CTA dark-on-green in both themes. + expect(out).toContain("text-brand-foreground"); + expect(out).not.toContain("--fg-on-vivid"); + }); + } }); diff --git a/packages/web/src/components/ui/button.tsx b/packages/web/src/components/ui/button.tsx index 3f51a5858..2b7b9dda1 100644 --- a/packages/web/src/components/ui/button.tsx +++ b/packages/web/src/components/ui/button.tsx @@ -13,7 +13,10 @@ const buttonVariants = cva( // (onboarding "Get started", "Create agent", marketing) — NOT for dense // or repeated actions, which stay neutral (`default`). Green-on-buttons // beyond hero moments slides back into the green-primary (E2) collision. - cta: "bg-brand text-[color:var(--fg-on-vivid)] hover:bg-brand-dim", + // The on-brand foreground is near-black (`--fg-on-brand`): near-white + // fails WCAG AA on the brand green, and the brand pair is stable across + // light/dark because --brand never inverts. + cta: "bg-brand text-brand-foreground hover:bg-brand-dim", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", // Bordered variant: focus deepens its own border (no ringed second // frame), matching Input/OptionCard. `ring-0` cancels the base ring. 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 3d0af51bc..6c78a6afc 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 @@ -405,4 +405,49 @@ describe("shared setup hooks", () => { expect(sessionStorage.getItem("onboarding:agentUuid")).toBeNull(); expect(eventMocks.reportOnboardingEvent).not.toHaveBeenCalled(); }); + + it("passes optional templateIds through to the create POST verbatim", async () => { + const latest = { current: null as ReturnType | null }; + const queryClient = testQueryClient(); + clientMocks.api.post.mockResolvedValueOnce({ uuid: "agent-created" }); + agentConfigMocks.getAgentClientStatus.mockResolvedValueOnce({ online: true }); + + function Probe() { + latest.current = useAgentCreation({}); + return
{latest.current.phase}
; + } + + await renderProbe(, queryClient); + await act(async () => { + await expectHookValue(latest.current).create({ + displayName: "Deploy Bot", + clientId: "client-1", + runtimeProvider: "claude-code", + visibility: "organization", + organizationId: "org-1", + templateIds: ["0190f000-0000-7000-8000-000000000001"], + }); + }); + + expect(clientMocks.api.post).toHaveBeenCalledWith( + "/orgs/org-1/agents", + expect.objectContaining({ templateIds: ["0190f000-0000-7000-8000-000000000001"] }), + ); + + // An empty selection stays omitted — identical to a plain create. + clientMocks.api.post.mockResolvedValueOnce({ uuid: "agent-created-2" }); + agentConfigMocks.getAgentClientStatus.mockResolvedValueOnce({ online: true }); + await act(async () => { + await expectHookValue(latest.current).create({ + displayName: "Deploy Bot", + clientId: "client-1", + runtimeProvider: "claude-code", + visibility: "organization", + organizationId: "org-1", + templateIds: [], + }); + }); + const secondBody = clientMocks.api.post.mock.calls[1]?.[1] as Record; + expect("templateIds" in secondBody).toBe(false); + }); }); diff --git a/packages/web/src/features/agent-setup/use-agent-creation.ts b/packages/web/src/features/agent-setup/use-agent-creation.ts index 16da2a3da..8ab1a91a3 100644 --- a/packages/web/src/features/agent-setup/use-agent-creation.ts +++ b/packages/web/src/features/agent-setup/use-agent-creation.ts @@ -23,6 +23,12 @@ export type CreateAgentArgs = { runtimeProvider: string; visibility: AgentVisibility; organizationId: string | null; + /** + * Optional official Template ids to adopt atomically with creation (0-3). + * The server imports their components into Team Resources inside the same + * transaction; omit for a plain create. + */ + templateIds?: string[]; }; export type CreatedAgentInfo = { @@ -119,6 +125,7 @@ export function useAgentCreation(options: UseAgentCreationOptions = {}) { runtimeProvider: args.runtimeProvider, visibility: args.visibility, ...(args.organizationId ? { organizationId: args.organizationId } : {}), + ...(args.templateIds && args.templateIds.length > 0 ? { templateIds: [...args.templateIds] } : {}), }); agentUuid = res.uuid; createdRef.current = agentUuid; diff --git a/packages/web/src/index.css b/packages/web/src/index.css index ac35a382f..2e5b343bc 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -202,6 +202,8 @@ /* Brand green — signature surfaces only (logo / tree nodes / mentions / success). */ --color-brand: var(--brand); --color-brand-dim: var(--brand-dim); + /* AA foreground for brand-filled actions (see --fg-on-brand's contract). */ + --color-brand-foreground: var(--fg-on-brand); --color-destructive: var(--state-error); --color-destructive-foreground: oklch(0.985 0 0); --color-border: var(--border); @@ -349,6 +351,16 @@ under .dark and would yield dark-on-color in dark mode. */ --fg-on-vivid: oklch(0.985 0 0); + /* Foreground ON THE BRAND GREEN specifically. The near-white --fg-on-vivid + fails WCAG AA on --brand / --brand-dim (measured 2.2:1), so brand-filled + primary actions (Button variant="cta", and every bg-primary action inside + .landing-marketing where --primary is re-pointed at --brand) use this + near-black instead: AA ≥ 4.5:1 on both the normal and hover brand steps, + stable across light/dark because --brand does not invert. Distinct + contract from --fg-on-vivid, which keeps serving the other vivid hues + (avatars, error badges, overlays) — do not merge the two. */ + --fg-on-brand: oklch(0.17 0 0); + /* QR modules must stay absolute black-on-white in every theme so phone cameras get maximum contrast. These intentionally do not invert in .dark. */ --qr-fg: oklch(0 0 0); @@ -585,10 +597,12 @@ /* Marketing is a brand-forward surface (not the dashboard's neutral language): the primary CTA is the brand green, not near-black ink. The dashboard's --primary would resolve to the :root light value here (near-black on a - near-black canvas = invisible), so it is re-pointed at --brand for this scope. */ + near-black canvas = invisible), so it is re-pointed at --brand for this + scope. --primary-on follows the brand-foreground contract (near-black on + green) — the near-white --fg-on-vivid fails WCAG AA on --brand. */ --primary: var(--brand); --primary-hover: var(--brand-dim); - --primary-on: var(--fg-on-vivid); + --primary-on: var(--fg-on-brand); --ring: var(--brand-ring); /* Inline-callout pairs use the DARK variants here: .landing-marketing is a 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 beb90c58d..14e1f2d21 100644 --- a/packages/web/src/pages/__tests__/preview-pages-extra.test.tsx +++ b/packages/web/src/pages/__tests__/preview-pages-extra.test.tsx @@ -37,6 +37,7 @@ type AuthValue = React.ComponentProps["value"]; const DEFAULT_AUTH = { isAuthenticated: true, meLoaded: true, + meAuthoritative: true, user: { id: "preview-human", displayName: "Gandy", username: "gandy2025", avatarUrl: null }, memberships: [], currentMembership: null, diff --git a/packages/web/src/pages/onboarding/__tests__/onboarding-flow-template-intent.test.tsx b/packages/web/src/pages/onboarding/__tests__/onboarding-flow-template-intent.test.tsx new file mode 100644 index 000000000..40c27fd70 --- /dev/null +++ b/packages/web/src/pages/onboarding/__tests__/onboarding-flow-template-intent.test.tsx @@ -0,0 +1,201 @@ +// @vitest-environment happy-dom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +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 { readOnboardingTemplateIntent, writeOnboardingTemplateIntent } from "../../../utils/onboarding-flags.js"; +import { OnboardingFlowProvider } from "../onboarding-flow.js"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const creationMock = vi.hoisted(() => ({ + options: null as null | { + onCreated?: (info: { agentUuid: string; args: Record }) => void | Promise; + onOnline?: (uuid: string) => void; + onFailure?: (failure: unknown) => void; + }, +})); + +const analyticsMocks = vi.hoisted(() => ({ + trackEvent: vi.fn(), +})); + +const eventMocks = vi.hoisted(() => ({ + reportOnboardingEvent: vi.fn(async () => undefined), +})); + +const authMock = vi.hoisted(() => ({ + value: { + organizationId: "org-2" as string | null, + memberId: "member-1", + role: "admin", + user: { username: "devuser" }, + teamDisplayName: "Side Team", + orgHasOtherMembers: false, + onboardingStep: "create_agent" as const, + currentOrgHasPersonalAgent: false, + currentOrgHasUsableAgent: false, + refreshMe: vi.fn(async () => undefined), + dismissOnboarding: vi.fn(async () => undefined), + applyOnboardingKickoffStamp: vi.fn(), + }, +})); + +vi.mock("../../../features/agent-setup/use-agent-creation.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAgentCreation: (options: Record) => { + creationMock.options = options as typeof creationMock.options; + return { phase: "idle", error: null, create: vi.fn(), retry: vi.fn(), createdUuid: null }; + }, + }; +}); +vi.mock("../../../features/agent-setup/use-computer-connection.js", () => ({ + useComputerConnection: () => ({ + connectedClient: null, + capabilitiesLoaded: false, + okRuntimes: [], + }), +})); +vi.mock("../../../api/onboarding-events.js", () => eventMocks); +vi.mock("../../../analytics.js", async (importOriginal) => ({ + ...(await importOriginal()), + ...analyticsMocks, +})); +vi.mock("../../../auth/auth-context.js", () => ({ + AuthProvider: ({ children }: { children: ReactNode }) => children, + useAuth: () => authMock.value, +})); + +let root: Root | null = null; + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function renderFlow(): Promise { + const container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root?.render( + + + +
flow-child
+
+
+
, + ); + }); + await flush(); +} + +describe("OnboardingFlowProvider template intent cleanup", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.sessionStorage.clear(); + creationMock.options = null; + authMock.value.organizationId = "org-2"; + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; + window.sessionStorage.clear(); + }); + + it("clears the handoff for the org the agent was actually submitted to, not the drifted selection", async () => { + // The create POST went to org-1; the member has since switched to org-2. + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + writeOnboardingTemplateIntent("org-2", "docs-writer"); + await renderFlow(); + + const onCreated = creationMock.options?.onCreated; + if (!onCreated) throw new Error("useAgentCreation options not captured"); + await act(async () => { + await onCreated({ + agentUuid: "agent-1", + args: { + displayName: "Bot", + clientId: "client-1", + runtimeProvider: "claude-code", + visibility: "organization", + organizationId: "org-1", + templateIds: ["0190f000-0000-7000-8000-000000000001"], + }, + }); + }); + await flush(); + + // org-1 (the real submit target) is cleared; org-2 (the drifted + // selection) keeps its own handoff untouched. + expect(readOnboardingTemplateIntent("org-1")).toBeNull(); + expect(readOnboardingTemplateIntent("org-2")).toBe("docs-writer"); + expect(analyticsMocks.trackEvent).toHaveBeenCalledWith("agent_template_create_success", { template_count: 1 }); + // The server-side event is attributed to the submit target, not the + // drifted current org. + expect(eventMocks.reportOnboardingEvent).toHaveBeenCalledWith( + "agent_created", + expect.objectContaining({ organizationId: "org-1" }), + ); + }); + + it("clears nothing when the submit-time org is missing (fail closed)", async () => { + writeOnboardingTemplateIntent("org-2", "docs-writer"); + await renderFlow(); + + const onCreated = creationMock.options?.onCreated; + if (!onCreated) throw new Error("useAgentCreation options not captured"); + await act(async () => { + await onCreated({ + agentUuid: "agent-1", + args: { + displayName: "Bot", + clientId: "client-1", + runtimeProvider: "claude-code", + visibility: "organization", + organizationId: null, + }, + }); + }); + await flush(); + + // No submit-time org → no handoff is cleared anywhere (the drifting + // closure must not be used as a guess), and the event carries null org. + expect(readOnboardingTemplateIntent("org-2")).toBe("docs-writer"); + expect(eventMocks.reportOnboardingEvent).toHaveBeenCalledWith( + "agent_created", + expect.objectContaining({ organizationId: null }), + ); + }); + + it("fires no template success event for a plain creation", async () => { + await renderFlow(); + const onCreated = creationMock.options?.onCreated; + if (!onCreated) throw new Error("useAgentCreation options not captured"); + await act(async () => { + await onCreated({ + agentUuid: "agent-1", + args: { + displayName: "Bot", + clientId: "client-1", + runtimeProvider: "claude-code", + visibility: "organization", + organizationId: "org-2", + }, + }); + }); + await flush(); + expect(analyticsMocks.trackEvent).not.toHaveBeenCalledWith("agent_template_create_success", expect.anything()); + }); +}); diff --git a/packages/web/src/pages/onboarding/copy.ts b/packages/web/src/pages/onboarding/copy.ts index 3e25395ce..0b05253f4 100644 --- a/packages/web/src/pages/onboarding/copy.ts +++ b/packages/web/src/pages/onboarding/copy.ts @@ -255,6 +255,11 @@ export const COPY = { link: "reconnect it", post: ".", }, + /** Template intent handoff degraded (retired Template, failed detail + fetch, or a stale handoff). Recoverable: plain create stays fully + available, so the line states the loss and the path forward. */ + templateIntentUnavailable: + "The template you started from is no longer available — you can still create your agent from scratch.", }, /** start-chat — one unified "launch" finale across every path. Titles/bodies are rendered per-state by the step; the shell leaves STEP_COPY["start-chat"] empty diff --git a/packages/web/src/pages/onboarding/onboarding-flow.tsx b/packages/web/src/pages/onboarding/onboarding-flow.tsx index 1cc69f558..576815643 100644 --- a/packages/web/src/pages/onboarding/onboarding-flow.tsx +++ b/packages/web/src/pages/onboarding/onboarding-flow.tsx @@ -1,6 +1,7 @@ import type { AgentVisibility } from "@first-tree/shared"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router"; +import { trackEvent } from "../../analytics.js"; import { type OnboardingFailureReason, reportOnboardingEvent } from "../../api/onboarding-events.js"; import { useAuth } from "../../auth/auth-context.js"; import { @@ -14,6 +15,7 @@ import { readOnboardingSelectedRepos, writeOnboardingAgentUuid, writeOnboardingSelectedRepos, + writeOnboardingTemplateIntent, } from "../../utils/onboarding-flags.js"; import { canOfferTeamAgentStart, @@ -320,13 +322,30 @@ export function OnboardingFlowProvider({ path, children }: { path: OnboardingPat const onAgentCreated = useCallback( (info: CreatedAgentInfo) => { writeOnboardingAgentUuid(info.agentUuid); + // The Template intent handoff is consumed by a successful creation — + // whether or not the Template was still selected at submit time — so a + // later same-tab onboarding in this org starts clean. Key the cleanup + // off the org the agent was actually submitted to (the create args), + // never the drifting closure: if the member switched Teams while the + // POST was in flight, we must clear THAT org's key, not the new one. + // Fail CLOSED: without a submit-time org we clear nothing rather than + // guess from the current selection. + const createdOrgId = info.args.organizationId ?? null; + if (createdOrgId) writeOnboardingTemplateIntent(createdOrgId, null); + // Mirror the New Agent dialog's success event for the onboarding path; + // count comes from the submitted args, and it is a creation signal, + // never an activation claim. + if (info.args.templateIds && info.args.templateIds.length > 0) { + trackEvent("agent_template_create_success", { template_count: info.args.templateIds.length }); + } void reportOnboardingEvent("agent_created", { runtimeProvider: info.args.runtimeProvider, path, - organizationId: organizationId ?? null, + // Attributed to the actual submit target, not the drifting closure. + organizationId: createdOrgId, }); }, - [organizationId, path], + [path], ); const { phase: agentPhase, diff --git a/packages/web/src/pages/onboarding/steps/__tests__/step-create-agent-template.test.tsx b/packages/web/src/pages/onboarding/steps/__tests__/step-create-agent-template.test.tsx new file mode 100644 index 000000000..c77c3d674 --- /dev/null +++ b/packages/web/src/pages/onboarding/steps/__tests__/step-create-agent-template.test.tsx @@ -0,0 +1,556 @@ +// @vitest-environment happy-dom + +import type { AgentTemplatePublicTemplate } from "@first-tree/shared"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, type ReactNode, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { writeOnboardingTemplateIntent } from "../../../../utils/onboarding-flags.js"; +import { StepCreateAgent } from "../step-create-agent.js"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const templateMocks = vi.hoisted(() => ({ + listAgentTemplates: vi.fn(), + getAgentTemplate: vi.fn(), + updateAgentTemplates: vi.fn(), +})); + +const flowMock = vi.hoisted(() => ({ + path: "admin" as const, + organizationId: "org-1" as string | null, + agentDisplayName: "My assistant", + setAgentDisplayName: vi.fn(), + visibility: "organization" as const, + setVisibility: vi.fn(), + computer: { + connectedClient: { id: "client-1" } as { id: string } | null, + selectedRuntime: "claude-code" as string | null, + setSelectedRuntime: vi.fn(), + okRuntimes: ["claude-code"], + }, + createAgent: vi.fn(async (_args: Record) => undefined), + retryAgent: vi.fn(async () => undefined), + finishLater: vi.fn(async () => undefined), + agentPhase: "idle" as const, + agentError: null as string | null, + goNext: vi.fn(), + goTo: vi.fn(), + sequence: ["create-team", "connect-computer", "create-agent", "start-chat"] as const, +})); + +const authMock = vi.hoisted(() => ({ + value: { currentOrgHasPersonalAgent: false }, +})); + +vi.mock("../../../../api/agent-templates.js", () => templateMocks); +vi.mock("../../onboarding-flow.js", async () => { + const actual = await vi.importActual("../../onboarding-flow.js"); + return { ...actual, useOnboardingFlow: () => flowMock }; +}); +vi.mock("../../../../auth/auth-context.js", () => ({ + AuthProvider: ({ children }: { children: ReactNode }) => children, + useAuth: () => authMock.value, +})); + +const NOW = "2026-07-30T12:00:00.000Z"; + +const TEMPLATE: AgentTemplatePublicTemplate = { + id: "0190f000-0000-7000-8000-000000000001", + slug: "pr-engineer", + name: "PR Engineer", + status: "active", + public: { + tagline: "Reviews your pull requests", + purpose: "Purpose text", + targetUsers: "Indie hackers", + userValue: "Value text", + instructionsSummary: "Instructions summary", + toolsAndSkillsSummary: "Tools summary", + }, + updatedAt: NOW, + replacement: null, +}; + +const TEMPLATE_B: AgentTemplatePublicTemplate = { + ...TEMPLATE, + id: "0190f000-0000-7000-8000-000000000002", + slug: "docs-writer", + name: "Docs Writer", +}; + +let root: Root | null = null; +let stepQueryClient: QueryClient | null = null; + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function stepTree(): ReactNode { + return ( + + + {windowProbeEnabled ? : null} + + ); +} + +/** + * Deterministic commit-to-passive-effect window driver. Its layout effect + * runs DURING Team B's commit — after StepCreateAgent's own layout effects, + * before ANY passive effect — exactly the window in which a stale lookup + * callback must already be invalidated. (Promise callbacks are microtasks + * and would run after act's passive flush, so the test invokes the captured + * lookup callback synchronously from here.) + */ +let windowProbeEnabled = false; +let pendingWindowCallback: (() => void) | null = null; + +function WindowProbe() { + useLayoutEffect(() => { + const cb = pendingWindowCallback; + pendingWindowCallback = null; + cb?.(); + }); + return null; +} + +/** A thenable whose callbacks are captured for synchronous invocation. */ +function deferredLookup(): { + impl: () => Promise; + fireSuccess: (value: AgentTemplatePublicTemplate) => void; + fireFailure: (reason: unknown) => void; +} { + let onSuccess: ((value: AgentTemplatePublicTemplate) => void) | null = null; + let onFailure: ((reason: unknown) => void) | null = null; + return { + impl: () => + ({ + // biome-ignore lint/suspicious/noThenProperty: intentional minimal thenable — lets the test fire the stale lookup callback synchronously inside the commit window instead of waiting for a microtask. + then: (cb: (value: AgentTemplatePublicTemplate) => void) => { + onSuccess = cb; + return { + catch: (errCb: (reason: unknown) => void) => { + onFailure = errCb; + }, + }; + }, + }) as unknown as Promise, + fireSuccess: (value) => onSuccess?.(value), + fireFailure: (reason) => onFailure?.(reason), + }; +} + +async function renderStep(): Promise { + const container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + stepQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root?.render(stepTree()); + }); + await flush(); +} + +/** Re-render after mutating flow/auth mocks (e.g. a Team switch). */ +async function rerenderStep(): Promise { + await act(async () => { + root?.render(stepTree()); + }); + await flush(); +} + +async function click(element: Element | null): Promise { + if (!element) throw new Error("Expected element to click"); + await act(async () => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + await flush(); +} + +function buttonByText(text: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll("button")].find((el) => el.textContent?.trim().includes(text)); + if (!button) throw new Error(`Missing button "${text}". Body: ${document.body.textContent ?? ""}`); + return button; +} + +describe("StepCreateAgent template intent", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.sessionStorage.clear(); + flowMock.agentPhase = "idle"; + flowMock.agentError = null; + flowMock.organizationId = "org-1"; + flowMock.computer.connectedClient = { id: "client-1" }; + flowMock.computer.selectedRuntime = "claude-code"; + authMock.value.currentOrgHasPersonalAgent = false; + windowProbeEnabled = false; + pendingWindowCallback = null; + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; + window.sessionStorage.clear(); + }); + + it("shows the intent responsibility selected by default and submits its exact template id", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + templateMocks.getAgentTemplate.mockResolvedValue(TEMPLATE); + await renderStep(); + await flush(); + + expect(templateMocks.getAgentTemplate).toHaveBeenCalledWith("pr-engineer"); + expect(document.body.textContent).toContain("PR Engineer"); + expect(document.body.textContent).toContain("Reviews your pull requests"); + + await click(buttonByText("Create agent")); + expect(flowMock.createAgent).toHaveBeenCalledTimes(1); + expect(flowMock.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ templateIds: [TEMPLATE.id], organizationId: "org-1" }), + ); + }); + + it("lets the member remove the intent and create from scratch", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + templateMocks.getAgentTemplate.mockResolvedValue(TEMPLATE); + await renderStep(); + await flush(); + expect(document.body.textContent).toContain("PR Engineer"); + + await click(buttonByText("Remove")); + expect(document.body.textContent).not.toContain("Reviews your pull requests"); + + await click(buttonByText("Create agent")); + expect(flowMock.createAgent).toHaveBeenCalledTimes(1); + const args = flowMock.createAgent.mock.calls[0]?.[0] as Record; + expect(args.templateIds).toBeUndefined(); + }); + + it("degrades to plain create when the template is retired", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + templateMocks.getAgentTemplate.mockResolvedValue({ ...TEMPLATE, status: "retired" }); + await renderStep(); + await flush(); + + expect(document.body.textContent).toContain("no longer available"); + await click(buttonByText("Create agent")); + const args = flowMock.createAgent.mock.calls[0]?.[0] as Record; + expect(args.templateIds).toBeUndefined(); + }); + + it("degrades to plain create when the detail fetch fails", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + templateMocks.getAgentTemplate.mockRejectedValue(new Error("network down")); + await renderStep(); + await flush(); + + expect(document.body.textContent).toContain("no longer available"); + await click(buttonByText("Create agent")); + const args = flowMock.createAgent.mock.calls[0]?.[0] as Record; + expect(args.templateIds).toBeUndefined(); + }); + + it("ignores a stale handoff written for another org", async () => { + writeOnboardingTemplateIntent("org-2", "pr-engineer"); + await renderStep(); + await flush(); + + expect(templateMocks.getAgentTemplate).not.toHaveBeenCalled(); + expect(document.body.textContent).not.toContain("PR Engineer"); + await click(buttonByText("Create agent")); + const args = flowMock.createAgent.mock.calls[0]?.[0] as Record; + expect(args.templateIds).toBeUndefined(); + }); + + it("blocks creation while the explicit intent is still resolving", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + let resolveTemplate!: (value: AgentTemplatePublicTemplate) => void; + templateMocks.getAgentTemplate.mockImplementation( + () => + new Promise((resolve) => { + resolveTemplate = resolve; + }), + ); + await renderStep(); + await flush(); + + expect(document.body.textContent).toContain("Resolving your template…"); + const createButton = buttonByText("Create agent"); + expect(createButton.disabled).toBe(true); + // Handler-level guard too: even a synthetic click reaches no create call. + await click(createButton); + expect(flowMock.createAgent).not.toHaveBeenCalled(); + + // Active resolution unblocks and submits the exact id. + await act(async () => { + resolveTemplate(TEMPLATE); + }); + await flush(); + expect(buttonByText("Create agent").disabled).toBe(false); + await click(buttonByText("Create agent")); + expect(flowMock.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ templateIds: [TEMPLATE.id], organizationId: "org-1" }), + ); + }); + + it("unlocks plain create on the explicit pending Remove, and a late resolution is ignored", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + let resolveTemplate!: (value: AgentTemplatePublicTemplate) => void; + templateMocks.getAgentTemplate.mockImplementation( + () => + new Promise((resolve) => { + resolveTemplate = resolve; + }), + ); + await renderStep(); + await flush(); + expect(document.body.textContent).toContain("Resolving your template…"); + + await click(buttonByText("Create without this template")); + expect(document.body.textContent).not.toContain("Resolving your template…"); + await click(buttonByText("Create agent")); + expect(flowMock.createAgent).toHaveBeenCalledTimes(1); + const args = flowMock.createAgent.mock.calls[0]?.[0] as Record; + expect(args.templateIds).toBeUndefined(); + + // The late lookup must not reapply the card or re-block anything. + await act(async () => { + resolveTemplate(TEMPLATE); + }); + await flush(); + expect(document.body.textContent).not.toContain("Reviews your pull requests"); + expect(document.body.textContent).not.toContain("Resolving your template…"); + }); + + it("keeps ordinary create byte-identical when there is no intent", async () => { + await renderStep(); + await flush(); + + expect(templateMocks.getAgentTemplate).not.toHaveBeenCalled(); + expect(document.body.textContent).not.toContain("Remove"); + await click(buttonByText("Create agent")); + const args = flowMock.createAgent.mock.calls[0]?.[0] as Record; + expect(args.templateIds).toBeUndefined(); + }); + + it("drops the intent entirely when the team changes under a mounted step", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + templateMocks.getAgentTemplate.mockResolvedValue(TEMPLATE); + await renderStep(); + await flush(); + expect(document.body.textContent).toContain("PR Engineer"); + + // Team switch (org-2 has NO handoff): the card and any template id must + // disappear, and the create goes to org-2 with no templateIds. + flowMock.organizationId = "org-2"; + await rerenderStep(); + await flush(); + expect(document.body.textContent).not.toContain("PR Engineer"); + + await click(buttonByText("Create agent")); + const args = flowMock.createAgent.mock.calls[0]?.[0] as Record; + expect(args.organizationId).toBe("org-2"); + expect(args.templateIds).toBeUndefined(); + }); + + it("loads the new team's own intent after a switch", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + writeOnboardingTemplateIntent("org-2", "docs-writer"); + templateMocks.getAgentTemplate.mockImplementation(async (slug: string) => + slug === "pr-engineer" ? TEMPLATE : TEMPLATE_B, + ); + await renderStep(); + await flush(); + expect(document.body.textContent).toContain("PR Engineer"); + + flowMock.organizationId = "org-2"; + await rerenderStep(); + await flush(); + expect(document.body.textContent).toContain("Docs Writer"); + expect(document.body.textContent).not.toContain("PR Engineer"); + + await click(buttonByText("Create agent")); + const args = flowMock.createAgent.mock.calls[0]?.[0] as Record; + expect(args.organizationId).toBe("org-2"); + expect(args.templateIds).toEqual([TEMPLATE_B.id]); + }); + + it("ignores a late fetch for the previous team's slug", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + let resolveA!: (value: AgentTemplatePublicTemplate) => void; + templateMocks.getAgentTemplate.mockImplementation( + () => + new Promise((resolve) => { + resolveA = resolve; + }), + ); + await renderStep(); + await flush(); + + flowMock.organizationId = "org-2"; + await rerenderStep(); + // The old fetch resolves AFTER the switch — it must not paint org-1's + // template onto org-2. + await act(async () => { + resolveA(TEMPLATE); + }); + await flush(); + expect(document.body.textContent).not.toContain("PR Engineer"); + + await click(buttonByText("Create agent")); + const args = flowMock.createAgent.mock.calls[0]?.[0] as Record; + expect(args.templateIds).toBeUndefined(); + }); + + it("does not carry a removal decision across teams", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + writeOnboardingTemplateIntent("org-2", "docs-writer"); + templateMocks.getAgentTemplate.mockImplementation(async (slug: string) => + slug === "pr-engineer" ? TEMPLATE : TEMPLATE_B, + ); + await renderStep(); + await flush(); + await click(buttonByText("Remove")); + expect(document.body.textContent).not.toContain("PR Engineer"); + + flowMock.organizationId = "org-2"; + await rerenderStep(); + await flush(); + // Team B's intent starts selected again — A's Remove never applied to B. + expect(document.body.textContent).toContain("Docs Writer"); + }); + + it("discards a stale success fired inside the commit-to-passive-effect window after a team switch", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + const lookup = deferredLookup(); + templateMocks.getAgentTemplate.mockImplementation(lookup.impl); + await renderStep(); + await flush(); + + // Fire Team A's lookup callback synchronously from a layout effect inside + // Team B's commit — after StepCreateAgent's layout effects, before the new + // passive fetch effect — the exact window the reviewer identified. On the + // pre-fix code the callback still owns the old sequence and writes Team + // A's template into Team B; with the commit-time invalidation it is + // already stale and discarded. + windowProbeEnabled = true; + flowMock.organizationId = "org-2"; + pendingWindowCallback = () => lookup.fireSuccess(TEMPLATE); + act(() => { + root?.render(stepTree()); + }); + await flush(); + + // Team A's template must never paint or submit for Team B. + expect(document.body.textContent).not.toContain("PR Engineer"); + await click(buttonByText("Create agent")); + const args = flowMock.createAgent.mock.calls[0]?.[0] as Record; + expect(args.organizationId).toBe("org-2"); + expect(args.templateIds).toBeUndefined(); + }); + + it("discards a stale failure fired inside the commit-to-passive-effect window after a team switch", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + const lookup = deferredLookup(); + templateMocks.getAgentTemplate.mockImplementation(lookup.impl); + await renderStep(); + await flush(); + + windowProbeEnabled = true; + flowMock.organizationId = "org-2"; + pendingWindowCallback = () => lookup.fireFailure(new Error("network down")); + act(() => { + root?.render(stepTree()); + }); + await flush(); + + // Team A's failure must not paint Team B as unavailable either. + expect(document.body.textContent).not.toContain("no longer available"); + await click(buttonByText("Create agent")); + const args = flowMock.createAgent.mock.calls[0]?.[0] as Record; + expect(args.organizationId).toBe("org-2"); + expect(args.templateIds).toBeUndefined(); + }); + + it("restarts the lookup for the new team when both teams hand off the same slug", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + writeOnboardingTemplateIntent("org-2", "pr-engineer"); + const TEMPLATE_B_SAME_SLUG: AgentTemplatePublicTemplate = { + ...TEMPLATE, + id: "0190f000-0000-7000-8000-000000000009", + slug: "pr-engineer", + name: "PR Engineer B", + }; + templateMocks.getAgentTemplate.mockResolvedValueOnce(TEMPLATE).mockResolvedValueOnce(TEMPLATE_B_SAME_SLUG); + await renderStep(); + await flush(); + expect(document.body.textContent).toContain("PR Engineer"); + expect(templateMocks.getAgentTemplate).toHaveBeenCalledTimes(1); + + // Same slug on both sides — the string does not change, but Team B still + // needs its OWN lookup with the committed {org, slug} identity. + flowMock.organizationId = "org-2"; + await rerenderStep(); + await flush(); + + expect(templateMocks.getAgentTemplate).toHaveBeenCalledTimes(2); + expect(templateMocks.getAgentTemplate).toHaveBeenLastCalledWith("pr-engineer"); + // B's own resolution painted — pending cleared, never A's stale content. + expect(document.body.textContent).toContain("PR Engineer B"); + expect(document.body.textContent).not.toContain("Resolving your template…"); + + await click(buttonByText("Create agent")); + const args = flowMock.createAgent.mock.calls[0]?.[0] as Record; + expect(args.organizationId).toBe("org-2"); + expect(args.templateIds).toEqual([TEMPLATE_B_SAME_SLUG.id]); + }); + + it("starts the new team's lookup even when the previous team's same-slug request is still pending", async () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + writeOnboardingTemplateIntent("org-2", "pr-engineer"); + let resolveA!: (value: AgentTemplatePublicTemplate) => void; + const TEMPLATE_B_SAME_SLUG: AgentTemplatePublicTemplate = { + ...TEMPLATE, + id: "0190f000-0000-7000-8000-000000000009", + slug: "pr-engineer", + name: "PR Engineer B", + }; + templateMocks.getAgentTemplate + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveA = resolve; + }), + ) + .mockResolvedValueOnce(TEMPLATE_B_SAME_SLUG); + await renderStep(); + await flush(); + expect(document.body.textContent).toContain("Resolving your template…"); + + flowMock.organizationId = "org-2"; + await rerenderStep(); + await flush(); + + // B's own lookup started and resolved — pending cleared with B's content. + expect(templateMocks.getAgentTemplate).toHaveBeenCalledTimes(2); + expect(document.body.textContent).toContain("PR Engineer B"); + + // Team A's late resolution is ignored. + await act(async () => { + resolveA(TEMPLATE); + }); + await flush(); + expect(document.body.textContent).toContain("PR Engineer B"); + + await click(buttonByText("Create agent")); + const args = flowMock.createAgent.mock.calls[0]?.[0] as Record; + expect(args.organizationId).toBe("org-2"); + expect(args.templateIds).toEqual([TEMPLATE_B_SAME_SLUG.id]); + }); +}); diff --git a/packages/web/src/pages/onboarding/steps/step-create-agent.tsx b/packages/web/src/pages/onboarding/steps/step-create-agent.tsx index f5075b580..13c055be6 100644 --- a/packages/web/src/pages/onboarding/steps/step-create-agent.tsx +++ b/packages/web/src/pages/onboarding/steps/step-create-agent.tsx @@ -1,10 +1,12 @@ -import type { AgentVisibility } from "@first-tree/shared"; +import type { AgentTemplatePublicTemplate, AgentVisibility } from "@first-tree/shared"; import { ArrowRight } from "lucide-react"; -import { useEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { getAgentTemplate } from "../../../api/agent-templates.js"; import { useAuth } from "../../../auth/auth-context.js"; import { Button } from "../../../components/ui/button.js"; import { Input } from "../../../components/ui/input.js"; import { OptionCard } from "../../../components/ui/option-card.js"; +import { readOnboardingTemplateIntent } from "../../../utils/onboarding-flags.js"; import { asRuntimeProvider, PROVIDER_LABEL } from "../../clients/cards/shared/providers.js"; import { COPY } from "../copy.js"; import { FlowHint, WorkingState } from "../flow-ui.js"; @@ -55,6 +57,73 @@ export function StepCreateAgent() { } = useOnboardingFlow(); const { currentOrgHasPersonalAgent } = useAuth(); + // Template intent handoff from the public `/templates/:slug?use=1` entry. + // Per-org (sessionStorage); the public-safe detail is fetched so the step + // can show the responsibility the member picked. Any failure — fetch error, + // retired Template, invalid/stale handoff — degrades to a recoverable hint + // and leaves plain create fully available. + // + // The onboarding shell lets the member switch Teams while this step stays + // mounted, so the intent is re-read whenever the selected org changes: + // every piece of intent state (slug, resolved template, unavailable flag, + // and the user's Remove decision) belongs to ONE org and is dropped on + // switch — Team A's Template id or removal must never leak into Team B. + const [intentOrg, setIntentOrg] = useState(organizationId); + const [intentSlug, setIntentSlug] = useState(() => + organizationId ? readOnboardingTemplateIntent(organizationId) : null, + ); + const [intentTemplate, setIntentTemplate] = useState(null); + const [intentUnavailable, setIntentUnavailable] = useState(false); + const [intentRemoved, setIntentRemoved] = useState(false); + if (organizationId !== intentOrg) { + // Render-time reset (React's recommended derived-state pattern, matching + // the flow provider's own org-change handling) so the stale intent never + // paints or submits for the new org. + setIntentOrg(organizationId); + setIntentSlug(organizationId ? readOnboardingTemplateIntent(organizationId) : null); + setIntentTemplate(null); + setIntentUnavailable(false); + setIntentRemoved(false); + } + const intentFetchSeqRef = useRef(0); + // biome-ignore lint/correctness/useExhaustiveDependencies: the invalidation must fire exactly when the committed {org, slug} identity changes; the ref itself is not a dependency. + useLayoutEffect(() => { + // Invalidate in-flight lookups from the PREVIOUS committed {org, slug} + // synchronously at commit. A passive effect would leave a + // commit-to-effect window where Team A's late success/failure callback + // could still pass the old sequence and mutate Team B's intent state; + // layout effects run inside the commit, before any promise callback can + // interleave. + intentFetchSeqRef.current += 1; + }, [organizationId, intentSlug]); + // biome-ignore lint/correctness/useExhaustiveDependencies: organizationId is part of the committed {org, slug} lookup identity even though the request itself only uses the slug. + useEffect(() => { + // Sequence-guarded so a late response for the PREVIOUS org's slug can + // never overwrite the new org's state. The lookup identity is the + // committed {organizationId, intentSlug} pair — two Teams may hand off + // the SAME slug, and the new Team still needs its own request (the slug + // string alone does not change). + const seq = ++intentFetchSeqRef.current; + if (!intentSlug) return; + getAgentTemplate(intentSlug) + .then((template) => { + if (seq !== intentFetchSeqRef.current) return; + if (template.status === "active") setIntentTemplate(template); + else setIntentUnavailable(true); + }) + .catch(() => { + if (seq !== intentFetchSeqRef.current) return; + setIntentUnavailable(true); + }); + }, [organizationId, intentSlug]); + const activeIntentTemplate = intentTemplate && !intentRemoved ? intentTemplate : null; + // An explicit intent whose lookup is still in flight. While pending, + // creation is BLOCKED (visible resolving state + handler guard) so a + // slow/hung lookup can never silently turn an explicit adoption into a + // plain Agent; the explicit Remove escape unlocks plain creation and a + // late response can never reapply or re-block. + const intentPending = intentSlug !== null && !intentTemplate && !intentUnavailable && !intentRemoved; + // Fresh onboarding entry always lands on the opening step and walks forward // (inferInitialStepIndex ignores server readiness), so a member who already // created their personal agent in this org — e.g. a refresh / new tab after @@ -77,6 +146,7 @@ export function StepCreateAgent() { !!computer.connectedClient && !!computer.selectedRuntime && computer.okRuntimes.includes(computer.selectedRuntime) && + !intentPending && agentPhase === "idle"; // The coding-agent picker lives HERE now (moved from connect-computer). Always @@ -134,13 +204,16 @@ export function StepCreateAgent() { } const handleCreate = (): void => { - if (!canCreate || !computer.connectedClient || !computer.selectedRuntime) return; + // Guard the handler itself, not just the button: an unresolved explicit + // intent must never submit as a plain create. + if (!canCreate || intentPending || !computer.connectedClient || !computer.selectedRuntime) return; void createAgent({ displayName: trimmed, clientId: computer.connectedClient.id, runtimeProvider: computer.selectedRuntime, visibility, organizationId, + ...(activeIntentTemplate ? { templateIds: [activeIntentTemplate.id] } : {}), }); }; @@ -152,6 +225,39 @@ export function StepCreateAgent() { {COPY.createAgent.subtitle}

+ {/* Template intent handoff. Default-selected and removable; with no + intent this block renders nothing and the step is byte-identical to + the ordinary create path. */} + {intentPending && ( +
+

+ Resolving your template… +

+
+ {/* Explicit escape hatch: plain creation without waiting. Once + removed, a late lookup can never reapply or re-block. */} + +
+
+ )} + {activeIntentTemplate && ( +
+
+
{activeIntentTemplate.name}
+ +
+
{activeIntentTemplate.public.tagline}
+
{activeIntentTemplate.public.purpose}
+
+ )} + {intentUnavailable && !intentRemoved && !activeIntentTemplate && ( + {COPY.createAgent.templateIntentUnavailable} + )} + {/* Coding agent — always a list (even for one), default Claude Code. Stays visible (disabled) when the computer drops, so the field never vanishes from under the user. */} diff --git a/packages/web/src/pages/styleguide-preview.tsx b/packages/web/src/pages/styleguide-preview.tsx index 67d68906c..f670ba57a 100644 --- a/packages/web/src/pages/styleguide-preview.tsx +++ b/packages/web/src/pages/styleguide-preview.tsx @@ -61,7 +61,8 @@ const TEXT_COLORS: SwatchDef[] = [ { name: "--fg-2", token: "var(--fg-2)", note: "secondary" }, { name: "--fg-3", token: "var(--fg-3)", note: "tertiary / hints" }, { name: "--fg-4", token: "var(--fg-4)", note: "disabled" }, - { name: "--fg-on-vivid", token: "var(--fg-on-vivid)", note: "on color (no invert)" }, + { name: "--fg-on-vivid", token: "var(--fg-on-vivid)", note: "on vivid hues (avatars/badges)" }, + { name: "--fg-on-brand", token: "var(--fg-on-brand)", note: "on brand green (AA, no invert)" }, ]; const SURFACE_COLORS: SwatchDef[] = [ diff --git a/packages/web/src/pages/templates/__tests__/template-detail-page.test.tsx b/packages/web/src/pages/templates/__tests__/template-detail-page.test.tsx new file mode 100644 index 000000000..7e347c2e3 --- /dev/null +++ b/packages/web/src/pages/templates/__tests__/template-detail-page.test.tsx @@ -0,0 +1,327 @@ +// @vitest-environment happy-dom + +import type { AgentTemplatePublicTemplate } from "@first-tree/shared"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "../../../api/client.js"; +import { TemplateDetailPage } from "../template-detail-page.js"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const templateMocks = vi.hoisted(() => ({ + listAgentTemplates: vi.fn(), + getAgentTemplate: vi.fn(), + updateAgentTemplates: vi.fn(), +})); + +const analyticsMocks = vi.hoisted(() => ({ + trackEvent: vi.fn(), +})); + +const authMock = vi.hoisted(() => ({ + value: { + isAuthenticated: false, + meLoaded: false, + meAuthoritative: false, + refreshMe: vi.fn(async () => undefined), + memberships: [] as unknown[], + }, +})); + +const navigateMock = vi.hoisted(() => vi.fn()); + +const intentMock = vi.hoisted(() => ({ + calls: [] as Array<{ slug: string }>, +})); + +vi.mock("../../../api/agent-templates.js", () => templateMocks); +vi.mock("../../../analytics.js", async (importOriginal) => ({ + ...(await importOriginal()), + ...analyticsMocks, +})); +vi.mock("../../../auth/auth-context.js", () => ({ + AuthProvider: ({ children }: { children: ReactNode }) => children, + useAuth: () => authMock.value, +})); +vi.mock("react-router", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => navigateMock }; +}); +vi.mock("../template-use-intent.js", () => ({ + TemplateUseIntent: ({ template }: { template: AgentTemplatePublicTemplate }) => { + intentMock.calls.push({ slug: template.slug }); + return
intent-resolution-stub
; + }, +})); + +const NOW = "2026-07-30T12:00:00.000Z"; + +function template(overrides: Partial = {}): AgentTemplatePublicTemplate { + return { + id: "0190f000-0000-7000-8000-000000000001", + slug: "pr-engineer", + name: "PR Engineer", + status: "active", + public: { + tagline: "Reviews your pull requests", + purpose: "Purpose text", + targetUsers: "Indie hackers", + userValue: "Value text", + instructionsSummary: "Instructions summary", + toolsAndSkillsSummary: "Tools summary", + }, + updatedAt: NOW, + replacement: null, + ...overrides, + }; +} + +let root: Root | null = null; +let loginStateProbe: { pathname: string; search: string } | null = null; + +function LoginProbe() { + const location = useLocation(); + const from = (location.state as { from?: { pathname: string; search: string } } | null)?.from ?? null; + loginStateProbe = from; + return
login-stub
; +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +let pageQueryClient: QueryClient | null = null; + +function pageTree(entry: string) { + return ( + + + + } /> + } /> + + + + ); +} + +async function renderPage(entry: string): Promise { + const container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + pageQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root?.render(pageTree(entry)); + }); + await flush(); +} + +async function rerenderPage(entry: string): Promise { + await act(async () => { + root?.render(pageTree(entry)); + }); + await flush(); +} + +async function click(element: Element | null): Promise { + if (!element) throw new Error("Expected element to click"); + await act(async () => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + await flush(); +} + +function buttonByText(text: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll("button")].find((el) => el.textContent?.trim() === text); + if (!button) throw new Error(`Missing button "${text}". Body: ${document.body.textContent ?? ""}`); + return button; +} + +describe("TemplateDetailPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + intentMock.calls.length = 0; + loginStateProbe = null; + authMock.value.isAuthenticated = false; + authMock.value.meLoaded = false; + authMock.value.meAuthoritative = false; + authMock.value.refreshMe = vi.fn(async () => undefined); + authMock.value.memberships = []; + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; + }); + + it("renders the public-safe detail for an active template", async () => { + templateMocks.getAgentTemplate.mockResolvedValue(template()); + await renderPage("/templates/pr-engineer"); + + expect(templateMocks.getAgentTemplate).toHaveBeenCalledWith("pr-engineer"); + expect(document.body.textContent).toContain("PR Engineer"); + expect(document.body.textContent).toContain("Reviews your pull requests"); + expect(document.body.textContent).toContain("Purpose text"); + expect(document.body.textContent).toContain("Indie hackers"); + expect(document.body.textContent).toContain("Value text"); + expect(document.body.textContent).toContain("Tools summary"); + const viewCalls = analyticsMocks.trackEvent.mock.calls.filter(([name]) => name === "agent_template_detail_view"); + expect(viewCalls).toHaveLength(1); + expect(viewCalls[0]?.[1]).toEqual({ slug: "pr-engineer", status: "active", authenticated: false }); + // The primary CTA uses the shared brand variant with the AA on-brand + // foreground (near-white on brand green fails WCAG AA). + const cta = buttonByText("Use this template"); + expect(cta.className).toContain("bg-brand"); + expect(cta.className).toContain("text-brand-foreground"); + expect(cta.className).not.toContain("--fg-on-vivid"); + }); + + it("starts the use intent from the CTA with safe analytics", async () => { + templateMocks.getAgentTemplate.mockResolvedValue(template()); + await renderPage("/templates/pr-engineer"); + await click(buttonByText("Use this template")); + + expect(analyticsMocks.trackEvent).toHaveBeenCalledWith("agent_template_use_started", { + slug: "pr-engineer", + authenticated: false, + team_count: 0, + }); + expect(navigateMock).toHaveBeenCalledWith("/templates/pr-engineer?use=1"); + }); + + it("explains a retired template and links its replacement", async () => { + templateMocks.getAgentTemplate.mockResolvedValue( + template({ status: "retired", replacement: { slug: "pr-engineer-v2", name: "PR Engineer v2" } }), + ); + await renderPage("/templates/pr-engineer"); + + expect(document.body.textContent).toContain("has been retired"); + expect(document.body.textContent).not.toContain("Use this template"); + const links = [...document.body.querySelectorAll("a")].map((a) => a.getAttribute("href")); + expect(links).toContain("/templates/pr-engineer-v2"); + }); + + it("renders a neutral not-found for a 404 and for an invalid slug", async () => { + templateMocks.getAgentTemplate.mockRejectedValue(new ApiError(404, "not found")); + await renderPage("/templates/ghost"); + expect(document.body.textContent).toContain("couldn't find that template"); + expect([...document.body.querySelectorAll("a")].map((a) => a.getAttribute("href"))).toContain("/templates"); + + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; + vi.clearAllMocks(); + await renderPage("/templates/Not_A_Slug"); + expect(templateMocks.getAgentTemplate).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("couldn't find that template"); + }); + + it("offers retry on a network error", async () => { + templateMocks.getAgentTemplate.mockRejectedValueOnce(new ApiError(500, "boom")); + await renderPage("/templates/pr-engineer"); + expect(document.body.textContent).toContain("couldn't load this template"); + + templateMocks.getAgentTemplate.mockResolvedValueOnce(template()); + await click(buttonByText("Try again")); + expect(templateMocks.getAgentTemplate).toHaveBeenCalledTimes(2); + expect(document.body.textContent).toContain("PR Engineer"); + }); + + it("bounces a logged-out intent visitor to /login with the exact intent URL preserved", async () => { + templateMocks.getAgentTemplate.mockResolvedValue(template()); + await renderPage("/templates/pr-engineer?use=1"); + + expect(document.body.textContent).toContain("login-stub"); + expect(loginStateProbe).toMatchObject({ pathname: "/templates/pr-engineer", search: "?use=1" }); + }); + + it("treats non-canonical intent spellings as ordinary detail, with no login bounce", async () => { + for (const entry of [ + "/templates/pr-engineer?use=1&campaign=x", + "/templates/pr-engineer?use=1&use=1", + "/templates/pr-engineer?use=1#details", + "/templates/pr-engineer?use=1&", + ]) { + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; + vi.clearAllMocks(); + loginStateProbe = null; + templateMocks.getAgentTemplate.mockResolvedValue(template()); + await renderPage(entry); + + // Logged-out, but NOT bounced to /login: the shared strict parser + // rejected the URL as an intent, so this is just the public detail. + expect(document.body.textContent).not.toContain("login-stub"); + expect(loginStateProbe).toBeNull(); + expect(document.body.textContent).toContain("PR Engineer"); + } + }); + + it("hands a signed-in active intent to the intent resolution", async () => { + authMock.value.isAuthenticated = true; + authMock.value.meLoaded = true; + authMock.value.meAuthoritative = true; + templateMocks.getAgentTemplate.mockResolvedValue(template()); + await renderPage("/templates/pr-engineer?use=1"); + + expect(intentMock.calls).toEqual([{ slug: "pr-engineer" }]); + expect(document.body.textContent).toContain("intent-resolution-stub"); + }); + + it("never resolves an intent for a retired template", async () => { + authMock.value.isAuthenticated = true; + authMock.value.meLoaded = true; + authMock.value.meAuthoritative = true; + templateMocks.getAgentTemplate.mockResolvedValue(template({ status: "retired" })); + await renderPage("/templates/pr-engineer?use=1"); + + expect(intentMock.calls).toHaveLength(0); + expect(document.body.textContent).toContain("has been retired"); + }); + + it("shows a recoverable error with guarded retry when signed-in intent lacks /me authority", async () => { + authMock.value.isAuthenticated = true; + authMock.value.meLoaded = true; + authMock.value.meAuthoritative = false; + templateMocks.getAgentTemplate.mockResolvedValue(template()); + let resolveRetry!: () => void; + authMock.value.refreshMe = vi.fn( + () => + new Promise((resolve) => { + resolveRetry = resolve; + }), + ); + await renderPage("/templates/pr-engineer?use=1"); + + // No resolution surface may mount without authoritative memberships: + // no chooser, no dialog, no handoff. + expect(document.body.textContent).toContain("couldn't confirm your team"); + expect(document.body.textContent).not.toContain("intent-resolution-stub"); + expect(intentMock.calls).toHaveLength(0); + + // Retry is guarded against double-fire while in flight. + await click(buttonByText("Try again")); + expect(authMock.value.refreshMe).toHaveBeenCalledTimes(1); + expect(document.body.textContent).toContain("Retrying…"); + await click(buttonByText("Retrying…")); + expect(authMock.value.refreshMe).toHaveBeenCalledTimes(1); + + // A successful fresh /me flips authority and proceeds into resolution. + authMock.value.meAuthoritative = true; + await act(async () => { + resolveRetry(); + }); + await rerenderPage("/templates/pr-engineer?use=1"); + expect(intentMock.calls).toEqual([{ slug: "pr-engineer" }]); + expect(document.body.textContent).toContain("intent-resolution-stub"); + }); +}); diff --git a/packages/web/src/pages/templates/__tests__/template-detail-slug-transition.test.tsx b/packages/web/src/pages/templates/__tests__/template-detail-slug-transition.test.tsx new file mode 100644 index 000000000..ebb113460 --- /dev/null +++ b/packages/web/src/pages/templates/__tests__/template-detail-slug-transition.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment happy-dom + +import type { AgentTemplatePublicTemplate, MeMembership } from "@first-tree/shared"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter, Route, Routes, useNavigate } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TemplateDetailPage } from "../template-detail-page.js"; + +/** + * Same-Route slug transitions: React Router reuses the TemplateDetailPage + * instance across `/templates/:slug` param changes, so the sticky intent + * snapshot and per-slug analytics must be keyed by slug identity — never by + * mount. TemplateUseIntent is stubbed so these tests observe exactly which + * Template the page hands to intent resolution. + */ + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const templateMocks = vi.hoisted(() => ({ + listAgentTemplates: vi.fn(), + getAgentTemplate: vi.fn(), + updateAgentTemplates: vi.fn(), +})); + +const flagsMocks = vi.hoisted(() => ({ + writeOnboardingTemplateIntent: vi.fn(), +})); + +const analyticsMocks = vi.hoisted(() => ({ + trackEvent: vi.fn(), +})); + +const intentStubCalls = vi.hoisted(() => ({ + slugs: [] as string[], +})); + +vi.mock("../../../api/agent-templates.js", () => templateMocks); +vi.mock("../../../utils/onboarding-flags.js", async (importOriginal) => ({ + ...(await importOriginal()), + ...flagsMocks, +})); +vi.mock("../../../analytics.js", async (importOriginal) => ({ + ...(await importOriginal()), + ...analyticsMocks, +})); +vi.mock("../../../components/new-agent-dialog.js", () => ({ + NewAgentDialog: () => null, +})); +vi.mock("../template-use-intent.js", () => ({ + TemplateUseIntent: ({ template }: { template: AgentTemplatePublicTemplate }) => { + intentStubCalls.slugs.push(template.slug); + return
{`intent-stub:${template.slug}`}
; + }, +})); +vi.mock("../../../auth/auth-context.js", () => ({ + AuthProvider: ({ children }: { children: ReactNode }) => children, + useAuth: () => authMock.value, +})); + +const authMock = vi.hoisted(() => ({ + value: { + isAuthenticated: true, + meLoaded: true, + meAuthoritative: true, + onboardingStep: "completed" as const, + currentOrgHasPersonalAgent: true, + onboardingDismissedAt: null as string | null, + onboardingCompletedAt: "2026-07-01T00:00:00.000Z" as string | null, + organizationId: "org-1" as string | null, + memberships: [] as MeMembership[], + selectOrganization: vi.fn(async () => undefined), + }, +})); + +const NOW = "2026-07-30T12:00:00.000Z"; + +function template(id: string, slug: string, name: string): AgentTemplatePublicTemplate { + return { + id, + slug, + name, + status: "active", + public: { + tagline: `Tagline of ${name}`, + purpose: `Purpose of ${name}`, + targetUsers: `Users of ${name}`, + userValue: `Value of ${name}`, + instructionsSummary: `Instructions of ${name}`, + toolsAndSkillsSummary: `Tools of ${name}`, + }, + updatedAt: NOW, + replacement: null, + }; +} + +const TEMPLATE_A = template("0190f000-0000-7000-8000-000000000001", "pr-engineer", "PR Engineer"); +const TEMPLATE_B = template("0190f000-0000-7000-8000-000000000002", "docs-writer", "Docs Writer"); + +let root: Root | null = null; +const navRef: { current: ((to: string) => void) | null } = { current: null }; + +function NavProbe() { + const navigate = useNavigate(); + navRef.current = (to: string) => navigate(to); + return null; +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function renderApp(entry: string): Promise { + const container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root?.render( + + + + + } /> + + + , + ); + }); + await flush(); +} + +async function navigateTo(to: string): Promise { + if (!navRef.current) throw new Error("nav probe not mounted"); + await act(async () => { + navRef.current?.(to); + }); + await flush(); +} + +function detailViewEvents(): Array> { + return analyticsMocks.trackEvent.mock.calls + .filter(([name]) => name === "agent_template_detail_view") + .map(([, params]) => params as Record); +} + +describe("TemplateDetailPage slug transitions (same Route instance)", () => { + beforeEach(() => { + vi.clearAllMocks(); + intentStubCalls.slugs.length = 0; + templateMocks.getAgentTemplate.mockImplementation(async (slug: string) => + slug === "pr-engineer" ? TEMPLATE_A : TEMPLATE_B, + ); + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + navRef.current = null; + document.body.innerHTML = ""; + }); + + it("never shows the previous template's intent while the next slug's detail is pending", async () => { + await renderApp("/templates/pr-engineer?use=1"); + await flush(); + // Intent resolution for A is live (sticky captured). + expect(document.body.textContent).toContain("intent-stub:pr-engineer"); + + // Navigate A → B on the SAME Route instance, with B's detail deferred. + let resolveB!: (value: AgentTemplatePublicTemplate) => void; + templateMocks.getAgentTemplate.mockImplementation( + () => + new Promise((resolve) => { + resolveB = resolve; + }), + ); + await navigateTo("/templates/docs-writer?use=1"); + await flush(); + + // The pending window must not render A's intent at all. + expect(document.body.textContent).not.toContain("intent-stub:pr-engineer"); + expect(document.body.textContent).not.toContain("PR Engineer"); + expect(intentStubCalls.slugs.filter((slug) => slug === "docs-writer")).toHaveLength(0); + expect(flagsMocks.writeOnboardingTemplateIntent).not.toHaveBeenCalled(); + + // Once B resolves, only B's intent is used. + await act(async () => { + resolveB(TEMPLATE_B); + }); + await flush(); + expect(document.body.textContent).toContain("intent-stub:docs-writer"); + expect(document.body.textContent).not.toContain("intent-stub:pr-engineer"); + }); + + it("dedupes the detail view event by slug transition, not by mount", async () => { + await renderApp("/templates/pr-engineer"); + await flush(); + expect(detailViewEvents()).toEqual([{ slug: "pr-engineer", status: "active", authenticated: true }]); + + // A → B: B earns its own event on the same component instance. + await navigateTo("/templates/docs-writer"); + await flush(); + expect(detailViewEvents()).toEqual([ + { slug: "pr-engineer", status: "active", authenticated: true }, + { slug: "docs-writer", status: "active", authenticated: true }, + ]); + + // B → A: a fresh transition back fires again. + await navigateTo("/templates/pr-engineer"); + await flush(); + expect(detailViewEvents()).toHaveLength(3); + expect(detailViewEvents().at(-1)).toEqual({ slug: "pr-engineer", status: "active", authenticated: true }); + }); +}); diff --git a/packages/web/src/pages/templates/__tests__/template-intent-integration.test.tsx b/packages/web/src/pages/templates/__tests__/template-intent-integration.test.tsx new file mode 100644 index 000000000..25af76f3c --- /dev/null +++ b/packages/web/src/pages/templates/__tests__/template-intent-integration.test.tsx @@ -0,0 +1,296 @@ +// @vitest-environment happy-dom + +import type { AgentTemplatePublicTemplate, MeMembership } from "@first-tree/shared"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, type ReactNode, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TemplateDetailPage } from "../template-detail-page.js"; + +/** + * Integration coverage for the Team-confirmation state machine: the REAL + * React Query client is cleared inside selectOrganization (mirroring + * auth-context), so the detail query re-pends and refetches mid-flow. The + * intent subtree — including an open NewAgentDialog — must survive that + * window, and the dialog may only open against the exact confirmed Team. + */ + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const templateMocks = vi.hoisted(() => ({ + listAgentTemplates: vi.fn(), + getAgentTemplate: vi.fn(), + updateAgentTemplates: vi.fn(), +})); + +const flagsMocks = vi.hoisted(() => ({ + writeOnboardingTemplateIntent: vi.fn(), +})); + +const dialogMock = vi.hoisted(() => ({ + mounts: 0, + openProps: [] as Array<{ open: boolean; initialTemplateSlug?: string }>, +})); + +const analyticsMocks = vi.hoisted(() => ({ + trackEvent: vi.fn(), +})); + +const authMock = vi.hoisted(() => ({ + value: { + isAuthenticated: true, + meLoaded: true, + meAuthoritative: true, + onboardingStep: "completed" as "connect" | "create_agent" | "completed" | null, + currentOrgHasPersonalAgent: true, + onboardingDismissedAt: null as string | null, + onboardingCompletedAt: "2026-07-01T00:00:00.000Z" as string | null, + organizationId: "org-1" as string | null, + memberships: [] as MeMembership[], + selectOrganization: vi.fn(async (_orgId: string) => undefined), + }, +})); + +vi.mock("../../../api/agent-templates.js", () => templateMocks); +vi.mock("../../../utils/onboarding-flags.js", async (importOriginal) => ({ + ...(await importOriginal()), + ...flagsMocks, +})); +vi.mock("../../../components/new-agent-dialog.js", () => ({ + NewAgentDialog: (props: { open: boolean; initialTemplateSlug?: string }) => { + dialogMock.openProps.push({ open: props.open, initialTemplateSlug: props.initialTemplateSlug }); + return ; + }, +})); +vi.mock("../../../analytics.js", async (importOriginal) => ({ + ...(await importOriginal()), + ...analyticsMocks, +})); +vi.mock("../../../auth/auth-context.js", () => ({ + AuthProvider: ({ children }: { children: ReactNode }) => children, + useAuth: () => authMock.value, +})); + +function DialogStub({ open }: { open: boolean }) { + useEffect(() => { + dialogMock.mounts += 1; + }, []); + return open ?
new-agent-dialog-stub
: null; +} + +const NOW = "2026-07-30T12:00:00.000Z"; + +const TEMPLATE: AgentTemplatePublicTemplate = { + id: "0190f000-0000-7000-8000-000000000001", + slug: "pr-engineer", + name: "PR Engineer", + status: "active", + public: { + tagline: "Reviews your pull requests", + purpose: "Purpose text", + targetUsers: "Indie hackers", + userValue: "Value text", + instructionsSummary: "Instructions summary", + toolsAndSkillsSummary: "Tools summary", + }, + updatedAt: NOW, + replacement: null, +}; + +function membership(id: string, orgId: string, orgName: string, overrides: Partial = {}): MeMembership { + return { + id, + organizationId: orgId, + organizationName: orgName, + role: "admin", + agentId: `agent-${orgId}`, + orgHasOtherMembers: false, + hasUsableAgent: true, + hasPersonalAgent: true, + onboardingSuppressedAt: null, + onboardingSuppressedReason: null, + onboardingCompletedAt: NOW, + ...overrides, + }; +} + +let root: Root | null = null; +let testQueryClient: QueryClient | null = null; + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function pageTree(): ReactNode { + return ( + + + + } /> + onboarding-stub
} /> + + + + ); +} + +async function renderPage(): Promise { + const container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + testQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root?.render(pageTree()); + }); + await flush(); +} + +/** + * Force a full re-render after auth mutations. In production this is exactly + * what AuthProvider's setSelectedOrgId/fetchMe state writes do after + * `selectOrganization`; our auth mock is a plain object, so the test drives + * the same propagation explicitly. + */ +async function rerenderPage(): Promise { + await act(async () => { + root?.render(pageTree()); + }); + await flush(); +} + +async function click(element: Element | null): Promise { + if (!element) throw new Error("Expected element to click"); + await act(async () => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + await flush(); +} + +function buttonByText(text: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll("button")].find((el) => el.textContent?.trim() === text); + if (!button) throw new Error(`Missing button "${text}". Body: ${document.body.textContent ?? ""}`); + return button; +} + +function optionCardByText(text: string): HTMLElement { + const label = [...document.body.querySelectorAll("label")].find((el) => el.textContent?.includes(text)); + if (!label) throw new Error(`Missing option card "${text}". Body: ${document.body.textContent ?? ""}`); + const input = label.querySelector("input"); + return (input ?? label) as HTMLElement; +} + +function queryClient(): QueryClient { + if (!testQueryClient) throw new Error("page not rendered"); + return testQueryClient; +} + +describe("Template intent × real queryClient.clear() integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + dialogMock.mounts = 0; + dialogMock.openProps.length = 0; + templateMocks.getAgentTemplate.mockResolvedValue(TEMPLATE); + authMock.value.isAuthenticated = true; + authMock.value.meLoaded = true; + authMock.value.meAuthoritative = true; + authMock.value.onboardingStep = "completed"; + authMock.value.currentOrgHasPersonalAgent = true; + authMock.value.onboardingDismissedAt = null; + authMock.value.onboardingCompletedAt = NOW; + authMock.value.organizationId = "org-1"; + authMock.value.memberships = [membership("m-1", "org-1", "Acme Team")]; + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + testQueryClient = null; + document.body.innerHTML = ""; + }); + + it("keeps the intent subtree mounted through cache clear + refetch and opens the dialog on the confirmed team", async () => { + authMock.value.selectOrganization = vi.fn(async (orgId: string) => { + // Mirror auth-context.selectOrganization: wipe every cached query, + // then settle on the target with a fresh memberships array. + queryClient().clear(); + authMock.value.organizationId = orgId; + authMock.value.memberships = [...authMock.value.memberships]; + }); + await renderPage(); + // Detail loaded once; chooser visible. + expect(document.body.textContent).toContain("Start with PR Engineer"); + const callsBefore = templateMocks.getAgentTemplate.mock.calls.length; + + await click(buttonByText("Continue")); + await flush(); + // Production: AuthProvider's state writes re-render every useAuth + // consumer, which also revives the cleared detail query. Simulate the + // same propagation (the auth mock is a plain object). + await rerenderPage(); + await flush(); + + // The clear + auth-driven re-render forced a real refetch of the detail + // query… + expect(templateMocks.getAgentTemplate.mock.calls.length).toBeGreaterThan(callsBefore); + // …yet the dialog subtree was never unmounted… + expect(dialogMock.mounts).toBe(1); + // …and it ended open against the confirmed team with the intent slug. + expect(document.body.textContent).toContain("new-agent-dialog-stub"); + expect(dialogMock.openProps.filter((p) => p.open).at(-1)?.initialTemplateSlug).toBe("pr-engineer"); + }); + + it("surfaces a recoverable error and never opens the dialog when auth reconciles to a fallback", async () => { + authMock.value.memberships = [membership("m-1", "org-1", "Acme Team"), membership("m-2", "org-2", "Side Team")]; + authMock.value.selectOrganization = vi.fn(async (_orgId: string) => { + queryClient().clear(); + // Target membership vanished mid-switch: auth settles on the fallback. + authMock.value.organizationId = "org-1"; + authMock.value.memberships = [membership("m-1", "org-1", "Acme Team")]; + }); + await renderPage(); + + await click(optionCardByText("Side Team")); + await click(buttonByText("Continue")); + await flush(); + await flush(); + + expect(document.body.textContent).toContain("couldn't confirm that team"); + expect(document.body.textContent).not.toContain("new-agent-dialog-stub"); + expect(dialogMock.openProps.every((p) => !p.open)).toBe(true); + expect(flagsMocks.writeOnboardingTemplateIntent).not.toHaveBeenCalled(); + }); + + it("writes the handoff for the confirmed team and enters onboarding when that team still needs it", async () => { + authMock.value.memberships = [ + membership("m-1", "org-1", "Acme Team"), + membership("m-2", "org-2", "Fresh Team", { + hasPersonalAgent: false, + hasUsableAgent: false, + onboardingCompletedAt: null, + }), + ]; + authMock.value.selectOrganization = vi.fn(async (orgId: string) => { + queryClient().clear(); + authMock.value.organizationId = orgId; + authMock.value.currentOrgHasPersonalAgent = false; + authMock.value.onboardingCompletedAt = null; + authMock.value.memberships = [...authMock.value.memberships]; + }); + await renderPage(); + + await click(optionCardByText("Fresh Team")); + await click(buttonByText("Continue")); + await flush(); + await flush(); + + expect(flagsMocks.writeOnboardingTemplateIntent).toHaveBeenCalledWith("org-2", "pr-engineer"); + expect(document.body.textContent).toContain("onboarding-stub"); + expect(document.body.textContent).not.toContain("new-agent-dialog-stub"); + expect(dialogMock.openProps.every((p) => !p.open)).toBe(true); + }); +}); diff --git a/packages/web/src/pages/templates/__tests__/template-library-page.test.tsx b/packages/web/src/pages/templates/__tests__/template-library-page.test.tsx new file mode 100644 index 000000000..18f5f9840 --- /dev/null +++ b/packages/web/src/pages/templates/__tests__/template-library-page.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment happy-dom + +import type { AgentTemplatePublicTemplate } from "@first-tree/shared"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +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 { TemplateLibraryPage } from "../template-library-page.js"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const templateMocks = vi.hoisted(() => ({ + listAgentTemplates: vi.fn(), + getAgentTemplate: vi.fn(), + updateAgentTemplates: vi.fn(), +})); + +const analyticsMocks = vi.hoisted(() => ({ + trackEvent: vi.fn(), +})); + +const authMock = vi.hoisted(() => ({ + value: { isAuthenticated: false }, +})); + +vi.mock("../../../api/agent-templates.js", () => templateMocks); +vi.mock("../../../analytics.js", async (importOriginal) => ({ + ...(await importOriginal()), + ...analyticsMocks, +})); +vi.mock("../../../auth/auth-context.js", () => ({ + AuthProvider: ({ children }: { children: ReactNode }) => children, + useAuth: () => authMock.value, +})); + +const NOW = "2026-07-30T12:00:00.000Z"; + +function template(id: string, slug: string, name: string): AgentTemplatePublicTemplate { + return { + id, + slug, + name, + status: "active", + public: { + tagline: `Tagline of ${name}`, + purpose: `Purpose of ${name}`, + targetUsers: `Users of ${name}`, + userValue: `Value of ${name}`, + instructionsSummary: `Instructions of ${name}`, + toolsAndSkillsSummary: `Tools of ${name}`, + }, + updatedAt: NOW, + replacement: null, + }; +} + +const TEMPLATE_A = template("0190f000-0000-7000-8000-000000000001", "pr-engineer", "PR Engineer"); +const TEMPLATE_B = template("0190f000-0000-7000-8000-000000000002", "docs-writer", "Docs Writer"); + +let root: Root | null = null; + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function renderPage(): Promise { + const container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root?.render( + + + + + , + ); + }); + await flush(); +} + +async function click(element: Element | null): Promise { + if (!element) throw new Error("Expected element to click"); + await act(async () => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + await flush(); +} + +function buttonByText(text: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll("button")].find((el) => el.textContent?.trim() === text); + if (!button) throw new Error(`Missing button "${text}". Body: ${document.body.textContent ?? ""}`); + return button; +} + +describe("TemplateLibraryPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + authMock.value.isAuthenticated = false; + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; + }); + + it("renders active templates from the public-safe catalog without auth", async () => { + templateMocks.listAgentTemplates.mockResolvedValue({ templates: [TEMPLATE_A, TEMPLATE_B] }); + await renderPage(); + + expect(templateMocks.listAgentTemplates).toHaveBeenCalledTimes(1); + expect(document.body.textContent).toContain("Agent templates"); + expect(document.body.textContent).toContain("PR Engineer"); + expect(document.body.textContent).toContain("Tagline of PR Engineer"); + expect(document.body.textContent).toContain("For Users of PR Engineer"); + expect(document.body.textContent).toContain("Docs Writer"); + // Cards link to the public detail route. + const links = [...document.body.querySelectorAll("a")].map((a) => a.getAttribute("href")); + expect(links).toContain("/templates/pr-engineer"); + expect(links).toContain("/templates/docs-writer"); + // Deduped library view with safe properties only. + const viewCalls = analyticsMocks.trackEvent.mock.calls.filter(([name]) => name === "agent_template_library_view"); + expect(viewCalls).toHaveLength(1); + expect(viewCalls[0]?.[1]).toEqual({ template_count: 2, authenticated: false }); + }); + + it("shows the empty state when no templates are available", async () => { + templateMocks.listAgentTemplates.mockResolvedValue({ templates: [] }); + await renderPage(); + expect(document.body.textContent).toContain("No templates are available yet."); + }); + + it("shows a recoverable error and retries", async () => { + templateMocks.listAgentTemplates.mockRejectedValueOnce(new Error("boom")); + await renderPage(); + expect(document.body.textContent).toContain("couldn't load the template library"); + + templateMocks.listAgentTemplates.mockResolvedValueOnce({ templates: [TEMPLATE_A] }); + await click(buttonByText("Try again")); + expect(templateMocks.listAgentTemplates).toHaveBeenCalledTimes(2); + expect(document.body.textContent).toContain("PR Engineer"); + }); + + it("reports the authenticated flag for signed-in visitors", async () => { + authMock.value.isAuthenticated = true; + templateMocks.listAgentTemplates.mockResolvedValue({ templates: [TEMPLATE_A] }); + await renderPage(); + expect(analyticsMocks.trackEvent).toHaveBeenCalledWith("agent_template_library_view", { + template_count: 1, + authenticated: true, + }); + }); +}); diff --git a/packages/web/src/pages/templates/__tests__/template-use-intent.test.tsx b/packages/web/src/pages/templates/__tests__/template-use-intent.test.tsx new file mode 100644 index 000000000..fd4ba9be5 --- /dev/null +++ b/packages/web/src/pages/templates/__tests__/template-use-intent.test.tsx @@ -0,0 +1,548 @@ +// @vitest-environment happy-dom + +import type { AgentTemplatePublicTemplate, MeMembership } from "@first-tree/shared"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TemplateUseIntent } from "../template-use-intent.js"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const flagsMocks = vi.hoisted(() => ({ + writeOnboardingTemplateIntent: vi.fn(), +})); + +const dialogMock = vi.hoisted(() => ({ + props: [] as Array<{ open: boolean; initialTemplateSlug?: string }>, + latestOnCreated: null as null | ((agent: { uuid: string }, runtime: string, templateCount: number) => void), +})); + +const navigateMock = vi.hoisted(() => vi.fn()); + +const analyticsMocks = vi.hoisted(() => ({ + trackEvent: vi.fn(), +})); + +const authMock = vi.hoisted(() => ({ + value: { + meLoaded: true, + onboardingStep: "completed" as "connect" | "create_agent" | "completed" | null, + currentOrgHasPersonalAgent: true, + onboardingDismissedAt: null as string | null, + onboardingCompletedAt: "2026-07-01T00:00:00.000Z" as string | null, + organizationId: "org-1" as string | null, + memberships: [] as MeMembership[], + selectOrganization: vi.fn(async (_orgId: string) => undefined), + }, +})); + +vi.mock("../../../utils/onboarding-flags.js", async (importOriginal) => ({ + ...(await importOriginal()), + ...flagsMocks, +})); +vi.mock("../../../components/new-agent-dialog.js", () => ({ + NewAgentDialog: (props: { + open: boolean; + initialTemplateSlug?: string; + onCreated: (agent: { uuid: string }, runtime: string, templateCount: number) => void; + }) => { + dialogMock.props.push({ open: props.open, initialTemplateSlug: props.initialTemplateSlug }); + dialogMock.latestOnCreated = props.onCreated; + return props.open ?
new-agent-dialog-stub
: null; + }, +})); +vi.mock("../../../analytics.js", async (importOriginal) => ({ + ...(await importOriginal()), + ...analyticsMocks, +})); +vi.mock("../../../auth/auth-context.js", () => ({ + AuthProvider: ({ children }: { children: ReactNode }) => children, + useAuth: () => authMock.value, +})); +vi.mock("react-router", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => navigateMock }; +}); + +const NOW = "2026-07-30T12:00:00.000Z"; + +const TEMPLATE: AgentTemplatePublicTemplate = { + id: "0190f000-0000-7000-8000-000000000001", + slug: "pr-engineer", + name: "PR Engineer", + status: "active", + public: { + tagline: "Reviews your pull requests", + purpose: "Purpose text", + targetUsers: "Indie hackers", + userValue: "Value text", + instructionsSummary: "Instructions summary", + toolsAndSkillsSummary: "Tools summary", + }, + updatedAt: NOW, + replacement: null, +}; + +function membership(id: string, orgId: string, orgName: string, overrides: Partial = {}): MeMembership { + return { + id, + organizationId: orgId, + organizationName: orgName, + role: "admin", + agentId: `agent-${orgId}`, + orgHasOtherMembers: false, + hasUsableAgent: true, + hasPersonalAgent: true, + onboardingSuppressedAt: null, + onboardingSuppressedReason: null, + onboardingCompletedAt: NOW, + ...overrides, + }; +} + +let root: Root | null = null; +let container: HTMLElement | null = null; + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function tree(): ReactNode { + return ( + + + } /> + onboarding-stub} /> + + + ); +} + +async function renderIntent(): Promise { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root?.render({tree()}); + }); + await flush(); +} + +async function rerender(): Promise { + await act(async () => { + root?.render({tree()}); + }); + await flush(); +} + +async function click(element: Element | null): Promise { + if (!element) throw new Error("Expected element to click"); + await act(async () => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + await flush(); +} + +function buttonByText(text: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll("button")].find((el) => el.textContent?.trim() === text); + if (!button) throw new Error(`Missing button "${text}". Body: ${document.body.textContent ?? ""}`); + return button; +} + +function optionCardByText(text: string): HTMLElement { + const label = [...document.body.querySelectorAll("label")].find((el) => el.textContent?.includes(text)); + if (!label) throw new Error(`Missing option card "${text}". Body: ${document.body.textContent ?? ""}`); + const input = label.querySelector("input"); + return (input ?? label) as HTMLElement; +} + +function radioCheckedForCard(text: string): boolean { + const label = [...document.body.querySelectorAll("label")].find((el) => el.textContent?.includes(text)); + if (!label) throw new Error(`Missing option card "${text}". Body: ${document.body.textContent ?? ""}`); + const input = label.querySelector('input[type="radio"]'); + if (!input) throw new Error(`Missing radio in card "${text}"`); + return input.checked; +} + +function dialogOpenCount(): number { + return dialogMock.props.filter((p) => p.open).length; +} + +describe("TemplateUseIntent", () => { + beforeEach(() => { + vi.clearAllMocks(); + dialogMock.props.length = 0; + dialogMock.latestOnCreated = null; + authMock.value.meLoaded = true; + authMock.value.onboardingStep = "completed"; + authMock.value.currentOrgHasPersonalAgent = true; + authMock.value.onboardingDismissedAt = null; + authMock.value.onboardingCompletedAt = NOW; + authMock.value.organizationId = "org-1"; + authMock.value.memberships = [membership("m-1", "org-1", "Acme Team")]; + // Default: the switch lands on the exact target with a fresh memberships + // array (the real post-switch /me) — matching selectOrganization's + // client-side semantics. + authMock.value.selectOrganization = vi.fn(async (orgId: string) => { + authMock.value.organizationId = orgId; + authMock.value.memberships = [...authMock.value.memberships]; + }); + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + container = null; + document.body.innerHTML = ""; + }); + + it("hands a fresh-onboarding member a per-org handoff and enters /onboarding", async () => { + authMock.value.onboardingStep = "connect"; + authMock.value.currentOrgHasPersonalAgent = false; + authMock.value.onboardingCompletedAt = null; + await renderIntent(); + + expect(flagsMocks.writeOnboardingTemplateIntent).toHaveBeenCalledWith("org-1", "pr-engineer"); + expect(document.body.textContent).toContain("onboarding-stub"); + // The chooser / dialog path never ran. + expect(authMock.value.selectOrganization).not.toHaveBeenCalled(); + expect(dialogOpenCount()).toBe(0); + }); + + it("shows an explicit chooser even for a single team, then opens the dialog against the confirmed org", async () => { + await renderIntent(); + + expect(document.body.textContent).toContain("Start with PR Engineer"); + expect(document.body.textContent).toContain("Acme Team"); + expect(document.body.textContent).toContain("Current team"); + expect(flagsMocks.writeOnboardingTemplateIntent).not.toHaveBeenCalled(); + // The chooser's primary action shares the AA brand-foreground contract. + const continueButton = buttonByText("Continue"); + expect(continueButton.className).toContain("bg-brand"); + expect(continueButton.className).toContain("text-brand-foreground"); + + await click(buttonByText("Continue")); + await rerender(); + expect(authMock.value.selectOrganization).toHaveBeenCalledTimes(1); + expect(authMock.value.selectOrganization).toHaveBeenCalledWith("org-1"); + expect(dialogOpenCount()).toBeGreaterThan(0); + expect(dialogMock.props.filter((p) => p.open).at(-1)?.initialTemplateSlug).toBe("pr-engineer"); + }); + + it("lets a multi-team member pick the exact target team", async () => { + authMock.value.memberships = [membership("m-1", "org-1", "Acme Team"), membership("m-2", "org-2", "Side Team")]; + await renderIntent(); + + await click(optionCardByText("Side Team")); + await click(buttonByText("Continue")); + await rerender(); + expect(authMock.value.selectOrganization).toHaveBeenCalledWith("org-2"); + expect(dialogOpenCount()).toBeGreaterThan(0); + }); + + it("never opens the dialog when the team switch rejects", async () => { + authMock.value.selectOrganization = vi.fn(async () => { + throw new Error("switch failed"); + }); + await renderIntent(); + + // Confirming the CURRENT team (the common single-Team route) fails: + // the chooser must unfreeze with a recoverable error — never stay on + // "Confirming team…", never open the dialog, never hand off. + await click(buttonByText("Continue")); + await rerender(); + expect(document.body.textContent).toContain("couldn't switch to that team"); + expect(document.body.textContent).not.toContain("Confirming team…"); + expect(buttonByText("Continue").disabled).toBe(false); + expect(dialogOpenCount()).toBe(0); + expect(flagsMocks.writeOnboardingTemplateIntent).not.toHaveBeenCalled(); + }); + + it("never opens the dialog when auth reconciles to a fallback team", async () => { + authMock.value.memberships = [membership("m-1", "org-1", "Acme Team"), membership("m-2", "org-2", "Side Team")]; + // The target membership vanished mid-switch: selectOrganization resolves, + // but auth settles back on the ORIGINAL org with a fresh memberships + // array that no longer contains the target. + authMock.value.selectOrganization = vi.fn(async (_orgId: string) => { + authMock.value.organizationId = "org-1"; + authMock.value.memberships = [membership("m-1", "org-1", "Acme Team")]; + }); + await renderIntent(); + + await click(optionCardByText("Side Team")); + await click(buttonByText("Continue")); + await rerender(); + expect(document.body.textContent).toContain("couldn't confirm that team"); + expect(dialogOpenCount()).toBe(0); + expect(flagsMocks.writeOnboardingTemplateIntent).not.toHaveBeenCalled(); + }); + + it("hands off to onboarding when the confirmed team still needs it", async () => { + authMock.value.memberships = [ + membership("m-1", "org-1", "Acme Team"), + membership("m-2", "org-2", "Fresh Team", { + hasPersonalAgent: false, + hasUsableAgent: false, + onboardingCompletedAt: null, + }), + ]; + authMock.value.selectOrganization = vi.fn(async (orgId: string) => { + authMock.value.organizationId = orgId; + authMock.value.currentOrgHasPersonalAgent = false; + authMock.value.onboardingCompletedAt = null; + authMock.value.memberships = [...authMock.value.memberships]; + }); + await renderIntent(); + + await click(optionCardByText("Fresh Team")); + await click(buttonByText("Continue")); + await rerender(); + // Handoff written for the CONFIRMED team (never Team A's gate reused). + expect(flagsMocks.writeOnboardingTemplateIntent).toHaveBeenCalledWith("org-2", "pr-engineer"); + expect(document.body.textContent).toContain("onboarding-stub"); + expect(dialogOpenCount()).toBe(0); + }); + + it("navigates to the first workspace draft after creation", async () => { + await renderIntent(); + await click(buttonByText("Continue")); + await rerender(); + const onCreated = dialogMock.latestOnCreated; + if (!onCreated) throw new Error("dialog onCreated not captured"); + await act(async () => { + onCreated({ uuid: "agent-new-1" }, "claude-code", 1); + }); + await flush(); + + expect(analyticsMocks.trackEvent).toHaveBeenCalledWith("agent_create_draft_open", { template_count: 1 }); + expect(navigateMock).toHaveBeenCalledWith("/?c=draft&with=agent-new-1"); + }); + + it("ignores a double-click while the switch is in flight", async () => { + let resolveSwitch!: () => void; + authMock.value.selectOrganization = vi.fn( + (_orgId: string) => + new Promise((resolve) => { + resolveSwitch = () => { + // A resolved switch always carries a FRESH /me snapshot (new + // memberships identity) — the confirmation unlock condition. + authMock.value.memberships = [...authMock.value.memberships]; + resolve(undefined); + }; + }), + ); + await renderIntent(); + + const button = buttonByText("Continue"); + await click(button); + // In-flight: the button reads Confirming and is disabled; a second + // dispatch must not start a concurrent switch. + expect(document.body.textContent).toContain("Confirming team…"); + await click(buttonByText("Confirming team…")); + expect(authMock.value.selectOrganization).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveSwitch(); + }); + await rerender(); + expect(dialogOpenCount()).toBeGreaterThan(0); + }); + + it("suppresses the generic handoff while auth shows the unconfirmed target mid-flight", async () => { + authMock.value.memberships = [membership("m-1", "org-1", "Acme Team"), membership("m-2", "org-2", "Side Team")]; + let resolveSwitch!: () => void; + authMock.value.selectOrganization = vi.fn( + (_orgId: string) => + new Promise((resolve) => { + resolveSwitch = () => { + // A resolved switch always carries a FRESH /me snapshot (new + // memberships identity) — the confirmation unlock condition. + authMock.value.memberships = [...authMock.value.memberships]; + resolve(undefined); + }; + }), + ); + await renderIntent(); + + await click(optionCardByText("Side Team")); + await click(buttonByText("Continue")); + expect(authMock.value.selectOrganization).toHaveBeenCalledTimes(1); + + // Mid-flight, auth ALREADY shows org-2 with needs-onboarding facts (the + // real selectOrganization writes the selected org before /me confirms). + // The generic gate must not hand off or navigate for this unconfirmed Team. + authMock.value.organizationId = "org-2"; + authMock.value.currentOrgHasPersonalAgent = false; + authMock.value.onboardingCompletedAt = null; + await rerender(); + expect(flagsMocks.writeOnboardingTemplateIntent).not.toHaveBeenCalled(); + expect(document.body.textContent).not.toContain("onboarding-stub"); + expect(document.body.textContent).toContain("Confirming team…"); + expect(dialogOpenCount()).toBe(0); + + // The promise resolves with auth on the exact target: only NOW the gate + // is evaluated for org-2 and the handoff is written explicitly. + await act(async () => { + resolveSwitch(); + }); + await rerender(); + expect(flagsMocks.writeOnboardingTemplateIntent).toHaveBeenCalledTimes(1); + expect(flagsMocks.writeOnboardingTemplateIntent).toHaveBeenCalledWith("org-2", "pr-engineer"); + expect(document.body.textContent).toContain("onboarding-stub"); + expect(dialogOpenCount()).toBe(0); + }); + + it("never hands off to a fallback Team that needs onboarding after a failed confirmation", async () => { + authMock.value.memberships = [ + membership("m-1", "org-1", "Acme Team"), + membership("m-2", "org-2", "Side Team"), + membership("m-3", "org-3", "Fresh Team", { + hasPersonalAgent: false, + hasUsableAgent: false, + onboardingCompletedAt: null, + }), + ]; + let resolveSwitch!: () => void; + authMock.value.selectOrganization = vi.fn( + (_orgId: string) => + new Promise((resolve) => { + resolveSwitch = () => { + // A resolved switch always carries a FRESH /me snapshot (new + // memberships identity) — the confirmation unlock condition. + authMock.value.memberships = [...authMock.value.memberships]; + resolve(undefined); + }; + }), + ); + await renderIntent(); + + await click(optionCardByText("Side Team")); + await click(buttonByText("Continue")); + + // /me comes back: the org-2 membership vanished and auth reconciled to + // org-3, which happens to need onboarding. The user never confirmed it. + authMock.value.organizationId = "org-3"; + authMock.value.currentOrgHasPersonalAgent = false; + authMock.value.onboardingCompletedAt = null; + authMock.value.memberships = [ + membership("m-1", "org-1", "Acme Team"), + membership("m-3", "org-3", "Fresh Team", { + hasPersonalAgent: false, + hasUsableAgent: false, + onboardingCompletedAt: null, + }), + ]; + await act(async () => { + resolveSwitch(); + }); + await rerender(); + + expect(document.body.textContent).toContain("couldn't confirm that team"); + expect(flagsMocks.writeOnboardingTemplateIntent).not.toHaveBeenCalled(); + expect(document.body.textContent).not.toContain("onboarding-stub"); + expect(dialogOpenCount()).toBe(0); + }); + + it("freezes the team chooser for the whole switch flight", async () => { + authMock.value.memberships = [ + membership("m-1", "org-1", "Acme Team"), + membership("m-2", "org-2", "Side Team"), + membership("m-3", "org-3", "Third Team"), + ]; + let resolveSwitch!: () => void; + authMock.value.selectOrganization = vi.fn( + (orgId: string) => + new Promise((resolve) => { + resolveSwitch = () => { + // The exact target lands in auth before the promise resolves. + authMock.value.organizationId = orgId; + authMock.value.memberships = [...authMock.value.memberships]; + resolve(undefined); + }; + }), + ); + await renderIntent(); + + // Pick B and confirm. + await click(optionCardByText("Side Team")); + expect(radioCheckedForCard("Side Team")).toBe(true); + await click(buttonByText("Continue")); + expect(authMock.value.selectOrganization).toHaveBeenCalledTimes(1); + expect(authMock.value.selectOrganization).toHaveBeenCalledWith("org-2"); + + // Mid-flight: clicking C must NOT move the visible selection — the card + // radios are disabled and the select handler is frozen. + expect(document.body.textContent).toContain("Confirming team…"); + await click(optionCardByText("Third Team")); + expect(radioCheckedForCard("Third Team")).toBe(false); + expect(radioCheckedForCard("Side Team")).toBe(true); + expect(authMock.value.selectOrganization).toHaveBeenCalledTimes(1); + + // Exact B settles: the flow continues against B — the Team the user + // last saw selected — never against the mid-flight click target. + await act(async () => { + resolveSwitch(); + }); + await rerender(); + expect(dialogOpenCount()).toBeGreaterThan(0); + expect(flagsMocks.writeOnboardingTemplateIntent).not.toHaveBeenCalled(); + }); + + it("shows a recoverable error and never confirms when the switch rejects after an optimistic window", async () => { + authMock.value.memberships = [membership("m-1", "org-1", "Acme Team"), membership("m-2", "org-2", "Side Team")]; + let rejectSwitch!: (reason: unknown) => void; + authMock.value.selectOrganization = vi.fn( + (_orgId: string) => + new Promise((_resolve, reject) => { + rejectSwitch = reject; + }), + ); + await renderIntent(); + + await click(optionCardByText("Side Team")); + await click(buttonByText("Continue")); + + // Optimistic window: auth already displays the unconfirmed target (the + // real selectOrganization writes it before /me answers). Nothing may + // confirm from this alone — the optimistic org is not authority. + authMock.value.organizationId = "org-2"; + await rerender(); + expect(dialogOpenCount()).toBe(0); + expect(flagsMocks.writeOnboardingTemplateIntent).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Confirming team…"); + + // The post-switch /me fails: selectOrganization rejects (its rollback is + // covered by the AuthProvider tests) and the auth value settles back on + // the pre-switch Team. The chooser recovers with an error, unfrozen. + authMock.value.organizationId = "org-1"; + await act(async () => { + rejectSwitch(new Error("network")); + }); + await rerender(); + + expect(document.body.textContent).toContain("couldn't switch to that team"); + expect(document.body.textContent).toContain("Continue"); + expect(dialogOpenCount()).toBe(0); + expect(flagsMocks.writeOnboardingTemplateIntent).not.toHaveBeenCalled(); + + // Retry path: a fresh confirm against the same target works. + authMock.value.selectOrganization = vi.fn(async (orgId: string) => { + authMock.value.organizationId = orgId; + authMock.value.memberships = [...authMock.value.memberships]; + return undefined; + }); + // Re-render so the rendered handlers close over the new mock. + await rerender(); + await click(optionCardByText("Side Team")); + await click(buttonByText("Continue")); + await rerender(); + expect(dialogOpenCount()).toBeGreaterThan(0); + }); +}); diff --git a/packages/web/src/pages/templates/template-detail-page.tsx b/packages/web/src/pages/templates/template-detail-page.tsx new file mode 100644 index 000000000..fe6f23236 --- /dev/null +++ b/packages/web/src/pages/templates/template-detail-page.tsx @@ -0,0 +1,266 @@ +import { + type AgentTemplatePublicTemplate, + agentTemplateIntentPath, + agentTemplateSlugSchema, + parseAgentTemplateIntentPath, +} from "@first-tree/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { Link, Navigate, useLocation, useNavigate, useParams } from "react-router"; +import { trackEvent } from "../../analytics.js"; +import { getAgentTemplate } from "../../api/agent-templates.js"; +import { ApiError } from "../../api/client.js"; +import { useAuth } from "../../auth/auth-context.js"; +import { FirstTreeLogo } from "../../components/first-tree-logo.js"; +import { Button } from "../../components/ui/button.js"; +import { TemplateUseIntent } from "./template-use-intent.js"; + +function DetailSkeleton() { + return

Loading template…

; +} + +function NotFound() { + return ( +
+

We couldn't find that template. It may have been removed.

+ +
+ ); +} + +function LoadError({ onRetry }: { onRetry: () => void }) { + return ( +
+

We couldn't load this template. This is usually temporary.

+ +
+ ); +} + +/** + * Recoverable state for a signed-in Template intent whose initial `/me` + * failed: Team membership is unknown, so no Team decision may render. Retry + * re-fetches `/me`; the in-flight state disables repeat clicks, and a + * successful refresh flips `meAuthoritative`, which re-renders this page + * into the ordinary intent resolution. + */ +function MeAuthorityError() { + const { refreshMe } = useAuth(); + const [retrying, setRetrying] = useState(false); + return ( +
+
+

+ We couldn't confirm your team right now. This is usually temporary — try again. +

+ +
+
+ ); +} + +function RetiredNotice({ template }: { template: AgentTemplatePublicTemplate }) { + return ( +
+

+ This template has been retired and can no longer be used for a new agent. Agents already created from it keep + working from the resources their team imported. +

+ {template.replacement && ( +

+ A newer template is available:{" "} + + {template.replacement.name} + +

+ )} +
+ ); +} + +function ProfileSection({ label, children }: { label: string; children: string }) { + if (!children) return null; + return ( +
+

{label}

+

+ {children} +

+
+ ); +} + +/** + * Public Agent Template detail. Anonymous and signed-in visitors get the same + * public-safe projection. `?use=1` is the canonical "use this Template" intent + * URL: logged-out visitors are sent through `/login` (which preserves this + * exact relative URL as the OAuth `next`), while signed-in members resolve the + * intent into onboarding or an explicit Team choice. + */ +export function TemplateDetailPage() { + const { slug: rawSlug } = useParams(); + const location = useLocation(); + const navigate = useNavigate(); + const { isAuthenticated, meLoaded, meAuthoritative, memberships } = useAuth(); + const slugResult = agentTemplateSlugSchema.safeParse(rawSlug ?? ""); + const slug = slugResult.success ? slugResult.data : null; + // The intent flag is judged by the SAME strict shared parser the server + // uses for OAuth `next` preservation — no looser local reading. A + // non-canonical query (extra params, duplicate `use`, fragment, normalized + // spellings) renders the ordinary public detail instead of entering intent + // resolution or bouncing anyone through login. + const intentSlug = parseAgentTemplateIntentPath(`${location.pathname}${location.search}${location.hash}`); + const intent = intentSlug !== null && slug !== null && intentSlug === slug; + + const detailQuery = useQuery({ + queryKey: ["agent-template", slug], + queryFn: () => getAgentTemplate(slug as string), + enabled: slug !== null, + retry: false, + }); + const template = detailQuery.data ?? null; + + // Sticky intent template. Team confirmation calls selectOrganization, which + // clears the whole React Query cache — the detail query then re-pends and + // `template` goes null for one refetch window. Without this capture the + // intent subtree (including an open NewAgentDialog) would unmount mid-flow + // and lose its state; the sticky copy keeps the resolution mounted with the + // last confirmed ACTIVE template for THIS slug. + // + // The snapshot carries its slug identity and is validated SYNCHRONOUSLY at + // render: React Router reuses this component instance across + // `/templates/:slug` param changes, so a stale snapshot for slug A must + // never render while the route already points at slug B — not even for one + // pending window. + const [stickyIntentTemplate, setStickyIntentTemplate] = useState(null); + useEffect(() => { + if (!intent || template?.status !== "active") return; + setStickyIntentTemplate((prev) => (prev?.slug === template.slug ? prev : template)); + }, [intent, template]); + const activeStickyIntentTemplate = + stickyIntentTemplate && stickyIntentTemplate.slug === slug ? stickyIntentTemplate : null; + + // Detail view dedupe is keyed by slug TRANSITION, not by mount: the same + // component instance can render A → B (and back), and each distinct slug + // that actually renders a detail earns exactly one event. + const viewTrackedForRef = useRef(null); + useEffect(() => { + if (!template) return; + if (viewTrackedForRef.current === template.slug) return; + viewTrackedForRef.current = template.slug; + trackEvent("agent_template_detail_view", { + slug: template.slug, + status: template.status, + authenticated: isAuthenticated, + }); + }, [template, isAuthenticated]); + + function handleUseStarted(activeTemplate: AgentTemplatePublicTemplate): void { + trackEvent("agent_template_use_started", { + slug: activeTemplate.slug, + authenticated: isAuthenticated, + team_count: isAuthenticated ? memberships.length : 0, + }); + navigate(agentTemplateIntentPath(activeTemplate.slug)); + } + + // Intent resolution. The logged-out bounce carries the EXACT intent URL + // (pathname + `use=1`) through router state so LoginPage threads it into the + // OAuth `next` unchanged. + if (intent) { + if (!isAuthenticated) { + return ; + } + if (!meLoaded) { + return ( +
+ Loading… +
+ ); + } + // meLoaded alone is not Team authority: an initial /me transport failure + // also flips it. Without an authoritative membership snapshot, never + // mount the resolution (chooser/dialog/handoff could otherwise run on an + // empty or guessed Team) — show a recoverable error with a guarded retry + // instead. + if (!meAuthoritative) { + return ; + } + if (activeStickyIntentTemplate) { + // Keyed by slug so every piece of intent state (chooser, dialog, + // handoff) belongs to ONE Template and resets on a slug transition. + return ; + } + // A retired/unavailable Template cannot start a new agent — fall through + // to the ordinary detail rendering, which explains the state. + } + + return ( +
+
+ + + Agent templates + +
+ +
+ {slug === null || + (detailQuery.isError && detailQuery.error instanceof ApiError && detailQuery.error.status === 404) ? ( + + ) : detailQuery.isPending ? ( + + ) : detailQuery.isError || !template ? ( + void detailQuery.refetch()} /> + ) : ( +
+
+

{template.name}

+

+ {template.public.tagline} +

+
+ + {template.status === "retired" && } + + {template.public.purpose} + {template.public.targetUsers} + {template.public.userValue} + {template.public.instructionsSummary} + {template.public.toolsAndSkillsSummary} + + {template.status === "active" && ( +
+ +

+ Creates a new agent in your team with this responsibility. You can adjust or remove it before anything + is created. +

+
+ )} +
+ )} +
+
+ ); +} diff --git a/packages/web/src/pages/templates/template-library-page.tsx b/packages/web/src/pages/templates/template-library-page.tsx new file mode 100644 index 000000000..92a5b9721 --- /dev/null +++ b/packages/web/src/pages/templates/template-library-page.tsx @@ -0,0 +1,98 @@ +import type { AgentTemplatePublicTemplate } from "@first-tree/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { Link } from "react-router"; +import { trackEvent } from "../../analytics.js"; +import { listAgentTemplates } from "../../api/agent-templates.js"; +import { useAuth } from "../../auth/auth-context.js"; +import { FirstTreeLogo } from "../../components/first-tree-logo.js"; +import { Button } from "../../components/ui/button.js"; + +function TemplateCard({ template }: { template: AgentTemplatePublicTemplate }) { + return ( + +
{template.name}
+

+ {template.public.tagline} +

+

+ For {template.public.targetUsers} +

+ + ); +} + +/** + * Public Agent Template Library. Anonymous visitors and signed-in members see + * the same public-safe catalog — only active Templates, and only the explicit + * safe projection (identity, tagline, purpose, audience, value, capability + * summaries). No component payload ever reaches this page. + */ +export function TemplateLibraryPage() { + const { isAuthenticated } = useAuth(); + const catalogQuery = useQuery({ + queryKey: ["agent-template-catalog"], + queryFn: listAgentTemplates, + retry: false, + }); + + // One deduped view per page mount, reported only once the catalog has + // actually rendered something (a failed load is not a library view). + const viewTrackedRef = useRef(false); + useEffect(() => { + if (viewTrackedRef.current || !catalogQuery.data) return; + viewTrackedRef.current = true; + trackEvent("agent_template_library_view", { + template_count: catalogQuery.data.templates.length, + authenticated: isAuthenticated, + }); + }, [catalogQuery.data, isAuthenticated]); + + return ( +
+
+ + + First Tree + +
+ +
+

Agent templates

+

+ Official starting responsibilities for a First Tree agent. Pick one, and its instructions, skills, and tools + are imported into your team when the agent is created. +

+ +
+ {catalogQuery.isPending ? ( +

Loading templates…

+ ) : catalogQuery.isError ? ( +
+

+ We couldn't load the template library. This is usually temporary. +

+ +
+ ) : catalogQuery.data.templates.length === 0 ? ( +

No templates are available yet. Check back soon.

+ ) : ( +
+ {catalogQuery.data.templates.map((template) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/packages/web/src/pages/templates/template-use-intent.tsx b/packages/web/src/pages/templates/template-use-intent.tsx new file mode 100644 index 000000000..585bc5802 --- /dev/null +++ b/packages/web/src/pages/templates/template-use-intent.tsx @@ -0,0 +1,243 @@ +import type { AgentTemplatePublicTemplate, MeMembership } from "@first-tree/shared"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { Link, Navigate, useNavigate } from "react-router"; +import { trackEvent } from "../../analytics.js"; +import { useAuth } from "../../auth/auth-context.js"; +import { NewAgentDialog } from "../../components/new-agent-dialog.js"; +import { Button } from "../../components/ui/button.js"; +import { OptionCard } from "../../components/ui/option-card.js"; +import { writeOnboardingTemplateIntent } from "../../utils/onboarding-flags.js"; +import { shouldEnterOnboarding } from "../onboarding/steps.js"; + +/** + * Signed-in resolution of the canonical Template intent (`/templates/:slug?use=1`). + * + * Two destinations, decided only after `/me` has settled (the caller gates on + * `meLoaded`, so no org-scoped request fires before the org is resolved): + * + * - Fresh / incomplete onboarding — judged by the SAME gate the workspace + * root uses (`shouldEnterOnboarding`), never a parallel re-derivation that + * could drift. The slug is stashed as a per-org sessionStorage handoff and + * the user continues through the ordinary onboarding flow, whose + * create-agent step picks the intent up. + * - Everyone else — an explicit Team chooser. Even a single-Team member + * confirms the destination Team; a multi-Team member is never silently + * written into the wrong one. + * + * Team confirmation is a state machine with an explicit in-flight phase, + * because `selectOrganization` clears the whole React Query cache, re-fetches + * `/me`, and writes the target selected-org BEFORE `/me` confirms it: + * + * 1. `handleConfirm` SYNCHRONOUSLY enters the `switching` phase (exact + * target + pre-switch memberships identity) — the UI disables instantly, + * double-clicks can't start concurrent switches, and the generic + * onboarding gate is suppressed while ANY confirmation is in flight. + * 2. Only after the switch promise resolved AND the post-switch auth + * snapshot landed does the confirmation effect judge: EXACT org match → + * re-check the onboarding gate against THAT Team and explicitly choose + * handoff + onboarding or the shared NewAgentDialog; anything else + * (reject, fallback) → recoverable error, no handoff for any Team the + * user never confirmed, nothing is created. + */ +export function TemplateUseIntent({ template }: { template: AgentTemplatePublicTemplate }) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { + meLoaded, + onboardingStep, + currentOrgHasPersonalAgent, + onboardingDismissedAt, + onboardingCompletedAt, + organizationId, + memberships, + selectOrganization, + } = useAuth(); + + const needsOnboarding = shouldEnterOnboarding({ + meLoaded, + onboardingStep, + currentOrgHasPersonalAgent, + onboardingSuppressedAt: onboardingDismissedAt, + onboardingCompletedAt, + }); + + // Team switch in-flight phase. Established SYNCHRONOUSLY on click — a real + // `selectOrganization` writes the target selected-org before `/me` confirms + // it, so without this phase the generic onboarding gate could hand off or + // navigate for a Team whose membership was never confirmed (or for a + // fallback the user never picked). + type TeamSwitch = { targetOrgId: string; preMemberships: MeMembership[]; promiseResolved: boolean }; + const [teamSwitch, setTeamSwitch] = useState(null); + // Explicit, post-confirmation onboarding destination. Set ONLY by the + // confirmation effect after the exact target is proven — never by the + // generic gate during a switch. + const [handoffTarget, setHandoffTarget] = useState(null); + // The org this page mounted with — the ONLY Team the generic (no explicit + // choice) onboarding handoff may ever fire for. After any switch attempt, a + // fallback Team that happens to need onboarding must NOT receive a handoff. + const mountOrgRef = useRef(organizationId); + + // Generic onboarding handoff (no explicit Team choice): the member landed + // here while their CURRENT Team still needs onboarding. Runs after EVERY + // commit with a ref guard instead of a deps array so a passive-effect + // scheduling edge can never skip it while the gate is already showing the + // onboarding destination; the write is keyed by the org it was performed + // for, so repeats are idempotent (StrictMode double-effects, refreshes). + const handoffWrittenForRef = useRef(null); + const [handoffWritten, setHandoffWritten] = useState(false); + useEffect(() => { + if (teamSwitch) return; // confirmation in flight: no generic handoff + if (!needsOnboarding || !organizationId || organizationId !== mountOrgRef.current) { + handoffWrittenForRef.current = null; + return; + } + if (handoffWrittenForRef.current === organizationId) return; + handoffWrittenForRef.current = organizationId; + writeOnboardingTemplateIntent(organizationId, template.slug); + setHandoffWritten(true); + }); + + const [selectedOrgId, setSelectedOrgId] = useState(organizationId); + const [switchError, setSwitchError] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + + // Confirmation judgement — only after the switch promise resolved AND a + // FRESH /me membership snapshot landed. `selectOrganization` resolves only + // after a successful post-switch /me and rejects (with rollback) on + // transport failure, so a new memberships array is the proof of authority; + // the optimistic `organizationId` write alone NEVER unlocks confirmation. + useEffect(() => { + if (!teamSwitch?.promiseResolved) return; + const landed = memberships !== teamSwitch.preMemberships; + if (!landed) return; + const target = teamSwitch.targetOrgId; + setTeamSwitch(null); + if (organizationId !== target) { + // The target membership was lost and Auth reconciled to a fallback + // Team — never create against a Team the user did not confirm, and + // never hand off to that fallback either. + setSwitchError("We couldn't confirm that team — nothing was created. Pick a team and try again."); + return; + } + // Exact Team confirmed. If THIS Team still needs onboarding, hand off + // explicitly for it — do not open the creation dialog. + if (needsOnboarding) { + writeOnboardingTemplateIntent(target, template.slug); + setHandoffTarget(target); + return; + } + setDialogOpen(true); + }, [teamSwitch, organizationId, memberships, needsOnboarding, template.slug]); + + // Explicit confirmed onboarding destination (post-confirmation only). + if (handoffTarget) { + return ; + } + + // Generic onboarding destination — initial landing on a Team that still + // needs onboarding. Never while a confirmation is in flight, and never for + // a Team other than the one this page mounted with. + if (!teamSwitch && needsOnboarding && organizationId && organizationId === mountOrgRef.current) { + if (!handoffWritten) { + return ( +
+ Loading… +
+ ); + } + return ; + } + + async function handleConfirm(): Promise { + if (!selectedOrgId || teamSwitch) return; + setSwitchError(null); + // Enter the switching phase IMMEDIATELY — before any async work — so the + // UI disables, concurrent clicks are ignored, and the generic onboarding + // gate stays suppressed for the whole flight. + const request: TeamSwitch = { targetOrgId: selectedOrgId, preMemberships: memberships, promiseResolved: false }; + setTeamSwitch(request); + try { + await selectOrganization(selectedOrgId); + } catch { + // Never open the creation dialog against an unconfirmed Team. + setTeamSwitch(null); + setSwitchError("We couldn't switch to that team. Try again."); + return; + } + setTeamSwitch((prev) => + prev && prev.targetOrgId === request.targetOrgId ? { ...prev, promiseResolved: true } : prev, + ); + } + + return ( +
+
+ + ← {template.name} + +
+ +
+

Start with {template.name}

+

+ Choose the team your new agent will join. Its instructions, skills, and tools are imported into that team when + the agent is created. +

+ +
+ {memberships.map((membership) => ( + { + if (teamSwitch) return; + setSelectedOrgId(membership.organizationId); + }} + > +
+
{membership.organizationName}
+ {membership.organizationId === organizationId && ( +
Current team
+ )} +
+
+ ))} +
+ + {switchError && ( +

+ {switchError} +

+ )} + +
+ +
+
+ + { + setDialogOpen(false); + queryClient.invalidateQueries({ queryKey: ["agents"] }); + queryClient.invalidateQueries({ queryKey: ["activity"] }); + trackEvent("agent_create_draft_open", { template_count: templateCount }); + navigate(`/?c=draft&with=${encodeURIComponent(agent.uuid)}`); + }} + /> +
+ ); +} diff --git a/packages/web/src/pages/workspace/center/__tests__/chat-by-id-center-dom.test.tsx b/packages/web/src/pages/workspace/center/__tests__/chat-by-id-center-dom.test.tsx index f55130602..338076f2b 100644 --- a/packages/web/src/pages/workspace/center/__tests__/chat-by-id-center-dom.test.tsx +++ b/packages/web/src/pages/workspace/center/__tests__/chat-by-id-center-dom.test.tsx @@ -585,4 +585,62 @@ describe("ChatByIdView and CenterPanel", () => { await act(async () => root.unmount()); }); + + it("does not loop the auto-switch after a rejected switch, and still tries a new target", async () => { + authMock.value.organizationId = "org-1"; + authMock.value.memberships = [ + { organizationId: "org-1" }, + { organizationId: "org-2" }, + { organizationId: "org-3" }, + ]; + authMock.value.selectOrganization = vi.fn(async () => { + throw new Error("offline"); + }); + // Refetches keep resolving so repeated effect passes stay possible. + chatMocks.getChat.mockImplementation(async (chatId: string) => + chatDetail({ organizationId: chatId === "chat-other" ? "org-3" : "org-2" }), + ); + const { ChatByIdView } = await import("../chat-by-id.js"); + const queryClient = createClient(); + const { container, root } = await renderDom( + , + queryClient, + ); + + await waitForText(container, "ChatView agent-1 chat-reject"); + expect(authMock.value.selectOrganization).toHaveBeenCalledTimes(1); + expect(authMock.value.selectOrganization).toHaveBeenCalledWith("org-2"); + + // Rejection fallout: the rollback leaves the org on org-1 and the cache + // clear refetches this chat's detail — the same target must NOT re-fire, + // no matter how many renders/effect passes follow. + await act(async () => { + queryClient.clear(); + }); + await flush(); + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + expect(authMock.value.selectOrganization).toHaveBeenCalledTimes(1); + + // A genuinely different chat target is still attempted. + await act(async () => { + root.render( + + + , + ); + }); + await waitForText(container, "ChatView agent-1 chat-other"); + expect(authMock.value.selectOrganization).toHaveBeenCalledTimes(2); + expect(authMock.value.selectOrganization).toHaveBeenLastCalledWith("org-3"); + + await act(async () => root.unmount()); + }); }); diff --git a/packages/web/src/pages/workspace/center/chat-by-id.tsx b/packages/web/src/pages/workspace/center/chat-by-id.tsx index cd5a53420..67bbdb0f4 100644 --- a/packages/web/src/pages/workspace/center/chat-by-id.tsx +++ b/packages/web/src/pages/workspace/center/chat-by-id.tsx @@ -160,7 +160,13 @@ export function ChatByIdView({ if (switchedOrgRef.current === chatOrg) return; if (!memberships.some((m) => m.organizationId === chatOrg)) return; switchedOrgRef.current = chatOrg; - void selectOrganization(chatOrg); + // A rejected switch (post-switch /me failed) rolls back to the confirmed + // org and clears the query caches, which refetches this chat's detail — + // so the rejection MUST stay swallowed with the target still marked as + // attempted. Resetting the ref here would re-fire selectOrganization on + // the very next effect pass and loop /me + chat-detail requests during a + // persistent outage. A retry needs an explicit new target or a remount. + void Promise.resolve(selectOrganization(chatOrg)).catch(() => undefined); }, [chatDetail?.organizationId, currentOrgId, memberships, selectOrganization, switchingOrg]); const primaryAgent = useMemo(() => { diff --git a/packages/web/src/utils/__tests__/onboarding-flags-template-intent.test.ts b/packages/web/src/utils/__tests__/onboarding-flags-template-intent.test.ts new file mode 100644 index 000000000..f89a8f9b8 --- /dev/null +++ b/packages/web/src/utils/__tests__/onboarding-flags-template-intent.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from "vitest"; +import { + clearOnboardingSessionFlags, + readOnboardingTemplateIntent, + writeOnboardingTemplateIntent, +} from "../onboarding-flags.js"; + +describe("onboarding template intent handoff", () => { + afterEach(() => { + window.sessionStorage.clear(); + }); + + it("round-trips a valid slug per org", () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + expect(readOnboardingTemplateIntent("org-1")).toBe("pr-engineer"); + }); + + it("is scoped per organization", () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + expect(readOnboardingTemplateIntent("org-2")).toBeNull(); + }); + + it("clears with a null write", () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + writeOnboardingTemplateIntent("org-1", null); + expect(readOnboardingTemplateIntent("org-1")).toBeNull(); + }); + + it("survives a refresh-style re-read (sessionStorage persists in-tab)", () => { + writeOnboardingTemplateIntent("org-1", "docs-writer"); + // A second reader in the same tab sees the same value — refresh / Finish + // later retention is just sessionStorage lifetime. + expect(readOnboardingTemplateIntent("org-1")).toBe("docs-writer"); + expect(readOnboardingTemplateIntent("org-1")).toBe("docs-writer"); + }); + + it("rejects and removes an invalid stored value", () => { + window.sessionStorage.setItem("onboarding:templateIntent:org-1", "Not A Slug!"); + expect(readOnboardingTemplateIntent("org-1")).toBeNull(); + expect(window.sessionStorage.getItem("onboarding:templateIntent:org-1")).toBeNull(); + }); + + it("refuses to store an invalid slug at the write boundary", () => { + writeOnboardingTemplateIntent("org-1", "Not A Slug!"); + expect(window.sessionStorage.getItem("onboarding:templateIntent:org-1")).toBeNull(); + expect(readOnboardingTemplateIntent("org-1")).toBeNull(); + // A valid write still works after a refused one. + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + expect(readOnboardingTemplateIntent("org-1")).toBe("pr-engineer"); + }); + + it("clears a previously stored valid slug on an invalid write (fail closed)", () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + expect(readOnboardingTemplateIntent("org-1")).toBe("pr-engineer"); + writeOnboardingTemplateIntent("org-1", "Not A Slug!"); + expect(readOnboardingTemplateIntent("org-1")).toBeNull(); + expect(window.sessionStorage.getItem("onboarding:templateIntent:org-1")).toBeNull(); + }); + + it("is covered by the logout session-flag wipe", () => { + writeOnboardingTemplateIntent("org-1", "pr-engineer"); + window.sessionStorage.setItem("unrelated:key", "keep"); + clearOnboardingSessionFlags(); + expect(readOnboardingTemplateIntent("org-1")).toBeNull(); + expect(window.sessionStorage.getItem("unrelated:key")).toBe("keep"); + }); +}); diff --git a/packages/web/src/utils/onboarding-flags.ts b/packages/web/src/utils/onboarding-flags.ts index b4e28d250..c09b29041 100644 --- a/packages/web/src/utils/onboarding-flags.ts +++ b/packages/web/src/utils/onboarding-flags.ts @@ -1,4 +1,4 @@ -import { isKnownLandingCampaignSlug, type KnownLandingCampaignSlug } from "@first-tree/shared"; +import { agentTemplateSlugSchema, isKnownLandingCampaignSlug, type KnownLandingCampaignSlug } from "@first-tree/shared"; /** * Onboarding-related browser-side flags (sessionStorage). @@ -18,6 +18,51 @@ const ONBOARDING_AGENT_UUID_KEY = "onboarding:agentUuid"; const SELECTED_REPOS_KEY = (orgId: string) => `onboarding:selectedRepos:${orgId}`; +const TEMPLATE_INTENT_KEY = (orgId: string) => `onboarding:templateIntent:${orgId}`; + +/** + * Per-org handoff carrying one official Template slug from the public + * `/templates/:slug?use=1` intent into the onboarding create-agent step. + * + * Written by the signed-in intent resolution when the member still needs + * onboarding; consumed there so the step can show the public-safe Template + * responsibility (default-selected, removable) and pass its id into the + * ordinary create-agent POST. Per-tab (sessionStorage) and per-org, matching + * the selected-repos draft: a refresh or "finish later" keeps it, logout + * clears it via the shared `onboarding:` prefix wipe, and it is cleared once + * an agent has actually been created from the flow. An unreadable or invalid + * value self-heals to `null` and is removed, so a corrupt/stale handoff can + * never block plain onboarding. + */ +export function readOnboardingTemplateIntent(orgId: string): string | null { + if (typeof window === "undefined" || !orgId) return null; + const raw = window.sessionStorage.getItem(TEMPLATE_INTENT_KEY(orgId)); + if (raw === null) return null; + // Stored as a bare slug string; validate strictly against the shared slug + // contract so a tampered/legacy value degrades to "no intent". + if (!agentTemplateSlugSchema.safeParse(raw).success) { + window.sessionStorage.removeItem(TEMPLATE_INTENT_KEY(orgId)); + return null; + } + return raw; +} + +export function writeOnboardingTemplateIntent(orgId: string, slug: string | null): void { + if (typeof window === "undefined" || !orgId) return; + if (slug === null) { + window.sessionStorage.removeItem(TEMPLATE_INTENT_KEY(orgId)); + return; + } + // Refuse invalid slugs at the write boundary — and fail CLOSED: remove any + // previously stored value so a stale legal slug can never survive an + // invalid write and be read back as a real intent later. + if (!agentTemplateSlugSchema.safeParse(slug).success) { + window.sessionStorage.removeItem(TEMPLATE_INTENT_KEY(orgId)); + return; + } + window.sessionStorage.setItem(TEMPLATE_INTENT_KEY(orgId), slug); +} + /** * Per-org draft of the repos the admin picked on the connect-code step. *