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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion web/app/signup/agent/[product]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ export async function GET(request: Request, { params }: { params: Promise<{ prod
const { product } = await params;
if (!isAgentSignupProduct(product)) return new Response('Not found', { status: 404 });
const url = new URL(request.url);
const requestHost = request.headers.get('host');
const localHostMatch = requestHost?.match(/^(localhost|127\.0\.0\.1)(?::(\d{1,5}))?$/);
const localPort = localHostMatch?.[2] ? Number(localHostMatch[2]) : undefined;
const localRequestHost = localHostMatch && (localPort === undefined || localPort <= 65_535) ? requestHost : null;
// The apex router's HTTP fallback rewrites the URL to the marketing origin.
// Sign-in and /cloud remain on the public apex, never that upstream host.
const site = url.hostname === 'origin-web.agentrelay.com' ? SITE_URL : url.origin;
// Next dev can normalize request.url to localhost while the browser used
// 127.0.0.1; keep the exact local host so OAuth and the progress page agree.
const site = url.hostname === 'origin-web.agentrelay.com' ? SITE_URL
: ['localhost', '127.0.0.1'].includes(url.hostname) && localRequestHost ? `${url.protocol}//${localRequestHost}` : url.origin;
const cloud = new URL(teamsCloudUrl(''), site).href.replace(/\/$/, '');
return new Response(agentSignupInstructions(product, site, cloud), {
headers: {
Expand Down
63 changes: 54 additions & 9 deletions web/components/AgentSignupJourney.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,25 @@ export function AgentSignupJourney({ product }: { product: AgentSignupProduct })
const [expired, setExpired] = useState(false);
const [attempt, setAttempt] = useState(0);
const [showPrompt, setShowPrompt] = useState(false);
const [answerValue, setAnswerValue] = useState('');
const [answerError, setAnswerError] = useState('');
const [answerSubmitting, setAnswerSubmitting] = useState(false);
const textarea = useRef<HTMLTextAreaElement>(null);
const boot = useRef<ReturnType<typeof startSession> | null>(null);
const steps = signupSteps[product];
const complete = progress?.state === 'complete';
const active = progress?.step || 0;
const inputRequest = product === 'flows' ? progress?.inputRequest : undefined;
// The API stamps the request's progress step, so reloads can distinguish a
// current answer (including step zero) from one left over after advancement.
const answeredInput = inputRequest?.status === 'answered' && progress?.state === 'waiting' && inputRequest.step === active;
const paused = progress?.state === 'waiting' && active > 0;
const failed = progress?.state === 'failed';
const endpoint = origin ? new URL(apiPath, origin).href : '';
const prompt = progress && token ? trackedSignupPrompt(product, origin, endpoint, { id: progress.id, writeToken: token }) : '';

useEffect(() => { setAnswerValue(''); setAnswerError(''); }, [inputRequest?.id]);

latest.current = { step: active, owner: Boolean(token) };
useEffect(() => {
const generation = ++lifecycle.current;
Expand Down Expand Up @@ -165,16 +174,34 @@ export function AgentSignupJourney({ product }: { product: AgentSignupProduct })
try { await navigator.clipboard.writeText(prompt); setCopyMessage('Copied. Paste it into your agent.'); analytics.track('prompt_copied'); }
catch { analytics.track('manual_copy_shown'); setShowPrompt(true); setCopyMessage('Select and copy the prompt below.'); requestAnimationFrame(() => { textarea.current?.focus(); textarea.current?.select(); }); }
}
async function submitAnswer(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!progress || !token || !inputRequest || inputRequest.status !== 'pending' || !answerValue.trim() || answerSubmitting) return;
setAnswerSubmitting(true); setAnswerError('');
try {
const response = await fetch(`${apiPath}/${encodeURIComponent(progress.id)}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ requestId: inputRequest.id, answer: answerValue.trim() }), signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(response.status === 409 ? 'This question changed. Wait for the latest request.' : 'Could not send your answer. Please try again.');
const next: unknown = await response.json();
if (!isSignupProgress(next)) throw new Error('Could not confirm your answer. Please refresh the page.');
setProgress(previous => !previous || next.revision >= previous.revision ? next : previous);
setAnswerValue('');
} catch (cause) { setAnswerError(cause instanceof Error ? cause.message : 'Could not send your answer. Please try again.'); }
finally { setAnswerSubmitting(false); }
}
function restart() {
analytics.restart(Boolean(token));
try { sessionStorage.removeItem(storageKey(product)); } catch { /* Best effort. */ }
window.location.assign(window.location.pathname);
}
const title = expired ? 'Session expired' : complete ? 'You’re all set.' : failed ? 'Your agent needs a hand.' : paused ? 'A quick approval from you.' : active ? steps[active - 1].title : 'Waiting for your agent';
const detail = expired ? 'Start a new session to keep watching setup.' : complete ? 'Your agent has verified setup. You’re ready to go.' : failed ? 'Check your agent’s conversation to resolve the issue. Progress will resume here.' : paused ? 'Follow the approval request in your agent’s conversation. We’ll pick up right here.' : active ? steps[active - 1].detail : 'The show starts when you paste the prompt into your agent.';
const mode = expired || error ? 'offline' : complete ? 'complete' : failed ? 'failed' : paused ? 'paused' : active ? 'working' : 'waiting';
const notice = product === 'flows' && inputRequest?.type === 'notice' && inputRequest.status === 'pending' ? inputRequest : undefined;
const title = expired ? 'Session expired' : complete ? 'You’re all set.' : notice ? 'Your Flow preview is saved.' : failed ? 'Your agent needs a hand.' : answeredInput ? 'Answer received.' : paused ? 'A quick approval from you.' : active ? steps[active - 1].title : 'Waiting for your agent';
const detail = expired ? 'Start a new session to keep watching setup.' : complete ? 'Your agent has verified setup. You’re ready to go.' : notice ? 'Review the inactive draft below. Internal model routing must be configured before live activation.' : failed ? (product === 'flows' ? 'Your agent will report what needs attention here.' : 'Check your agent’s conversation to resolve the issue. Progress will resume here.') : inputRequest?.status === 'pending' ? 'Answer on this page and your agent will keep going.' : answeredInput ? 'Your answer is in. Your agent is moving to the next step.' : paused ? (product === 'flows' ? 'Complete the sign-in or connection approval page your agent opened. Setup will resume here.' : 'Follow the approval request in your agent’s conversation. We’ll pick up right here.') : active ? steps[active - 1].detail : 'The show starts when you paste the prompt into your agent.';
const mode = expired || error ? 'offline' : complete ? 'complete' : failed ? 'failed' : paused || answeredInput ? 'paused' : active ? 'working' : 'waiting';

const heading = complete ? 'All yours.' : expired || failed || active ? title : 'Leave it to your agent.';
const heading = expired ? 'Session expired' : complete ? 'All yours.' : notice ? 'Preview saved.' : inputRequest?.status === 'pending' ? 'Your input is needed.' : answeredInput || failed || active ? title : 'Leave it to your agent.';

return (
<div className={`${s.page} ph-sensitive ph-no-capture`} data-mode={mode}>
Expand All @@ -183,8 +210,8 @@ export function AgentSignupJourney({ product }: { product: AgentSignupProduct })
<main className={s.main}>
<div className={s.content}>
<h1>{heading}</h1>
<p className={s.subtitle}>{complete ? 'Your agent has verified setup. You’re ready to go.' : expired || failed || active ? detail : 'Give this prompt to your coding agent and hang out here to watch it sign you up.'}</p>
{!complete && !active && !expired && !failed && (
<p className={s.subtitle}>{complete ? 'Your agent has verified setup. You’re ready to go.' : expired || failed || inputRequest?.status === 'pending' || answeredInput || active ? detail : 'Give this prompt to your coding agent and hang out here to watch it sign you up.'}</p>
{!complete && !active && !expired && !failed && !answeredInput && inputRequest?.status !== 'pending' && (
<div className={s.agents} role="img" aria-label="Codex, Claude Code, Grok, and OpenCode">
<span title="Codex"><AgentToolLogo provider="codex" className={s.agentLogo} /></span>
<span title="Claude Code"><AgentToolLogo provider="claude" className={s.agentLogo} /></span>
Expand All @@ -193,11 +220,29 @@ export function AgentSignupJourney({ product }: { product: AgentSignupProduct })
</div>
)}
{complete ? <a onClick={() => { if (token) analytics.track('dashboard_opened', active); }} className={s.primary} href={teamsCloudUrl(product === 'teams' ? '/dashboard/sessions' : '/dashboard')}>Open {product === 'teams' ? 'your workspace' : 'dashboard'} <ArrowUpRight size={17} /></a>
: !active && !expired ? <button type="button" className={s.primary} disabled={!prompt} onClick={() => void copy()}>{copyMessage.startsWith('Copied') ? <Check size={17} /> : <Copy size={17} />}{copyMessage.startsWith('Copied') ? 'Prompt copied' : 'Copy setup prompt'}</button> : null}
<p className={s.copyStatus} role="status">{complete ? '' : active ? (paused ? 'Your agent will continue after you approve.' : 'You can leave this page open.') : copyMessage || (expired ? '' : progress && !token ? 'Watching this session. The prompt is in the original browser tab.' : product === 'teams' ? 'Paste into a coding agent on your Mac.' : 'Paste into a coding agent with terminal access.')}</p>
: !active && !expired && !answeredInput && inputRequest?.status !== 'pending' ? <button type="button" className={s.primary} disabled={!prompt} onClick={() => void copy()}>{copyMessage.startsWith('Copied') ? <Check size={17} /> : <Copy size={17} />}{copyMessage.startsWith('Copied') ? 'Prompt copied' : 'Copy setup prompt'}</button> : null}
<p className={s.copyStatus} role="status">{complete ? '' : expired ? '' : notice ? 'No action is required to keep this draft saved.' : inputRequest?.status === 'pending' ? 'Your answer goes straight to your agent.' : answeredInput ? 'Your agent is processing your answer.' : active ? (paused ? 'Your agent will continue after you approve.' : 'You can leave this page open.') : copyMessage || (progress && !token ? 'Watching this session. The prompt is in the original browser tab.' : product === 'teams' ? 'Paste into a coding agent on your Mac.' : 'Paste into a coding agent with terminal access.')}</p>
{inputRequest && !expired && !complete && (inputRequest.status === 'pending' || answeredInput) && <section className={s.inputCard} aria-label={notice ? 'Flow preview status' : 'Question from your agent'}>
{notice ? <>
<p className={s.inputEyebrow}>INACTIVE PREVIEW</p>
<h2>{notice.label}</h2>
{notice.actionHref && <a className={s.inputAction} href={teamsCloudUrl(notice.actionHref)}>Open saved preview <ArrowUpRight size={16} /></a>}
</> : inputRequest.status === 'pending' ? <>
<p className={s.inputEyebrow}>YOUR AGENT IS ASKING</p>
<h2>{inputRequest.label}</h2>
{token ? <form onSubmit={event => void submitAnswer(event)}>
{inputRequest.type === 'select' ? <select aria-label={inputRequest.label} value={answerValue} onChange={event => setAnswerValue(event.target.value)} required>
<option value="">Choose an option</option>
{inputRequest.options?.map(option => <option key={option} value={option}>{option}</option>)}
</select> : <input aria-label={inputRequest.label} type="text" maxLength={300} value={answerValue} onChange={event => setAnswerValue(event.target.value)} placeholder={inputRequest.key === 'approver' ? '@username' : inputRequest.key === 'repository' ? 'owner/repository' : 'Type your answer'} required />}
<button type="submit" disabled={answerSubmitting || !answerValue.trim()}>{answerSubmitting ? 'Sending…' : 'Continue'}</button>
</form> : <p>Open the original signup tab to answer this question.</p>}
{answerError && <p className={s.inputError} role="alert">{answerError}</p>}
</> : <p className={s.inputSent} role="status">Answer sent. Your agent will continue setup here.</p>}
</section>}
<div className={s.progress} role="status" aria-live="polite">
<div className={s.progressDots} aria-hidden="true">{steps.map((step, index) => <i key={step.title} data-done={complete || active > index + 1} data-current={!complete && active === index + 1} />)}</div>
<span>{expired ? 'Session expired' : error ? 'Waiting for a connection' : complete ? 'Setup complete' : active ? `${active} of 5 · ${paused ? 'Waiting for your approval' : failed ? 'Needs your attention' : steps[active - 1].title}` : progress ? 'Ready when your agent is' : 'Preparing your session…'}</span>
<span>{expired ? 'Session expired' : error ? 'Waiting for a connection' : complete ? 'Setup complete' : notice ? `${active} of 5 · Preview saved; activation pending` : inputRequest?.status === 'pending' ? 'Waiting for your answer' : answeredInput ? (active ? `${active} of 5 · Answer received` : 'Answer received') : active ? `${active} of 5 · ${paused ? 'Waiting for your approval' : failed ? 'Needs your attention' : steps[active - 1].title}` : progress ? 'Ready when your agent is' : 'Preparing your session…'}</span>
</div>
{error && <div className={s.error} role="alert"><p>{error}</p>{!progress && <button type="button" onClick={() => {
boot.current = null; setError('');
Expand Down
14 changes: 14 additions & 0 deletions web/components/agent-signup-journey.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@
.primary { composes: btn btn-primary from global; }
.primary:disabled { opacity: .45; cursor: default; transform: none; }
.copyStatus { min-height: 20px; margin: 14px 0 0; color: #7899af; font-size: 11px; line-height: 1.7; }
.inputCard { box-sizing: border-box; margin: 30px auto 0; padding: 24px; max-width: 480px; border: 1px solid #8dc7e94d; border-radius: 14px; background: #0b2636e8; text-align: left; box-shadow: 0 16px 48px #030f1899; }
.inputEyebrow { margin: 0 0 9px; color: #8dc7e9; font-size: 10px; font-weight: 700; letter-spacing: .12em; }
.inputCard h2 { margin: 0 0 18px; font-size: 19px; font-weight: 500; line-height: 1.4; letter-spacing: -.025em; }
.inputCard form { display: flex; gap: 10px; }
.inputCard input, .inputCard select { min-width: 0; flex: 1; box-sizing: border-box; height: 44px; padding: 0 12px; border: 1px solid #45667a; border-radius: 8px; background: #071722; color: #e8f2fa; font: inherit; font-size: 13px; }
.inputCard input:focus-visible, .inputCard select:focus-visible { outline: 2px solid #8dc7e9; outline-offset: 2px; }
.inputCard button { flex: none; border: 0; border-radius: 8px; padding: 0 16px; background: #8dc7e9; color: #09202e; font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; }
.inputCard button:disabled { opacity: .5; cursor: default; }
.inputCard .inputError { margin: 12px 0 0; color: #efb6a8; font-size: 12px; }
.inputSent { margin: 0; color: #a9d6c9; font-size: 13px; }
.inputAction { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; box-sizing: border-box; padding: 0 15px; border-radius: 8px; background: #8dc7e9; color: #09202e; font-size: 13px; font-weight: 700; text-decoration: none; }
.inputAction:hover { background: #b3ddf4; }
.inputAction:focus-visible { outline: 2px solid #d4efff; outline-offset: 3px; }
.progress { margin: 32px 0 0; color: #9eb9cb; font-size: 11px; line-height: 1.7; }
.progressDots { display: flex; gap: 6px; justify-content: center; margin-bottom: 11px; }
.progressDots i { width: 23px; height: 2px; border-radius: 3px; background: #294355; transition: background 1s; }
Expand Down Expand Up @@ -46,6 +59,7 @@
.main { padding-top: 43svh; padding-bottom: 30px; }.atmosphere { height: 720px; }
.content h1 { font-size: 34px; }.subtitle { font-size: 14px; margin-bottom: 24px; }
.progress { margin-top: 26px; }.copyStatus { font-size: 10px; }
.inputCard { padding: 20px; }.inputCard form { flex-direction: column; }.inputCard input, .inputCard select { flex: none; width: 100%; }.inputCard button { min-height: 44px; }
}
@media (max-height: 700px) and (min-width: 601px) {
.main { padding-top: 360px; }.atmosphere { height: 720px; }
Expand Down
32 changes: 32 additions & 0 deletions web/e2e/agent-signup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,38 @@ test('clipboard failure reveals and selects the entire prompt', async ({ page })
expect(await prompt.inputValue()).toContain('/signup/agent/flows');
});

test('shows an answered flow input at step zero until the agent advances', async ({ page }) => {
let progress = {
id, product: 'flows', step: 0, state: 'waiting', revision: 1,
updatedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 7200000).toISOString(),
inputRequest: { id: `${id.slice(0, -1)}8`, key: 'repository', label: 'Which repository?', type: 'text', status: 'answered', step: 0 },
};
await page.route('**/cloud/api/v1/signup/agent/sessions**', route => route.fulfill({
status: route.request().method() === 'POST' ? 201 : 200,
json: route.request().method() === 'POST' ? { ...progress, writeToken: token } : progress,
}));
await page.goto('/signup/flows');
await expect(page.getByRole('heading', { name: 'Answer received.' })).toBeVisible();
await expect(page.getByRole('region', { name: 'Question from your agent' })).toContainText('Answer sent. Your agent will continue setup here.');
await expect(page.getByText('Answer received', { exact: true })).toBeVisible();
await expect(page.getByText('0 of 5 · Answer received')).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Copy setup prompt' })).not.toBeVisible();

progress = { ...progress, step: 2, state: 'working', revision: 2 };
await expect(page.getByRole('heading', { name: 'Choose your flow', exact: true })).toBeVisible();
await expect(page.getByText('2 of 5 · Choose your flow')).toBeVisible();
await expect(page.getByRole('region', { name: 'Question from your agent' })).not.toBeVisible();

progress = { ...progress, step: 2, state: 'waiting', revision: 3, inputRequest: { ...progress.inputRequest, id: `${id.slice(0, -1)}7`, step: 2 } };
await expect(page.getByRole('heading', { name: 'Answer received.' })).toBeVisible();
await expect(page.getByText('2 of 5 · Answer received')).toBeVisible();

progress = { ...progress, step: 3, state: 'waiting', revision: 4 };
await expect(page.getByRole('heading', { name: 'A quick approval from you.' })).toBeVisible();
await expect(page.getByText('3 of 5 · Waiting for your approval')).toBeVisible();
await expect(page.getByRole('region', { name: 'Question from your agent' })).not.toBeVisible();
});

test('logo ribbons respond to pointer movement and settle into the completed mark', async ({ page }, testInfo) => {
let progress = { id, product: 'teams', step: 0, state: 'waiting', revision: 0, updatedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 7200000).toISOString() };
await page.route('**/cloud/api/v1/signup/agent/sessions**', route => route.fulfill({ status: route.request().method() === 'POST' ? 201 : 200, json: route.request().method() === 'POST' ? {...progress, writeToken: token} : progress }));
Expand Down
Loading
Loading