From 5c2fb210439f50b67f4a315f3fb341843d93c749 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 14 Sep 2026 09:09:45 -0700 Subject: [PATCH 1/7] Refine Flows demo and add software factory onboarding [skip ci] --- web/app/flows/flow-examples.ts | 32 ++ web/app/flows/flows.module.css | 10 +- web/app/flows/onboarding/FactoryBuilder.tsx | 318 ++++++++++++++++++ web/app/flows/onboarding/SourcePicker.tsx | 63 ++++ web/app/flows/onboarding/[stage]/page.tsx | 10 + web/app/flows/onboarding/layout.tsx | 6 + .../flows/onboarding/onboarding.module.css | 221 ++++++++++++ web/app/flows/onboarding/page.tsx | 11 + web/app/flows/page.tsx | 15 +- web/app/landing.module.css | 22 ++ web/components/home/DeliveryFeature.tsx | 8 +- web/components/home/FlowGatePreview.tsx | 270 ++++++++++----- web/components/home/MessagingFeature.tsx | 5 +- .../home/flow-gate-preview.module.css | 229 +++++++++---- web/lib/feature-request-demo.ts | 170 ++++++++++ web/lib/flow-onboarding.ts | 172 ++++++++++ web/lib/flow-sources.ts | 101 ++++++ web/lib/test/feature-request-demo.test.ts | 170 ++++++++++ web/lib/test/flow-onboarding.test.ts | 214 ++++++++++++ web/lib/test/flow-sources.test.ts | 60 ++++ 20 files changed, 1946 insertions(+), 161 deletions(-) create mode 100644 web/app/flows/onboarding/FactoryBuilder.tsx create mode 100644 web/app/flows/onboarding/SourcePicker.tsx create mode 100644 web/app/flows/onboarding/[stage]/page.tsx create mode 100644 web/app/flows/onboarding/layout.tsx create mode 100644 web/app/flows/onboarding/onboarding.module.css create mode 100644 web/app/flows/onboarding/page.tsx create mode 100644 web/lib/feature-request-demo.ts create mode 100644 web/lib/flow-onboarding.ts create mode 100644 web/lib/flow-sources.ts create mode 100644 web/lib/test/feature-request-demo.test.ts create mode 100644 web/lib/test/flow-onboarding.test.ts create mode 100644 web/lib/test/flow-sources.test.ts diff --git a/web/app/flows/flow-examples.ts b/web/app/flows/flow-examples.ts index ef4291ad..0009d32f 100644 --- a/web/app/flows/flow-examples.ts +++ b/web/app/flows/flow-examples.ts @@ -62,5 +62,37 @@ export const flowExamples = [ "description": "Select the fields needed for the task before putting a record into the agent prompt.", "filename": "redacted-summary.flow.ts", "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = {\n record: { category: string; status: string; email: string };\n approver: string;\n};\n\nexport default flow(\n \"redacted-summary\",\n { budget: \"$5/run\" },\n async (f, input) => {\n // Select fields in code before building the prompt.\n // The email address is excluded from this input.\n const selected = {\n category: input.record.category,\n status: input.record.status,\n };\n\n await f.agent(\"summarizer\", {\n task: `Summarize this record: ` +\n `${JSON.stringify(selected)}. Write summary.md.`,\n }).gate((r) => r.artifacts.includes(\"summary.md\"));\n\n const summary = await f.run(\"cat summary.md\");\n const approved = await f.human(\n `Review this summary before sharing:\\n\\n${summary}`,\n { to: input.approver },\n );\n if (!approved) return f.done(\"canceled\");\n\n f.done(\"success\");\n },\n);" + }, + { + "id": "ci-repair", + "label": "Fix failing CI", + "title": "Give agents the failure. Get back a tested fix.", + "description": "Reproduce a failing check, patch the code, and rerun the tests. Stop after three repair attempts if the checks still fail.", + "filename": "ci-repair.flow.ts", + "code": "import { flow } from \"@relayflows/surface\";\n\n// Run in a clean checkout on a repair branch.\nexport default flow(\"ci-repair\", async (f) => {\n for (let attempt = 0; attempt <= 3; attempt++) {\n // Capture failures as data so the loop can repair them.\n const status = await f.run(\n \"if npm test > ci.log 2>&1; then \" +\n \"echo PASS; else echo FAIL; fi\"\n );\n if (status.trim() === \"PASS\") {\n return f.done(\"success\");\n }\n if (attempt === 3) return f.done(\"step_failed\");\n\n await f.agent(`repair-${attempt + 1}`, {\n cli: \"codex\",\n task: \"Read ci.log and reproduce the failure. \" +\n \"Fix the implementation without weakening tests \" +\n \"or changing the test command. Keep the patch small.\",\n });\n // The next iteration runs the same check again.\n }\n});" + }, + { + "id": "issue-routing", + "label": "Issue routing", + "title": "Send each issue to the right specialist.", + "description": "Route bugs to an implementer and documentation requests to a writer. Ask a human when the category is uncertain.", + "filename": "issue-routing.flow.ts", + "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = { issue: string; approver: string };\n\nexport default flow(\"issue-routing\", async (f, input) => {\n const classification = await f.agent(\"router\", {\n cli: \"claude\",\n task: `Classify this issue: ${input.issue}. ` +\n \"Return only bugfix, docs, or uncertain as your summary. \" +\n \"Choose uncertain if the request is ambiguous.\",\n });\n let route = classification.summary.trim().toLowerCase();\n\n // Unknown output never silently selects a specialist.\n if (route !== \"bugfix\" && route !== \"docs\") {\n const approved = await f.human(\n `Needs manual triage: ${input.issue}\\n` +\n \"Route this to the bugfix specialist?\",\n { to: input.approver },\n );\n if (!approved) return f.done(\"canceled\");\n route = \"bugfix\";\n }\n\n if (route === \"bugfix\") {\n await f.agent(\"implementer\", {\n cli: \"codex\",\n task: `Fix this issue and add regression coverage: ${input.issue}`,\n });\n await f.run(\"npm test\");\n } else {\n await f.agent(\"docs-writer\", {\n cli: \"claude\",\n task: `Update the documentation for: ${input.issue}. ` +\n \"Check examples against the implementation.\",\n });\n }\n f.done(\"success\");\n});" + }, + { + "id": "competing-implementations", + "label": "Competing implementations", + "title": "Try three approaches. Keep the one that passes.", + "description": "Solve the same problem in separate worktrees, test every attempt, and select the fastest passing implementation.", + "filename": "competing-implementations.flow.ts", + "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = { task: string };\n\n// Each checkout needs its dependencies installed.\n// npm run benchmark --silent must emit { \"durationMs\": number }.\nexport default flow(\"competing-implementations\", async (f, input) => {\n const contenders = [\"codex\", \"claude\", \"gemini\"];\n await f.run(\"mkdir -p attempts\");\n for (const cli of contenders) {\n await f.run(`git worktree add --detach attempts/${cli} HEAD`);\n }\n\n await Promise.all(contenders.map((cli) =>\n f.agent(`attempt-${cli}`, {\n cli,\n workspace: `attempts/${cli}/: readwrite`,\n task: `Work only in attempts/${cli}. ${input.task}. ` +\n \"Install dependencies and implement independently. \" +\n \"Keep the existing tests and benchmark unchanged.\",\n })\n ));\n\n // Run benchmarks sequentially to avoid resource contention.\n const passing: { cli: string; durationMs: number }[] = [];\n for (const cli of contenders) {\n const output = await f.run(\n `cd attempts/${cli} && ` +\n \"if npm test > test.log 2>&1 && \" +\n \"npm run benchmark --silent > benchmark.json; \" +\n \"then cat benchmark.json; else echo null; fi\"\n );\n try {\n const result = JSON.parse(output);\n if (typeof result?.durationMs === \"number\" &&\n Number.isFinite(result.durationMs) && result.durationMs >= 0) {\n passing.push({ cli, durationMs: result.durationMs });\n }\n } catch { /* Invalid benchmark output cannot win. */ }\n }\n if (!passing.length) return f.done(\"step_failed\");\n\n passing.sort((a, b) => a.durationMs - b.durationMs);\n const winner = passing[0];\n // Preserve all attempts; record the selected checkout.\n await f.run(`echo attempts/${winner.cli} > winner.txt`);\n f.done(\"success\");\n});" + }, + { + "id": "root-cause-investigation", + "label": "Root-cause investigation", + "title": "Turn competing theories into an evidence-backed diagnosis.", + "description": "Investigate logs, recent changes, and dependencies in parallel. Challenge each theory and keep unresolved questions visible in the report.", + "filename": "root-cause-investigation.flow.ts", + "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = { incident: string };\n\n// Supply sanitized logs and a local reproduction environment.\nexport default flow(\"root-cause-investigation\", async (f, input) => {\n const angles = [\"logs\", \"recent-changes\", \"dependencies\"];\n await f.run(\"mkdir -p investigation\");\n\n const hypotheses = await Promise.all(angles.map((angle) =>\n f.agent(`investigate-${angle}`, {\n task: `Investigate this incident through ${angle}: ` +\n `${input.incident}. Use the local evidence. ` +\n \"Do not change application code. Return a hypothesis, \" +\n \"supporting evidence, and a way to disprove it.\",\n })\n ));\n\n await Promise.all(hypotheses.map((hypothesis, i) =>\n f.agent(`verify-${angles[i]}`, {\n cli: \"codex\",\n task: `Try to disprove this theory: ${hypothesis.summary}. ` +\n \"Run focused checks in the local reproduction environment. \" +\n \"Do not change application code. Record commands, actual \" +\n \"results, and whether the theory is supported, rejected, \" +\n `or unresolved in investigation/${angles[i]}.md.`,\n }).gate((r) =>\n r.artifacts.includes(`investigation/${angles[i]}.md`)\n )\n ));\n\n await f.agent(\"incident-editor\", {\n cli: \"claude\",\n task: \"Read investigation/*.md. Write diagnosis.md with \" +\n \"the strongest supported explanation and its evidence. \" +\n \"Include rejected theories and unresolved questions. \" +\n \"If no cause is supported, say so and propose the next check.\",\n }).gate((r) => r.artifacts.includes(\"diagnosis.md\"));\n // Success means the investigation report is ready.\n f.done(\"success\");\n});" } ]; diff --git a/web/app/flows/flows.module.css b/web/app/flows/flows.module.css index b3fb109d..9dd1a7c5 100644 --- a/web/app/flows/flows.module.css +++ b/web/app/flows/flows.module.css @@ -18,7 +18,7 @@ min-height: 100vh; /* Clip decoration without creating a scroll container for the sticky nav. */ overflow-x: clip; - background: linear-gradient(180deg, #10273a 0%, #0d2638 38%, #0b1c2a 100%); + background: linear-gradient(180deg, #0b1e2d 0%, #0d2638 38%, #0b1c2a 100%); color: var(--flows-text); } @@ -59,12 +59,16 @@ .hero::before { z-index: -2; - background: var(--flows-band-blue); + /* Carry the hero color behind the floating navigation to the page top. */ + top: -100px; + background: + radial-gradient(circle at 18% 24%, rgba(116, 184, 226, 0.11), transparent 32%), + linear-gradient(180deg, #0b1e2d 0%, #112a3c 52%, #0b1e2d 100%); } .hero::after { z-index: -1; - opacity: 0.22; + opacity: 0.16; background: linear-gradient(90deg, transparent 0%, rgba(116, 184, 226, 0.2) 48%, transparent 74%), linear-gradient(180deg, rgba(116, 184, 226, 0.09) 1px, transparent 1px); diff --git a/web/app/flows/onboarding/FactoryBuilder.tsx b/web/app/flows/onboarding/FactoryBuilder.tsx new file mode 100644 index 00000000..afc4c88d --- /dev/null +++ b/web/app/flows/onboarding/FactoryBuilder.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { useEffect, useRef, useState, type CSSProperties } from 'react'; +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { usePostHog } from '@posthog/next'; +import { ArrowLeft, ArrowRight, Check, CheckCheck, Code2, Copy, Download, Info, + GitPullRequest, LockKeyhole, ShieldCheck, ChevronDown, Terminal, Pi as PiIcon, Ellipsis } from 'lucide-react'; +import Claude from '@lobehub/icons/es/Claude'; +import Codex from '@lobehub/icons/es/Codex'; +import Google from '@lobehub/icons/es/Google'; +import Gemini from '@lobehub/icons/es/Gemini'; +import OpenCode from '@lobehub/icons/es/OpenCode'; +import Cursor from '@lobehub/icons/es/Cursor'; +import GithubCopilot from '@lobehub/icons/es/GithubCopilot'; +import Windsurf from '@lobehub/icons/es/Windsurf'; +import Goose from '@lobehub/icons/es/Goose'; +import Grok from '@lobehub/icons/es/Grok'; +import { LogoIcon, LogoWordmark } from '../../../components/SiteNav'; +import { agentLabel, canContinue, cloudConnectionsHref, CODING_AGENTS, DEFAULT_FACTORY, + FACTORY_DRAFT_KEY, LEGACY_FACTORY_DRAFT_KEY, factoryCodeSections, factorySource, primaryAgent, readFactoryDraft, + ONBOARDING_STAGES, onboardingPath, accessibleOnboardingStep, otherAgentIsSelected, + type AgentId, type FactoryDraft } from '../../../lib/flow-onboarding'; +import { SourcePicker, SourceIcon } from './SourcePicker'; +import { sourceLabel, sourceSummary } from '../../../lib/flow-sources'; +import s from './onboarding.module.css'; + +const questions = [ + { title: 'Where should your issues and tickets come from?', description: 'Choose your sources, or start with a Markdown file. Connect any accounts later.' }, + { title: 'Which agents do you use?', description: 'Choose the tools you use. No accounts to connect yet.' }, + { title: 'How should your agent handle each ticket?', description: 'Your agent reads the incoming ticket. Add instructions for how it should approach the work.' }, + { title: 'Who should challenge the code?', description: 'A separate reviewer looks for bugs before the PR reaches you.' }, + { title: 'How persistent should it be?', description: 'Let your coding agent fix review comments and try again.' }, + { title: 'Keep the final say.', description: 'Your agents do the work. Nothing ships until you approve it.' }, +]; +const suggestions = ['Implement the ticket and add regression tests', 'Start with a plan, then implement the ticket', 'Keep changes small and update the tests']; + +function AgentIcon({ id, size = 22 }: { id: AgentId; size?: number }) { + const icons = { claude: Claude.Color, codex: Codex.Color, gemini: Gemini.Color, opencode: OpenCode, + cursor: Cursor, copilot: GithubCopilot, windsurf: Windsurf, aider: Terminal, goose: Goose, grok: Grok, pi: PiIcon }; + const Icon = icons[id]; + return ; +} + +function SyntaxLine({ text }: { text: string }) { + if (text.trimStart().startsWith('//')) return {text || ' '}; + return <>{text.split(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\b(?:import|from|type|export|default|async|await|const|let|for|if|return|break|false|true)\b)/g).map((part, i) => + {part} + )}; +} + +export function FactoryBuilder() { + const [answers, setDraft] = useState(DEFAULT_FACTORY); + const [hydrated, setHydrated] = useState(false); + const pathname = usePathname(); + const router = useRouter(); + const routeStep = ONBOARDING_STAGES.findIndex((_, index) => pathname === onboardingPath(index)); + const started = routeStep >= 0; + const draft = { ...answers, step: started ? routeStep : answers.step }; + const accessibleStep = accessibleOnboardingStep(answers, routeStep); + const loadingStage = started && (!hydrated || accessibleStep !== routeStep); + const [notice, setNotice] = useState(''); + const [copied, setCopied] = useState(false); + const [showInfo, setShowInfo] = useState(false); + const editor = useRef(null); + const questionHeading = useRef(null); + const copyTimeout = useRef | null>(null); + const previousStep = useRef(null); + const posthog = usePostHog(); + const ready = draft.step === 6; + const emptyPreview = !started || loadingStage || !draft.sources.length; + const question = questions[Math.min(draft.step, 5)]; + const source = factorySource(draft); + const sections = factoryCodeSections(emptyPreview ? DEFAULT_FACTORY : draft); + const otherSelected = otherAgentIsSelected(draft); + const requestedAgentCount = draft.agents.filter(id => id !== 'claude' && id !== 'codex').length + (otherSelected ? 1 : 0); + const comingSoonOnly = requestedAgentCount > 0 && !draft.agents.some(id => id === 'claude' || id === 'codex'); + const builder = primaryAgent(draft); + const activeSection = draft.step === 0 && draft.sources.length ? 'sources' : sections.filter(section => section.id !== 'end').at(-1)?.id; + let lineNumber = 0; + + useEffect(() => { + try { setDraft(readFactoryDraft(localStorage.getItem(FACTORY_DRAFT_KEY)) ?? readFactoryDraft(localStorage.getItem(LEGACY_FACTORY_DRAFT_KEY)) ?? DEFAULT_FACTORY); } + catch { /* Browser storage is optional. */ } + setHydrated(true); + return () => { if (copyTimeout.current) clearTimeout(copyTimeout.current); }; + }, []); + + useEffect(() => { + if (!hydrated) return; + try { localStorage.setItem(FACTORY_DRAFT_KEY, JSON.stringify({ ...answers, step: started ? accessibleStep : answers.step })); } + catch { setNotice('Browser storage unavailable. Download to keep your draft.'); } + }, [answers, hydrated, started, accessibleStep]); + + useEffect(() => { + if (hydrated && started && accessibleStep !== routeStep) { + router.replace(onboardingPath(accessibleStep) + window.location.search); + } + }, [hydrated, started, accessibleStep, routeStep, router]); + + useEffect(() => { + if (!hydrated || loadingStage) return; + if (!started) { + if (process.env.NEXT_PUBLIC_POSTHOG_KEY) { + try { posthog?.capture('flows_onboarding_intro_viewed'); } + catch { /* Measurement is optional. */ } + } + return; + } + questionHeading.current?.focus({ preventScroll: true }); + if (previousStep.current !== null && previousStep.current !== draft.step && + window.matchMedia('(max-width: 760px)').matches) { + questionHeading.current?.scrollIntoView({ block: 'start', behavior: 'auto' }); + } + previousStep.current = draft.step; + setNotice(''); + if (hydrated && draft.step < 6 && process.env.NEXT_PUBLIC_POSTHOG_KEY) { + try { posthog?.capture('flows_onboarding_step_viewed', { step: draft.step + 1 }); } + catch { /* Measurement is optional. */ } + } + }, [draft.step, hydrated, posthog, started, loadingStage]); + + useEffect(() => { + const section = editor.current?.querySelector(`[data-section="${activeSection}"]`); + if (section && editor.current) editor.current.scrollTop = section.offsetTop - 20; + }, [activeSection, draft.step, started]); + + function showQuestions() { + if (process.env.NEXT_PUBLIC_POSTHOG_KEY) { + try { posthog?.capture('flows_onboarding_intro_completed'); } + catch { /* Starting the wizard must not depend on analytics. */ } + } + goToStep(0); + } + + function goToStep(step: number) { + router.push(onboardingPath(step) + window.location.search); + } + + function toggleAgent(id: AgentId) { + setDraft(current => ({ ...current, agents: current.agents.includes(id) + ? current.agents.filter(agent => agent !== id) : [...current.agents, id] })); + } + + function next() { + if (!canContinue(draft)) return; + // Record only product preferences, never the user's task text. + if (draft.step === 1 && process.env.NEXT_PUBLIC_POSTHOG_KEY) { + try { posthog?.capture('flows_onboarding_agents_selected', { + agents: draft.agents, + requested_agents: draft.agents.filter(id => !CODING_AGENTS.find(agent => agent.id === id)!.available), + other_agent: otherSelected ? draft.otherAgent?.trim() || undefined : undefined, + }); } catch { /* Optional analytics must not block the wizard. */ } + } + if (process.env.NEXT_PUBLIC_POSTHOG_KEY) { + try { + posthog?.capture('flows_onboarding_step_completed', { step: draft.step + 1 }); + if (draft.step === 5) posthog?.capture('flows_onboarding_completed'); + } catch { /* Measurement must not block completion. */ } + } + goToStep(draft.step + 1); + } + + async function copyCode() { + try { + await navigator.clipboard.writeText(source); + setCopied(true); setNotice('Flow copied to clipboard.'); + if (copyTimeout.current) clearTimeout(copyTimeout.current); + copyTimeout.current = setTimeout(() => setCopied(false), 2200); + } catch { setNotice('Clipboard unavailable. Use Download to save your flow.'); } + } + + function downloadCode() { + const url = URL.createObjectURL(new Blob([source], { type: 'text/typescript' })); + const a = document.createElement('a'); + a.href = url; a.download = 'software-factory.flow.ts'; a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + setNotice('Downloaded software-factory.flow.ts.'); + } + + return
+
+
+
+ +
+ {ONBOARDING_STAGES.map((stage, index) =>
+
+
+
+
+
+ {!started ?
+

Let’s build your first flow on Agent Relay.

+

A software factory is the easiest flow to set up. It’ll take about 5 minutes. Once you get the hang of Flows, you can build another one for other use cases.

+ +
: loadingStage ?

Loading your flow…

:
+ {!ready ?
+

{question.title}

+

{question.description}

+ {comingSoonOnly && draft.step > 1 &&

You’re building a Claude Code example. Your coming-soon preferences are saved.

} + + {draft.step === 0 && } + + {draft.step === 1 && <> +
Select all you use +
{CODING_AGENTS.filter(agent => agent.available).map(agent => + + )}
+
+
+ Use another agent?{requestedAgentCount > 0 ? `${requestedAgentCount} selected` : 'See more'} +
Coming soon + setShowInfo(true)} onMouseLeave={() => setShowInfo(false)}> + + We’ll use this answer to prioritize which coding agents we add. + +
{CODING_AGENTS.filter(agent => !agent.available).map(agent => + + )} + +
+ {otherSelected &&
+ + setDraft({ ...draft, otherAgent: event.target.value })} + aria-describedby="other-agent-help" /> +

Tell us which coding agent you’d like us to support.

+
} +
+
+ {comingSoonOnly &&

You can still build an example with Claude Code while support for your tools is on the way.

} + {draft.agents.filter(id => id === 'claude' || id === 'codex').length > 1 &&

{agentLabel(builder)} will write the code. You’ll choose the reviewer next.

} + } + + {draft.step === 2 &&
+ +