A sentence typed before signing in is still there after: New keeps what was unsent for the life of the page

Signing in swaps a visitor's page for the rail and draws New again, so the
draft and its files wait in the document the popup kept. The rail's sign-in
tests read the name the rail now shows, and refused storage refuses the page's
own kept choices while the session stays.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-27 17:27:39 -07:00
co-authored by Hanzo Dev
parent d8463e9647
commit 52e02a2f50
6 changed files with 46 additions and 26 deletions
+23 -15
View File
@@ -70,7 +70,7 @@ const nothing = (page: Page) => serve(page, () => undefined)
const stored = (page: Page, key: string) => page.evaluate((k) => window.localStorage.getItem(k), key)
/** The account has arrived: the rail names who is signed in. The exchange and userinfo take a moment. */
const arrived = (page: Page, who = 'dave@acme.test') => expect(page.getByRole('button', { name: `Account: ${who}` })).toBeVisible({ timeout: 20_000 })
const arrived = (page: Page, who = 'Dave · acme') => expect(page.getByRole('button', { name: `Account: ${who}` })).toBeVisible({ timeout: 20_000 })
test.describe('signing in', () => {
test('a popup signs in and the page, with what was typed, stays', async ({ page, baseURL }) => {
@@ -79,7 +79,7 @@ test.describe('signing in', () => {
await page.goto('/')
const ask = page.getByRole('textbox', { name: 'Describe a task or ask a question' })
await ask.fill('Add a cart to the shop')
const [popup] = await Promise.all([page.waitForEvent('popup'), page.getByRole('button', { name: 'Account: Sign in' }).click()])
const [popup] = await Promise.all([page.waitForEvent('popup'), page.getByRole('banner').getByRole('button', { name: 'Log in' }).click()])
await popup.waitForEvent('close')
await arrived(page)
await expect(ask).toHaveValue('Add a cart to the shop')
@@ -103,7 +103,7 @@ test.describe('signing in', () => {
const asked = await issuer(page.context())
await nothing(page)
await page.goto('/-/codebases')
await page.getByRole('button', { name: 'Account: Sign in' }).click()
await page.getByRole('banner').getByRole('button', { name: 'Log in' }).click()
await arrived(page)
expect(page.url()).toBe(new URL('/', baseURL).href)
expect(asked.authorize).toHaveLength(1)
@@ -118,7 +118,7 @@ test.describe('signing in', () => {
await issuer(page.context(), { token: { status: 400, json: { error: 'invalid_grant' } } })
await nothing(page)
await page.goto('/')
await page.getByRole('button', { name: 'Account: Sign in' }).click()
await page.getByRole('banner').getByRole('button', { name: 'Log in' }).click()
await expect(page.getByText('Token exchange failed (400): {"error":"invalid_grant"}')).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('Try signing in again.')).toBeVisible()
expect(await stored(page, 'hanzo_iam_access_token')).toBeNull()
@@ -172,7 +172,7 @@ test.describe('signing in', () => {
})
await nothing(page)
await page.goto('/')
await page.getByRole('button', { name: 'Account: Sign in' }).click()
await page.getByRole('banner').getByRole('button', { name: 'Log in' }).click()
// The trip to hanzo.id and back loads the page twice.
await expect(page.getByText('Completing sign-in…')).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('One moment.')).toBeVisible()
@@ -196,7 +196,7 @@ test.describe('who is signed in', () => {
return r.fulfill({ status: 204 })
})
await page.goto('/')
await page.getByRole('button', { name: 'Account: dave@acme.test' }).click()
await page.getByRole('button', { name: 'Account: Dave · acme' }).click()
await page.getByRole('menuitem', { name: 'Log out' }).click()
await expect.poll(() => ended.length).toBe(1)
expect(ended[0]!.searchParams.get('post_logout_redirect_uri')).toBe(new URL('/', baseURL).href)
@@ -215,7 +215,7 @@ test.describe('who is signed in', () => {
})
await nothing(page)
await page.goto('/')
await expect(page.getByRole('button', { name: 'Account: Sign in' })).toBeVisible()
await expect(page.getByRole('banner').getByRole('button', { name: 'Log in' })).toBeVisible()
expect(await stored(page, 'hanzo:who')).toBeNull()
expect(await stored(page, 'hanzo.build.slack')).toBeNull()
expect(await stored(page, 'hanzo.build.new.acme')).toBeNull()
@@ -245,7 +245,7 @@ test.describe('who is signed in', () => {
test('an account hanzo.id names nothing about is still signed in', async ({ page }) => {
await account(page, {})
await expect(page.getByRole('button', { name: 'Account:', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: `Account: ${ORG}`, exact: true })).toBeVisible()
await expect(page.getByLabel('Full name')).toHaveValue('')
})
})
@@ -443,7 +443,7 @@ test.describe('a host that draws its own rail', () => {
await runs(page)
await page.route('**/v1/pref', (r) => r.fulfill({ json: { prefs: { theme: 'light' }, updatedAt: 1 } }))
await page.goto(`${HOST}?at=-/settings`)
await expect(page.getByText('How Hanzo Build looks and listens', { exact: false })).toBeVisible()
await expect(page.getByText('How Hanzo looks and listens', { exact: false })).toBeVisible()
// No theme to choose on this host, and the saved one is not forced on it.
await expect(page.getByRole('button', { name: /^Theme: / })).toHaveCount(0)
await expect(page.locator('html')).toHaveClass(/\bt_dark\b/)
@@ -791,14 +791,22 @@ test.describe('the person’s own settings', () => {
test('a browser that refuses storage keeps a choice for this page only', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (e) => errors.push(e.message))
// Storage refuses the page's own kept choices; the session IAM keeps is still there, so the rail is.
await page.addInitScript(() => {
Object.defineProperty(window, 'localStorage', {
get() {
throw new DOMException('The operation is insecure.', 'SecurityError')
},
})
const refuse = (key: string) => {
if (key.startsWith('hanzo.build.')) throw new DOMException('The operation is insecure.', 'SecurityError')
}
const { getItem, setItem } = Storage.prototype
Storage.prototype.getItem = function (key: string) {
refuse(key)
return getItem.call(this, key)
}
Storage.prototype.setItem = function (key: string, value: string) {
refuse(key)
setItem.call(this, key, value)
}
})
await serve(page, () => undefined)
await signIn(page, () => undefined)
await page.goto('/')
const rail = page.getByRole('navigation', { name: 'Runs' }).first()
await expect(rail).toHaveAttribute('data-collapsed', 'false')
+3 -3
View File
@@ -22,7 +22,7 @@ const SCREENS: [string, string, RegExp][] = [
['run', '/sess_0992f90537264a6b154ebff799f38e1c', /Sign in to follow this run/],
]
const BAR = ['Features', 'Resources', 'Enterprise', 'Pricing', 'Log in', 'Sign up']
const BAR = ['Download', 'Resources', 'Enterprise', 'Pricing', 'Log in', 'Sign up']
/** The IAM client this host signs in as: hanzo.app is the Hanzo App's. */
const client = (base: string) => (new URL(base).hostname === 'hanzo.app' ? 'hanzo-app' : 'hanzo-build')
@@ -58,7 +58,7 @@ test('the header leads to the product’s pages, and both doors open IAM beside
const bar = page.getByRole('banner')
const site = bar.getByRole('navigation', { name: 'Site' })
for (const [label, href] of [
['Features', 'https://hanzo.ai/app'],
['Download', '/#features'],
['Enterprise', 'https://hanzo.ai/enterprise'],
['Pricing', 'https://hanzo.ai/pricing'],
]) {
@@ -132,7 +132,7 @@ test('a phone gets every screen whole, with the header folded into one menu', as
}
await page.getByRole('button', { name: 'Menu' }).click()
const menu = page.getByRole('menu', { name: 'Menu' })
for (const label of ['Features', 'Enterprise', 'Pricing', 'Docs', 'Sign up']) await expect(menu.getByText(label, { exact: true })).toBeVisible()
for (const label of ['Download', 'Enterprise', 'Pricing', 'Docs', 'Sign up']) await expect(menu.getByText(label, { exact: true })).toBeVisible()
})
test('a run asks a visitor to sign in, not for an API key', async ({ page }) => {
+2 -1
View File
@@ -85,7 +85,8 @@ test('on a phone the places fold into one menu, which leaves and signs in', asyn
await bar.getByRole('button', { name: 'Menu' }).click()
const menu = page.getByRole('menu', { name: 'Menu' })
await page.screenshot({ path: info.outputPath('visit-phone-menu.png') })
expect(await leaves(page, () => menu.getByText('Features', { exact: true }).click())).toBe('https://hanzo.ai/app')
await menu.getByText('Download', { exact: true }).click()
await expect(page.getByRole('heading', { name: 'Hanzo Dev' })).toBeVisible()
await page.keyboard.press('Escape')
await bar.getByRole('button', { name: 'Menu' }).click()
+13 -2
View File
@@ -57,6 +57,14 @@ const FIRST: Kept = { repo: null, branch: '', place: '', mode: 'build', model: E
const COLUMN = 768
/**
* What was typed here and not sent, for as long as this document lives. Signing
* in swaps a visitor's page for the rail, which draws New again in another
* place; the popup keeps the document so the sentence can come along, and this
* is where it waits.
*/
let unsent: { draft: string; files: Attached[] } = { draft: '', files: [] }
export function Landing({ onStarted }: { onStarted: (session: string) => void }) {
const host = useHost()
const t = useTarget()
@@ -67,8 +75,11 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
const kept: Kept = stored ?? { ...FIRST, ...prefs.code }
const set = (patch: Partial<Kept>) => keep({ ...kept, ...patch })
const [draft, setDraft] = useState(kept.ask || '')
const [files, setFiles] = useState<Attached[]>([])
const [draft, setDraft] = useState(kept.ask || unsent.draft)
const [files, setFiles] = useState<Attached[]>(unsent.files)
useEffect(() => {
unsent = { draft, files }
}, [draft, files])
const [busy, setBusy] = useState(false)
const [note, setNote] = useState('')
const [adding, setAdding] = useState<Source | null>(null)
+3 -3
View File
@@ -71,14 +71,14 @@ export function Pitch({ onStart }: { onStart: () => void }) {
Powered by Zen 6 · routed by Enso
</SizableText>
<SizableText role="heading" aria-level={2} size="$9" color="$ink" style={{ textAlign: 'center' }}>
Bring Hanzo to your computer.
Hanzo Dev
</SizableText>
<SizableText size="$4" color="$soft" maxW={620} style={{ textAlign: 'center' }}>
The agent that builds here also runs in your terminal, on any codebase, in any language. Hand it a task, or open it and talk.
The agent. It runs in your terminal, on any codebase, in any language. This page is that same agent in the browser.
</SizableText>
<Line text={INSTALL} />
<SizableText size="$2" color="$soft" fontFamily="$mono">
then hanzo "fix the failing test", or hanzo code
dev opens it. dev "fix the failing test" hands it a task.
</SizableText>
<a href={DOCS} style={{ color: 'inherit', textDecoration: 'none' }}>
<XStack items="center" height={34} px="$3" rounded="$3" borderWidth={1} borderColor="$borderColor" hoverStyle={{ bg: '$hover' }}>
+2 -2
View File
@@ -2,7 +2,7 @@
* The page to a visitor: the product's header over New.
*
* Signed out there are no runs to list, so the rail gives way to a header —
* Features (under New, pitch.tsx), Resources, Enterprise, Pricing — and the two
* Download (Hanzo Dev, under New, pitch.tsx), Resources, Enterprise, Pricing — and the two
* doors in, which are one door: IAM's page offers both. New stays the page, and
* sending from it asks the visitor to sign in with the sentence kept
* (landing.tsx). Below md the four places fold into one menu beside Log in.
@@ -18,7 +18,7 @@ import { useHost } from './host.tsx'
const SITE = 'https://hanzo.ai'
export const PLACES = [
{ label: 'Features', href: '/#features' },
{ label: 'Download', href: '/#features' },
{ label: 'Enterprise', href: `${SITE}/enterprise` },
{ label: 'Pricing', href: `${SITE}/pricing` },
] as const