@hanzo/build 0.2.79: a suggestion does what it says

Each row carries a typed act and pressing it does it at once: open opens
the item it names (a conversation, a run, an issue, a creation), send
starts a chat with its prompt, task starts a Dev run, schedule makes the
scheduled task and shows it. The model names one of the four; a row it
cannot bind (an open of an item the activity does not hold, an act with
no prompt, a time `every` cannot read) is dropped, never repaired. The
whole row is the one target, its title one short action, its × kept.

hand and taken carry a send or a task to the product that does it,
through same-tab session storage, so an address never carries one.
book makes a schedule row's task.

The composer rests at two rows, a line of text and the tools, and grows
with the text; the dictation chevron shows only while the mic is reached
for.
This commit is contained in:
2026-10-20 22:26:12 -07:00
parent cd4414c987
commit e672672bc0
11 changed files with 472 additions and 245 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/build",
"version": "0.2.78",
"version": "0.2.79",
"type": "module",
"license": "Apache-2.0 OR MIT",
"description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",
+23
View File
@@ -107,6 +107,29 @@ test.describe('at 1440', () => {
await shot(p, '1440-new.png')
})
test('at rest the composer is two rows, one line of text and the tools, and grows with the text to its ceiling', async ({ page: p }) => {
await platform(p, HOLDING)
await p.goto('/')
const ask = field(p, 'Describe a task or ask a question')
await expect(ask).toBeVisible({ timeout: 45_000 })
const line = async () => (await ask.boundingBox())!.height
const rest = await line()
// One line of 24px, with the field's own few px of padding.
expect(rest).toBeGreaterThanOrEqual(24)
expect(rest).toBeLessThan(36)
const tall = (await frame(p).boundingBox())!.height
// The text's row and the tools' row: no third row of air.
expect(tall).toBeLessThan(rest + 70)
await shot(p, '1440-new-rest.png')
await ask.fill(['one', 'two', 'three', 'four'].join('\n'))
await expect.poll(line).toBeGreaterThan(rest + 60)
await ask.fill(Array.from({ length: 40 }, (_, n) => `line ${n}`).join('\n'))
const capped = await line()
expect(capped).toBeLessThan(900 / 2)
await ask.fill('')
await expect.poll(line).toBeLessThan(36)
})
test('the chip starts on Enso at Medium; High and a model chosen on New are kept and shown by a run’s follow-up, and Send carries them', async ({ page: p }) => {
const { sent } = await platform(p, HOLDING)
await p.goto('/')
+69 -40
View File
@@ -1,10 +1,11 @@
/**
* The suggestions under the composer (suggestions.tsx), against a platform this
* spec answers: made once from the person's activity and kept in the account,
* read back without asking again, each row a button that fills the composer,
* its title a link out with its summary on hover, its × a dismissal the
* account remembers; nothing drawn when the model has nothing to say; a
* finger's pitch on a phone, above the composer; both themes.
* read back without asking again, each row ONE button that does its act at once
* (open, send, task, schedule; the harness records which), its summary on
* hover, its × a dismissal the account remembers; a row that binds to nothing
* never drawn; the starters when the model has nothing to say; a finger's pitch
* on a phone, above the composer; both themes.
*/
import type { Page, Request } from '@playwright/test'
@@ -17,14 +18,15 @@ const BUG = 'https://github.com/hanzo-inc/kai/issues/12'
const ROWS = [
{
icon: 'github',
title: 'Hanzo app login fixes landed',
href: PR,
title: 'Check the login fixes on #29',
summary: 'Three pull requests changed account discovery and signup: new emails on /login now go to /signup without a code first.',
action: 'Verify the user flow',
prompt: `Walk me through verifying the login and signup flow that ${PR} changed.`,
action: 'open',
source: PR,
prompt: '',
every: '',
},
{ icon: 'github', title: 'Kai P0 mapping bug is open', href: BUG, summary: 'The mapping drops rows.', action: 'Audit the fix path', prompt: `Audit the fix path for ${BUG}.` },
{ icon: 'agent', title: 'Monday brief', href: '', summary: '', action: 'Sharpen its sources', prompt: 'Help me sharpen the sources my Monday brief reads.' },
{ icon: 'agent', title: 'Fix the Kai mapping bug', summary: 'The mapping drops rows.', action: 'task', source: '', prompt: `Fix ${BUG}: the mapping drops rows.`, every: '' },
{ icon: 'agent', title: 'Run the brief every Monday', summary: '', action: 'schedule', source: '', prompt: 'Brief me on adoption signals.', every: 'weekly mon 09:00' },
]
const BOARD = [
@@ -84,10 +86,11 @@ async function open(p: Page, rig: Rig): Promise<void> {
}
const list = (p: Page) => p.getByRole('list', { name: 'Suggestions' })
const row = (p: Page, title: string) => p.getByRole('button', { name: new RegExp(`^${title}\\.`) })
const row = (p: Page, title: string) => p.getByRole('button', { name: title, exact: true })
const went = (p: Page) => p.evaluate(() => window.went.map((w) => JSON.parse(w) as Record<string, unknown>))
const shot = (p: Page, name: string) => p.screenshot({ path: test.info().outputPath(name) })
test('made once from the person’s activity, kept, and each row does its three things', async ({ page }) => {
test('made once from the person’s activity, kept, and each row does its act at once', async ({ page }) => {
const seen = await platform(page, null)
await open(page, { theme: 'dark' })
@@ -96,42 +99,48 @@ test('made once from the person’s activity, kept, and each row does its three
const asked = seen.model[0]!
expect(asked.model).toBe('zen5-flash')
expect((asked.response_format as { json_schema: { strict: boolean } }).json_schema.strict).toBe(true)
const user = JSON.parse((asked.messages as { content: string }[])[1]!.content) as { activity: { kind: string; href: string }[]; person: Record<string, string> }
const user = JSON.parse((asked.messages as { content: string }[])[1]!.content) as { activity: { kind: string; key: string }[]; person: Record<string, string> }
expect(user.activity.map((a) => a.kind).sort()).toEqual(['automation', 'conversation', 'issue', 'pull', 'run'])
expect(user.activity.find((a) => a.kind === 'pull')!.key).toBe(PR)
expect(user.person).toEqual({ name: 'Z', role: 'Software engineer', use: 'team' })
await expect.poll(() => seen.patches.length).toBe(1)
const kept = seen.patches[0]!.suggest as { at: string; sig: string; rows: { href: string }[]; dismissed: string[] }
expect(kept.rows.map((r) => r.href)).toEqual([PR, BUG, ''])
const kept = seen.patches[0]!.suggest as { at: string; sig: string; rows: { act: { kind: string } }[]; dismissed: string[] }
expect(kept.rows.map((r) => r.act.kind)).toEqual(['open', 'task', 'schedule'])
expect(kept.sig).toMatch(/^[0-9a-f]{14}$/)
expect(kept.dismissed).toEqual([])
// The title opens its source in a new tab; the row without one has no link.
const link = page.getByRole('link', { name: 'Hanzo app login fixes landed, opens the source' })
await expect(link).toHaveAttribute('href', PR)
await expect(link).toHaveAttribute('target', '_blank')
await expect(page.getByRole('link', { name: /Monday brief/ })).toHaveCount(0)
// One short action a row, and no link inside it: the row is the one target.
await expect(list(page).getByRole('link')).toHaveCount(0)
await expect(list(page).getByRole('listitem').first()).toHaveText(ROWS[0]!.title)
// Its summary on hover, in a card over the row.
await link.hover()
await row(page, ROWS[0]!.title).hover()
await expect(page.getByText(/Three pull requests changed account discovery/)).toBeVisible()
await shot(page, 'suggest-dark-hover.png')
await page.mouse.move(5, 5)
// A press anywhere else on the row fills the composer.
await row(page, 'Kai P0 mapping bug is open').click()
await expect(page.getByRole('textbox', { name: 'Message' })).toHaveValue(`Audit the fix path for ${BUG}.`)
// A press anywhere on the row does its act: this one opens the pull request it names.
await row(page, ROWS[0]!.title).click({ position: { x: 300, y: 10 } })
expect(await went(page)).toEqual([{ kind: 'open', to: PR }])
// The keyboard reaches each row, its link and its × in turn; Enter picks.
await row(page, 'Monday brief').focus()
// The keyboard reaches each row and its × in turn; Enter does the act.
await row(page, 'Fix the Kai mapping bug').focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('textbox', { name: 'Message' })).toHaveValue('Help me sharpen the sources my Monday brief reads.')
expect((await went(page))[1]).toEqual({ kind: 'task', prompt: ROWS[1]!.prompt })
await row(page, 'Run the brief every Monday').focus()
await page.keyboard.press('Enter')
expect((await went(page))[2]).toEqual({ kind: 'schedule', name: 'Run the brief every Monday', prompt: ROWS[2]!.prompt, schedule: { kind: 'weekly', day: 'mon', at: '09:00' } })
// Nothing was only typed into the composer.
await expect(page.getByRole('textbox', { name: 'Message' })).toHaveValue('')
await page.keyboard.press('Tab')
await expect(page.getByRole('button', { name: 'Dismiss Monday brief' })).toBeFocused()
await expect(page.getByRole('button', { name: 'Dismiss Monday brief' })).toHaveCSS('opacity', '1')
await expect(page.getByRole('button', { name: 'Dismiss Run the brief every Monday' })).toBeFocused()
await expect(page.getByRole('button', { name: 'Dismiss Run the brief every Monday' })).toHaveCSS('opacity', '1')
// The × dismisses it, and the account remembers which.
// The × dismisses it, and the account remembers which; its act is not done.
await page.keyboard.press('Enter')
await expect(list(page).getByRole('listitem')).toHaveCount(2)
await expect(list(page).getByRole('listitem')).toHaveCount(3)
await expect(row(page, 'Run the brief every Monday')).toHaveCount(0)
expect(await went(page)).toHaveLength(3)
await expect.poll(() => seen.patches.length).toBe(2)
expect((seen.patches[1]!.suggest as { dismissed: string[]; rows: unknown[] }).dismissed).toHaveLength(1)
expect((seen.patches[1]!.suggest as { rows: unknown[] }).rows).toHaveLength(3)
@@ -139,24 +148,35 @@ test('made once from the person’s activity, kept, and each row does its three
// Read again: the kept rows, without reading a source or asking the model.
const before = seen.reads.length
await page.reload()
await expect(list(page).getByRole('listitem')).toHaveCount(2)
await expect(list(page).getByRole('listitem')).toHaveCount(3)
await expect(row(page, 'Run the brief every Monday')).toHaveCount(0)
expect(seen.model).toHaveLength(1)
expect(seen.reads.length).toBe(before)
})
test('nothing worth saying draws nothing', async ({ page }) => {
test('with nothing from the activity, the three starters, each an act', async ({ page }) => {
const seen = await platform(page, null, [])
await open(page, { theme: 'dark' })
await expect.poll(() => seen.patches.length).toBe(1)
expect((seen.patches[0]!.suggest as { rows: unknown[] }).rows).toEqual([])
await expect(list(page)).toHaveCount(0)
await expect(list(page).getByRole('listitem')).toHaveCount(3)
await row(page, 'Build a landing page with Dev').click()
expect((await went(page))[0]).toMatchObject({ kind: 'task' })
})
test('an address the activity does not hold is never drawn', async ({ page }) => {
await platform(page, null, [{ ...ROWS[0]!, href: 'https://evil.example/phish' }, ROWS[1]!])
test('a row that binds to nothing is never drawn: an open of what the activity does not hold, an act without its words or time', async ({ page }) => {
await platform(page, null, [
{ ...ROWS[0]!, source: 'https://evil.example/phish' },
{ ...ROWS[1]!, prompt: '' },
{ ...ROWS[2]!, every: 'whenever' },
{ ...ROWS[1]!, action: 'fill', title: 'Only typed' },
ROWS[1]!,
])
await open(page, { theme: 'dark' })
await expect(list(page).getByRole('listitem')).toHaveCount(1)
await expect(page.getByRole('link', { name: /opens the source/ })).toHaveAttribute('href', BUG)
// The one that binds, and the starters fill to three.
await expect(list(page).getByRole('listitem')).toHaveCount(3)
await expect(row(page, 'Fix the Kai mapping bug')).toHaveCount(1)
for (const gone of ['Check the login fixes on #29', 'Run the brief every Monday', 'Only typed']) await expect(row(page, gone)).toHaveCount(0)
})
test('signed out reads nothing and draws nothing', async ({ page }) => {
@@ -170,14 +190,23 @@ test('signed out reads nothing and draws nothing', async ({ page }) => {
test('on a phone, above the composer, a finger’s pitch, the × in sight; and in light', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await platform(page, { at: new Date().toISOString(), sig: 'x', rows: ROWS.map((r, n) => ({ ...r, id: `r${n}` })), dismissed: [] })
await platform(page, {
at: new Date().toISOString(),
sig: 'x',
rows: [
{ id: 'r0', icon: 'github', title: ROWS[0]!.title, summary: '', act: { kind: 'open', to: PR } },
{ id: 'r1', icon: 'agent', title: ROWS[1]!.title, summary: '', act: { kind: 'task', prompt: ROWS[1]!.prompt } },
{ id: 'r2', icon: 'agent', title: ROWS[2]!.title, summary: '', act: { kind: 'schedule', name: ROWS[2]!.title, prompt: ROWS[2]!.prompt, schedule: { kind: 'weekly', day: 'mon', at: '09:00' } } },
],
dismissed: [],
})
await open(page, { theme: 'light' })
const items = list(page).getByRole('listitem')
await expect(items).toHaveCount(3)
for (let n = 0; n < 3; n++) expect((await items.nth(n).boundingBox())!.height).toBeGreaterThanOrEqual(44)
const field = (await page.getByRole('textbox', { name: 'Message' }).boundingBox())!
expect((await items.nth(2).boundingBox())!.y).toBeLessThan(field.y)
await expect(page.getByRole('button', { name: 'Dismiss Monday brief' })).toHaveCSS('opacity', '1')
await expect(page.getByRole('button', { name: 'Dismiss Run the brief every Monday' })).toHaveCSS('opacity', '1')
const width = await page.evaluate(() => document.documentElement.scrollWidth)
expect(width).toBeLessThanOrEqual(390)
await shot(page, 'suggest-phone-light.png')
+3 -6
View File
@@ -1,8 +1,8 @@
/**
* The suggestions alone, over a stand-in for the composer, for suggest.spec.ts:
* `useSuggestions` against the platform the spec routes, `Suggestions` under the
* field (above it at a phone's width). A pick writes the prompt into the field
* and into `window.went`.
* field (above it at a phone's width). A press records the row's act in
* `window.went`, as JSON: what the product would do.
*
* `window.suggest` before the page loads: `theme`, and whether anyone is signed in.
*/
@@ -44,10 +44,7 @@ function Page() {
const list = (
<Suggestions
rows={s.rows}
onPick={(p) => {
setDraft(p)
window.went.push(`pick ${p}`)
}}
onAct={(act) => window.went.push(JSON.stringify(act))}
onDismiss={s.dismiss}
/>
)
+2 -2
View File
@@ -77,5 +77,5 @@ export { address, destination, located, name, PAGE, SEARCH, useDeck, useKey, use
* the person's own activity: `useSuggestions` reads and makes them, `Suggestions`
* draws them. The row is `Hint` here: `Suggestion` is the side panel's.
*/
export { Suggestions, useSuggestions, type Suggested, type SuggestionsProps } from './suggestions.tsx'
export type { Suggestion as Hint } from './suggest.ts'
export { book, hand, Suggestions, taken, useSuggestions, type Suggested, type SuggestionsProps } from './suggestions.tsx'
export type { Act, Suggestion as Hint } from './suggest.ts'
+47 -6
View File
@@ -37,7 +37,9 @@ import { Publish, type Source } from './publish.tsx'
import { path } from './route.ts'
import { useCarry } from './carry.tsx'
import { DEV_MODES } from './gear.ts'
import { Suggestions, useSuggestions } from './suggestions.tsx'
import { book, hand, Suggestions, taken, useSuggestions } from './suggestions.tsx'
import type { Act } from './suggest.ts'
import { want } from './library.tsx'
import { MEASURE, Prompt, useEar } from './prompt.tsx'
import { Monitor as Device } from '@hanzogui/lucide-icons-2'
import { compose, Files, pick, read as readFiles, type Attached } from './tools.tsx'
@@ -216,9 +218,9 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
)
// The composer sends only a draft with words in it, and not while a send is out.
const send = async () => {
const send = async (said: string = draft) => {
if (!signed) {
hold({ draft, files, sent: true })
hold({ draft: said, files, sent: true })
host.signIn?.()
return
}
@@ -244,7 +246,7 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
setNote('')
try {
// What the plugins that are on add: words for the run, or an answer of their own.
const prepped = carry.preparing ? await carry.prep({ text: draft.trim(), files: [], thread: '' }, new AbortController().signal) : {}
const prepped = carry.preparing ? await carry.prep({ text: said.trim(), files: [], thread: '' }, new AbortController().signal) : {}
// An answer of a plugin's own (a deck) is the answer: no run is started for it.
if (prepped.reply) {
// Its words, without the page it drew (Chat shows that in the side panel).
@@ -257,7 +259,7 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
return
}
const run = await start(t, {
prompt: compose([draft.trim(), carry.text, prepped.system ?? ''].filter(Boolean).join('\n\n---\n'), files),
prompt: compose([said.trim(), carry.text, prepped.system ?? ''].filter(Boolean).join('\n\n---\n'), files),
// The name alone. The org rides the request, and a slash is not a repo name.
// An empty base or place is not sent: the default branch, and the sandbox.
repo: repo?.name ?? hub?.full_name,
@@ -289,6 +291,45 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signed])
// A run a suggestion in Chat left for Dev starts on arrival, once its person is known.
const [handed] = useState(() => ({ prompt: taken('task') }))
useEffect(() => {
if (!signed || !handed.prompt) return
const p = handed.prompt
handed.prompt = ''
setDraft(p)
void send(p)
// Started once, on the sign-in that it waited for.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signed])
/** What pressing a suggestion does: open what it names, chat its prompt, start its run, or make its scheduled task. */
const act = (a: Act) => {
switch (a.kind) {
case 'open':
if (a.item) want(a.item)
if (/^https:\/\//.test(a.to)) window.open(a.to, '_blank', 'noopener,noreferrer')
else if (a.to.startsWith('/')) host.open(a.to)
else host.go(a.to)
return
case 'send':
hand('send', a.prompt)
host.open('/chat')
return
case 'task':
setDraft(a.prompt)
void send(a.prompt)
return
case 'schedule':
setNote('')
void book(t, a, 'dev').then(
(made) => host.go(path({ kind: 'automation', id: made.id })),
(e: unknown) => setNote(e instanceof Error && e.message ? e.message : 'The scheduled task could not be made.'),
)
return
}
}
/** Start the agent that finds a codebase's environment, and open it: its run is the progress. A refusal is the dialog's to say. */
const setup = async (repo: string) => {
const run = await start(t, { prompt: SETUP, repo, mode: 'setup', model: mind.model, effort: mind.effort })
@@ -474,7 +515,7 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
at={carry.plugins}
mode={carry.mode}
where={where}
below={<Suggestions rows={hints.rows} onPick={setDraft} onDismiss={hints.dismiss} />}
below={<Suggestions rows={hints.rows} onAct={act} onDismiss={hints.dismiss} />}
mind={mind}
limits={limits}
onUpgrade={() => host.go(path({ kind: 'screen', screen: 'plans' }))}
+8 -1
View File
@@ -148,6 +148,9 @@ export function Dictation({ voice, disabled = false, narrow = false, language =
const { inputs, allowed, using } = useInputs(open)
const [device, setDevice] = useState<string | null>(() => (typeof window === 'undefined' ? null : kept()))
const spoken = LANGUAGES.find((l) => l.id === language)
// The chevron is drawn while the mic is reached for (pointer, keyboard, menu open); at rest the row is the mic alone.
const [near, setNear] = useState(false)
const shown = open || near
return (
<XStack
data-slot="dictation"
@@ -157,6 +160,10 @@ export function Dictation({ voice, disabled = false, narrow = false, language =
borderColor={open ? '$borderColor' : 'transparent'}
hoverStyle={{ borderColor: '$borderColor' }}
shrink={0}
onPointerEnter={() => setNear(true)}
onPointerLeave={() => setNear(false)}
onFocus={() => setNear(true)}
onBlur={() => setNear(false)}
>
<Control asChild size={narrow ? 44 : 30} {...PLAIN}>
<Voice voice={voice} disabled={disabled} says={{ idle: 'Dictate', listening: 'Dictating — click to stop' }}>
@@ -179,7 +186,7 @@ export function Dictation({ voice, disabled = false, narrow = false, language =
hoverStyle={{ bg: '$hover' }}
{...(narrow ? { width: 44, height: 44 } : null)}
>
<ChevronDown size={14} color="$aside" />
<ChevronDown size={14} color="$soft" opacity={shown || narrow ? 1 : 0} />
</XStack>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" minW={200} data-slot="dictation-panel">
+4 -2
View File
@@ -537,14 +537,16 @@ export function prompt(o: PromptOptions, state?: { index: number; closed: string
disabled,
placeholder: o.placeholder ?? ASK,
...(o.label ? { label: o.label } : {}),
rows: 2,
// At rest two rows: one line of text, then the tools. It grows with the text to the kit's ceiling, then scrolls.
rows: 1,
field: {
...o.field,
fontSize: 16,
lineHeight: 24,
px: 8,
pt: 2,
minH: 48,
pb: 0,
minH: 26,
bg: 'transparent',
},
head,
+84 -45
View File
@@ -5,6 +5,7 @@ import { prefs } from './api/pref.ts'
import type { Session } from './api/sessions.ts'
import type { Issue } from './merge.ts'
import {
ACTIONS,
answer,
DAY,
dismissed,
@@ -15,6 +16,7 @@ import {
MODEL,
QUIET,
REMEMBER,
every,
request,
SCHEMA,
seed,
@@ -104,15 +106,19 @@ const flow = (o: Partial<Automation>): Automation => ({
const NONE = { conversations: [], issues: [], runs: [], automations: [], files: [], me: 'acme/u1' }
const SOME: Input[] = [
{ kind: 'pull', key: PR, title: 'Login redirects new emails to /signup', state: 'closed', text: 'hanzo-inc/app', href: PR, updated: '2026-10-20T08:00:00Z' },
{ kind: 'issue', key: BUG, title: 'Kai P0 mapping bug', state: 'todo', text: 'hanzo-inc/kai', href: BUG, updated: '2026-10-19T08:00:00Z' },
{ kind: 'conversation', key: 'c1', title: 'Enso sales targets', state: '', text: '', href: '', updated: '2026-10-18T08:00:00Z' },
{ kind: 'pull', key: PR, title: 'Login redirects new emails to /signup', state: 'closed', text: 'hanzo-inc/app', href: PR, to: PR, updated: '2026-10-20T08:00:00Z' },
{ kind: 'issue', key: BUG, title: 'Kai P0 mapping bug', state: 'todo', text: 'hanzo-inc/kai', href: BUG, to: BUG, updated: '2026-10-19T08:00:00Z' },
{ kind: 'conversation', key: 'c1', title: 'Enso sales targets', state: '', text: '', href: '', to: '/chat/c1', updated: '2026-10-18T08:00:00Z' },
{ kind: 'file', key: 'lib_1', title: 'poster.png', state: 'image', text: 'studio', href: '', to: '-/artifacts', item: 'lib_1', updated: '2026-10-17T08:00:00Z' },
]
/** A chat completion whose first choice says `text`. */
const said = (text: string) => ({ choices: [{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content: text } }] })
const rows = (...r: object[]) => said(JSON.stringify({ rows: r }))
const ROW = { icon: 'github', title: 'Hanzo app login fixes landed', href: PR, summary: 'PR #29 closed.', action: 'Verify the user flow', prompt: `Check the login flow ${PR} changed.` }
const OPEN = { icon: 'github', title: 'Check the login fixes on #29', summary: 'PR #29 closed.', action: 'open', source: PR, prompt: '', every: '' }
const SEND = { icon: 'chat', title: 'Draft the Enso sales email', summary: '', action: 'send', source: '', prompt: 'Draft the email to the Enso leads from our sales targets chat.', every: '' }
const TASK = { icon: 'agent', title: 'Fix the Kai mapping bug', summary: 'It drops rows.', action: 'task', source: '', prompt: `Fix ${BUG}: the mapping drops rows.`, every: '' }
const PLAN = { icon: 'agent', title: 'Brief me on PRs each morning', summary: '', action: 'schedule', source: '', prompt: 'Summarize my open pull requests.', every: 'weekdays 09:00' }
describe('what the model reads', () => {
it('is the person’s activity: their own runs, a GitHub address kept, the forge’s too, a draft automation left out', () => {
@@ -123,12 +129,12 @@ describe('what the model reads', () => {
automations: [flow({}), flow({ id: 'auto_2', draft: true })],
conversations: [{ id: 'c1', title: 'Enso sales targets', updated: '2026-10-20T07:00:00Z', pinned: false }],
})
expect(got.map((i) => [i.kind, i.key, i.state, i.href])).toEqual([
['conversation', 'c1', '', ''],
['issue', BUG, 'todo', BUG],
['run', 'sess_mine', 'done', PR],
['automation', 'auto_1', 'on', ''],
['issue', FORGE, 'closed', FORGE],
expect(got.map((i) => [i.kind, i.key, i.state, i.href, i.to])).toEqual([
['conversation', 'c1', '', '', '/chat/c1'],
['issue', BUG, 'todo', BUG, BUG],
['run', 'sess_mine', 'done', PR, 'sess_mine'],
['automation', 'auto_1', 'on', '', '-/automations/auto_1'],
['issue', FORGE, 'closed', FORGE, FORGE],
])
expect(got.find((i) => i.key === BUG)?.text).toBe('hanzo-inc/kai: The mapping drops rows.')
expect(got.find((i) => i.kind === 'automation')?.text).toBe('Brief me every Monday on adoption signals.')
@@ -157,7 +163,7 @@ describe('what the model reads', () => {
})
describe('the request', () => {
it('asks Hanzo’s model under a strict schema, and nothing when there is nothing', () => {
it('asks Hanzo’s model under a strict schema, each item by its key, and nothing when there is nothing', () => {
expect(request([], {}, [], new Date('2026-10-20T00:00:00Z'))).toBeNull()
const r = request(SOME, { role: 'Software engineer' }, [BUG], new Date('2026-10-20T00:00:00Z'))!
expect(r.model).toBe(MODEL)
@@ -166,7 +172,12 @@ describe('the request', () => {
expect(user.today).toBe('2026-10-20')
expect(user.person).toEqual({ role: 'Software engineer' })
expect(user.skip).toEqual([BUG])
expect(user.activity[0]).toEqual({ kind: 'pull', title: 'Login redirects new emails to /signup', state: 'closed', text: 'hanzo-inc/app', href: PR, updated: '2026-10-20' })
expect(user.activity[0]).toEqual({ key: PR, kind: 'pull', title: 'Login redirects new emails to /signup', state: 'closed', text: 'hanzo-inc/app', updated: '2026-10-20' })
})
it('offers the model the four actions and nothing else', () => {
expect(SCHEMA.properties.rows.items.properties.action.enum).toEqual([...ACTIONS])
expect(ACTIONS).toEqual(['open', 'send', 'task', 'schedule'])
})
it('seeds an account with no activity from signup alone', () => {
@@ -176,38 +187,48 @@ describe('the request', () => {
})
describe('the answer', () => {
it('is the rows, each with its source’s icon and a stable id', () => {
const got = answer(rows(ROW, { ...ROW, icon: 'chat', title: 'Kai P0 mapping bug is open', href: BUG, action: 'Audit the fix path.' }), SOME)
expect(got).toEqual([
{ id: hash(PR), icon: 'github', title: 'Hanzo app login fixes landed', href: PR, summary: 'PR #29 closed.', action: 'Verify the user flow', prompt: ROW.prompt },
{ id: hash(BUG), icon: 'github', title: 'Kai P0 mapping bug is open', href: BUG, summary: 'PR #29 closed.', action: 'Audit the fix path', prompt: ROW.prompt },
it('binds an open to the item its key names: where it opens, its mark and a stable id', () => {
expect(answer(rows(OPEN), SOME)).toEqual([{ id: hash(PR), icon: 'github', title: OPEN.title, summary: 'PR #29 closed.', act: { kind: 'open', to: PR } }])
expect(answer(rows({ ...OPEN, icon: 'github', source: 'c1' }), SOME)[0]).toMatchObject({ icon: 'chat', act: { kind: 'open', to: '/chat/c1' } })
expect(answer(rows({ ...OPEN, source: 'lib_1' }), SOME)[0]).toMatchObject({ icon: 'file', act: { kind: 'open', to: '-/artifacts', item: 'lib_1' } })
})
it('binds a send and a task to their prompt', () => {
expect(answer(rows(SEND), SOME)[0]!.act).toEqual({ kind: 'send', prompt: SEND.prompt })
expect(answer(rows(TASK), SOME)[0]!.act).toEqual({ kind: 'task', prompt: TASK.prompt })
})
it('binds a schedule to its prompt and a time it reads', () => {
expect(answer(rows(PLAN), SOME)[0]!.act).toEqual({ kind: 'schedule', name: PLAN.title, prompt: PLAN.prompt, schedule: { kind: 'weekdays', at: '09:00' } })
})
it('drops a row it cannot bind: an open naming no item, an act with no prompt, a time it cannot read, an action it does not know', () => {
expect(answer(rows({ ...OPEN, source: 'https://github.com/elsewhere/x/pull/1' }), SOME)).toEqual([])
expect(answer(rows({ ...OPEN, source: '' }), SOME)).toEqual([])
expect(answer(rows({ ...SEND, prompt: ' ' }), SOME)).toEqual([])
expect(answer(rows({ ...TASK, prompt: '' }), SOME)).toEqual([])
expect(answer(rows({ ...PLAN, every: 'every Monday morning' }), SOME)).toEqual([])
expect(answer(rows({ ...SEND, action: 'fill' }), SOME)).toEqual([])
expect(answer(rows({ ...SEND, title: '' }), SOME)).toEqual([])
})
it('draws a row that opens nothing with its mark as said, chat for an unknown one, never GitHub’s', () => {
const got = answer(rows(TASK, { ...SEND, icon: 'robot' }, { ...SEND, title: 'Ask about the issue', icon: 'github' }), SOME)
expect(got.map((r) => [r.icon, r.id])).toEqual([
['agent', idOf('', TASK.title)],
['chat', idOf('', SEND.title)],
['task', idOf('', 'Ask about the issue')],
])
})
it('drops a row whose address is not an input’s, or that lacks a field', () => {
expect(answer(rows({ ...ROW, href: 'https://github.com/elsewhere/x/pull/1' }), SOME)).toEqual([])
expect(answer(rows({ ...ROW, href: 'javascript:alert(1)' }), SOME)).toEqual([])
expect(answer(rows({ ...ROW, prompt: ' ' }), SOME)).toEqual([])
expect(answer(rows({ ...ROW, action: '' }), SOME)).toEqual([])
})
it('keeps a row with no address, its icon as said, chat for an unknown one, never GitHub’s mark', () => {
const got = answer(rows({ ...ROW, href: '', icon: 'agent', title: 'Monday brief' }, { ...ROW, href: '', icon: 'robot', title: 'Other' }, { ...ROW, href: '', icon: 'github', title: 'Issue' }), SOME)
expect(got.map((r) => [r.icon, r.href, r.id])).toEqual([
['agent', '', idOf('', 'Monday brief')],
['chat', '', idOf('', 'Other')],
['task', '', idOf('', 'Issue')],
])
})
it('is one row a source, three at most', () => {
expect(answer(rows(ROW, ROW), SOME)).toHaveLength(1)
const four = ['a', 'b', 'c', 'd'].map((t) => ({ ...ROW, href: '', title: t }))
it('is one row a thing, three at most', () => {
expect(answer(rows(OPEN, OPEN), SOME)).toHaveLength(1)
const four = ['a', 'b', 'c', 'd'].map((t) => ({ ...SEND, title: t }))
expect(answer(rows(...four), SOME).map((r) => r.title)).toEqual(['a', 'b', 'c'])
})
it('reads JSON after a model’s preamble, and none from prose, an error or no answer', () => {
expect(answer(said(`Here you go:\n${JSON.stringify({ rows: [ROW] })}\n`), SOME)).toHaveLength(1)
expect(answer(said(`Here you go:\n${JSON.stringify({ rows: [SEND] })}\n`), SOME)).toHaveLength(1)
expect(answer(said('Nothing to suggest.'), SOME)).toEqual([])
expect(answer(said('{"rows": []}'), SOME)).toEqual([])
expect(answer({ error: { message: 'no' } }, SOME)).toEqual([])
@@ -215,6 +236,16 @@ describe('the answer', () => {
})
})
describe('every', () => {
it('reads the four ways a schedule is said, and nothing else', () => {
expect(every('hourly')).toEqual({ kind: 'hourly', at: '00:00' })
expect(every('daily 07:30')).toEqual({ kind: 'daily', at: '07:30' })
expect(every(' Weekdays 09:00 ')).toEqual({ kind: 'weekdays', at: '09:00' })
expect(every('weekly mon 09:00')).toEqual({ kind: 'weekly', day: 'mon', at: '09:00' })
for (const no of ['', 'daily', 'daily 9am', 'daily 24:00', 'weekly monday 09:00', 'weekly mon', 'hourly 10:00', 'cron * * * * *']) expect(every(no)).toBeNull()
})
})
describe('the fingerprint and when rows are made again', () => {
it('moves with a new source or a new state, not with order or time', () => {
const a = sig(SOME, {})
@@ -245,14 +276,16 @@ describe('the fingerprint and when rows are made again', () => {
})
describe('what the account keeps', () => {
const row = { id: 'r1', icon: 'github', title: 'T', href: PR, summary: 'S', action: 'A', prompt: 'P' }
const row: Kept['rows'][number] = { id: 'r1', icon: 'github', title: 'T', summary: 'S', act: { kind: 'open', to: PR } }
const plan: Kept['rows'][number] = { id: 'r3', icon: 'agent', title: 'P', summary: '', act: { kind: 'schedule', name: 'P', prompt: 'Brief me.', schedule: { kind: 'weekly', day: 'mon', at: '09:00' } } }
it('reads the suggest key, a row that is not one dropped', () => {
it('reads the suggest key, a row that does nothing dropped (one from before rows were actions, among them)', () => {
expect(kept(undefined)).toBeUndefined()
expect(kept({ at: '2026-10-20T00:00:00Z', sig: 'x', rows: [row, { ...row, href: 'http://x' }, { ...row, prompt: '' }, 'junk'], dismissed: ['r9', 3] })).toEqual({
const before = { id: 'r0', icon: 'github', title: 'T', href: PR, summary: 'S', action: 'A', prompt: 'P' }
expect(kept({ at: '2026-10-20T00:00:00Z', sig: 'x', rows: [row, plan, before, { ...row, act: { kind: 'send', prompt: '' } }, 'junk'], dismissed: ['r9', 3] })).toEqual({
at: '2026-10-20T00:00:00Z',
sig: 'x',
rows: [row],
rows: [row, plan],
dismissed: ['r9'],
})
})
@@ -263,7 +296,7 @@ describe('what the account keeps', () => {
})
it('draws the rows not dismissed, and remembers a dismissal first', () => {
const k: Kept = { at: 'a', sig: 's', rows: [row as Kept['rows'][number], { ...(row as Kept['rows'][number]), id: 'r2', href: BUG }], dismissed: [] }
const k: Kept = { at: 'a', sig: 's', rows: [row, { ...row, id: 'r2', act: { kind: 'open', to: BUG } }], dismissed: [] }
expect(shown(k).map((r) => r.id)).toEqual(['r1', 'r2'])
const after = dismissed(k, 'r1')
expect(shown(after).map((r) => r.id)).toEqual(['r2'])
@@ -271,8 +304,14 @@ describe('what the account keeps', () => {
expect(shown(null)).toEqual([])
})
it('starts with three that each do something at once', () => {
expect(STARTERS).toHaveLength(3)
for (const r of STARTERS) expect(['send', 'task']).toContain(r.act.kind)
for (const r of STARTERS) expect(r.title.split(' ').length).toBeLessThanOrEqual(7)
})
it('fills to three with what Hanzo does, never past a dismissal, and none before the rows are read', () => {
const one: Kept = { at: 'a', sig: 's', rows: [row as Kept['rows'][number]], dismissed: [] }
const one: Kept = { at: 'a', sig: 's', rows: [row], dismissed: [] }
expect(fill(one).map((r) => r.id)).toEqual(['r1', STARTERS[0]!.id, STARTERS[1]!.id])
expect(fill({ ...one, rows: [] }).map((r) => r.id)).toEqual(STARTERS.map((r) => r.id))
expect(fill(dismissed(one, STARTERS[0]!.id)).map((r) => r.id)).toEqual(['r1', STARTERS[1]!.id, STARTERS[2]!.id])
@@ -287,8 +326,8 @@ describe('what the account keeps', () => {
expect(k.dismissed).not.toContain('r0')
})
it('tells the model to skip what was dismissed', () => {
const k: Kept = { at: 'a', sig: 's', rows: [{ id: idOf('', 'Old idea'), icon: 'chat', title: 'Old idea', href: '', summary: '', action: 'A', prompt: 'P' }], dismissed: [hash(BUG), idOf('', 'Old idea')] }
it('tells the model to skip what was dismissed: an item by its key, anything else by its words', () => {
const k: Kept = { at: 'a', sig: 's', rows: [{ id: idOf('', 'Old idea'), icon: 'chat', title: 'Old idea', summary: '', act: { kind: 'send', prompt: 'P' } }], dismissed: [hash(BUG), idOf('', 'Old idea')] }
expect(skip(k, SOME)).toEqual([BUG, 'Old idea'])
expect(skip(null, SOME)).toEqual([])
})
+134 -61
View File
@@ -1,7 +1,8 @@
/**
* Up to three suggestions under the composer, each one person's own: what
* happened in their recent work, linked to where it happened, and the next step
* as a prompt the composer takes.
* Three suggestions under the composer, each one person's own and each a thing
* done at once when pressed (`Act`): open what it names (a conversation, a run,
* a Studio creation, an item, an issue), send a new chat, start a Dev run, or
* schedule a task. None only fills the composer.
*
* Nothing here is written by hand. The rows are generated by Hanzo's own model
* (`MODEL`, POST /v1/chat/completions with the person's bearer, so the call is
@@ -24,7 +25,7 @@
* This file is the pure half: inputs to a request, an answer to validated rows,
* the fingerprint and the policy. suggestions.tsx reads the sources and draws them.
*/
import type { Automation } from './api/auto.ts'
import type { Automation, Schedule } from './api/auto.ts'
import type { Conversation } from './api/conversations.ts'
import type { Item } from './api/library.ts'
import type { Session } from './api/sessions.ts'
@@ -33,21 +34,32 @@ import type { Issue } from './merge.ts'
export type Icon = 'github' | 'agent' | 'chat' | 'file' | 'task'
export const ICONS: readonly Icon[] = ['github', 'agent', 'chat', 'file', 'task']
/** One row: where it came from, what happened, and what to do next. */
/**
* What pressing a row does, at once:
*
* open the thing it names: `to` is an app address (`/chat/<id>`,
* `/studio/c/<id>`), a builder path (`sess_…`, `-/automations/<id>`,
* `-/artifacts` with `item`), or another site's (a new tab)
* send a new chat, asked `prompt`
* task a new Dev run on `prompt`
* schedule a scheduled task, made and shown
*/
export type Act =
| { kind: 'open'; to: string; item?: string }
| { kind: 'send'; prompt: string }
| { kind: 'task'; prompt: string }
| { kind: 'schedule'; name: string; prompt: string; schedule: Schedule }
/** One row: one short action in plain words, and what pressing it does. */
export interface Suggestion {
/** Stable across generations: the source's address, or the title for a row with none. */
/** Stable across generations: what it opens, or its words. */
id: string
icon: Icon
/** What happened, a few words: "Hanzo app login fixes landed". */
/** The action, a few plain words: "Review poster.png in Studio". */
title: string
/** The source's own https address, copied from an input, or '' for none. */
href: string
/** What the source says, a sentence or three: the hover card. */
summary: string
/** The next step, a few words: "Verify the user flow". */
action: string
/** What the composer is given when the row is chosen. */
prompt: string
act: Act
}
/** One piece of the person's activity, as the model is shown it. */
@@ -62,6 +74,10 @@ export interface Input {
text: string
/** Its own https address, or ''. */
href: string
/** Where opening it goes (`Act` open's `to`), or '' when nothing opens it. */
to: string
/** A Library item's id, for an item the Library opens at. */
item?: string
/** RFC 3339, or ''. */
updated: string
}
@@ -167,7 +183,7 @@ const first = (s: string): string => s.split('\n').find((l) => l.trim()) ?? ''
/** The person's activity as inputs: their own runs only, each kind's newest `TAKE`. */
export function inputs(a: Activity): Input[] {
const all: Input[] = [
...a.conversations.map((c) => ({ kind: 'conversation' as const, key: c.id, title: c.title, state: '', text: '', href: '', updated: c.updated })),
...a.conversations.map((c) => ({ kind: 'conversation' as const, key: c.id, title: c.title, state: '', text: '', href: '', to: `/chat/${encodeURIComponent(c.id)}`, updated: c.updated })),
...a.issues.map((i) => ({
kind: i.pull ? ('pull' as const) : ('issue' as const),
key: i.key,
@@ -175,6 +191,7 @@ export function inputs(a: Activity): Input[] {
state: i.state === 'closed' ? 'closed' : i.status || 'open',
text: [i.repo || i.forge || i.board, first(i.body)].filter(Boolean).join(': '),
href: https(i.url),
to: https(i.url),
updated: i.updated,
})),
...a.runs
@@ -186,6 +203,7 @@ export function inputs(a: Activity): Input[] {
state: r.status,
text: [r.repo, r.branch].filter(Boolean).join(' @ '),
href: https(r.pr),
to: r.id,
updated: r.updatedAt || r.createdAt,
})),
...a.automations
@@ -197,9 +215,11 @@ export function inputs(a: Activity): Input[] {
state: x.last?.status || (x.enabled ? 'on' : 'off'),
text: x.last?.summary || first(x.instructions),
href: '',
to: `-/automations/${x.id}`,
updated: x.last?.at || x.updated,
})),
...a.files.map((f) => ({ kind: 'file' as const, key: f.id, title: f.name, state: f.kind, text: f.source, href: '', updated: f.updated || f.created })),
// An item opens where it was made when that has an address (a Studio creation), else in the Library at it.
...a.files.map((f) => ({ kind: 'file' as const, key: f.id, title: f.name, state: f.kind, text: f.source, href: '', ...(f.origin.startsWith('/') ? { to: f.origin } : { to: '-/artifacts', item: f.id }), updated: f.updated || f.created })),
]
return pick(all)
}
@@ -277,6 +297,9 @@ export function iconOf(i: Input): Icon {
}
}
/** What a row does, as the model names it. */
export const ACTIONS = ['open', 'send', 'task', 'schedule'] as const
/** The answer's shape, decoded strictly by the model. */
export const SCHEMA = {
type: 'object',
@@ -289,14 +312,15 @@ export const SCHEMA = {
items: {
type: 'object',
additionalProperties: false,
required: ['icon', 'title', 'href', 'summary', 'action', 'prompt'],
required: ['icon', 'title', 'summary', 'action', 'source', 'prompt', 'every'],
properties: {
icon: { type: 'string', enum: [...ICONS] },
title: { type: 'string' },
href: { type: 'string' },
summary: { type: 'string' },
action: { type: 'string' },
action: { type: 'string', enum: [...ACTIONS] },
source: { type: 'string' },
prompt: { type: 'string' },
every: { type: 'string' },
},
},
},
@@ -304,18 +328,20 @@ export const SCHEMA = {
} as const
const SYSTEM = [
'You choose up to three next steps for one person, from their own recent activity, to show under the box where they type to Hanzo.',
'Each row names something that happened and the step it calls for. Pick what is most worth their attention now: something that landed and needs checking, something open and urgent, something they were in the middle of.',
'You choose three things for one person to do next with Hanzo, from their own recent activity, shown under the box where they type. Pressing a row DOES it at once, so every row is an action, never a draft.',
'Each row has one action:',
'- open: open the thing an activity item names (a conversation to pick up, a run to check, a creation or file to review, an issue to read). source is that item\'s key, copied exactly.',
'- send: start a new chat and ask prompt. Use it for a question or a piece of work a chat answers (a summary, a draft, research).',
'- task: start a Dev coding run on prompt, in the person\'s current project. Use it for a change to code.',
'- schedule: make a scheduled task that runs prompt every `every`. every is one of: hourly · daily HH:MM · weekdays HH:MM · weekly mon|tue|wed|thu|fri|sat|sun HH:MM (24-hour).',
'Fields:',
'- title: what happened, at most 6 words, naming the thing ("Hanzo app login fixes landed", "Kai P0 mapping bug is open").',
'- action: the next step, imperative, at most 6 words ("Verify the user flow").',
'- summary: one to three plain sentences of what the source says. Only facts from the input; never invent numbers, names or dates.',
'- prompt: the message the person would send Hanzo to do the step, first person, complete on its own, naming the source and its address when it has one.',
'- href: copied exactly from the input the row is about, or "" when that input has none. Never write an address that is not in the input.',
'- icon: github for a github.com address, agent for a run or an automation, chat for a conversation, file for a file, task for any other issue or task.',
'Answer three rows. Take them from the activity first; fill the rest from what the person told us at signup (their role, their work): first things to ask Hanzo, with href "".',
'Never repeat an item listed under skip. Never suggest the same item twice.',
'With neither activity nor anything from signup worth a row, answer fewer; never pad with a weak one.',
'- title: the action itself, plain words, at most 7, naming the thing: "Review poster.png in Studio", "Continue the physics chat", "Pick up the Oct 10 hello thread". Never a label and an action glued together.',
'- summary: one or two plain sentences of what the source says, for the hover card. Only facts from the input; never invent numbers, names or dates.',
'- source: for open, the key of the activity item; else "".',
'- prompt: for send, task and schedule, the complete message, first person, naming its source; for open, "".',
'- every: for schedule; else "".',
'- icon: github for a github.com issue or pull request, agent for a run, a task or a schedule, chat for a conversation or a send, file for a file or creation, task for any other issue.',
'Answer three rows: the activity first, then from what the person told us at signup (their role, their work). A row you cannot bind to one of the actions is no row. Never repeat an item under skip, and never the same item twice.',
].join('\n')
/** The chat completion that asks for rows, or null when there is nothing to make them from. */
@@ -323,11 +349,11 @@ export function request(inputs: Input[], seed: Seed, skip: string[], now: Date):
const person = Object.fromEntries(Object.entries(seed).filter(([, v]) => typeof v === 'string' && v.trim()))
if (!inputs.length && !Object.keys(person).length) return null
const activity = inputs.map((i) => ({
key: i.key,
kind: i.kind,
title: line(i.title, 140),
...(i.state ? { state: i.state } : {}),
...(i.text ? { text: line(i.text, 240) } : {}),
href: i.href,
...(i.updated ? { updated: i.updated.slice(0, 10) } : {}),
}))
return {
@@ -342,6 +368,18 @@ export function request(inputs: Input[], seed: Seed, skip: string[], now: Date):
}
}
const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/
/** `every` as a schedule, or null for words that are not one. */
export function every(v: string): Schedule | null {
const [kind, a, b] = v.trim().toLowerCase().split(/\s+/)
if (kind === 'hourly' && !a) return { kind: 'hourly', at: '00:00' }
if ((kind === 'daily' || kind === 'weekdays') && a && HHMM.test(a) && !b) return { kind, at: a }
if (kind === 'weekly' && a && DAYS.includes(a) && b && HHMM.test(b)) return { kind: 'weekly', day: a, at: b }
return null
}
/** The first JSON object in a model's text: all of it, or from its first `{` to its last `}`. */
function json(text: string): unknown {
try {
@@ -365,54 +403,89 @@ export function content(raw: unknown): string {
return str(obj(first.message).content)
}
/** A row's id: its address, or for a row without one, its title. */
export const idOf = (href: string, title: string): string => hash(href || title.toLowerCase())
/** A row's id: what it opens, or its words. */
export const idOf = (to: string, title: string): string => hash(to || title.toLowerCase())
/** A model's row bound to what pressing it does, or null for one that binds to nothing. */
function bind(o: Record<string, unknown>, inputs: Input[]): Act | null {
const prompt = str(o.prompt).trim().slice(0, 2000)
switch (str(o.action)) {
case 'open': {
const i = inputs.find((x) => x.key === str(o.source).trim())
if (!i?.to) return null
return i.item ? { kind: 'open', to: i.to, item: i.item } : { kind: 'open', to: i.to }
}
case 'send':
case 'task':
return prompt ? { kind: str(o.action) as 'send' | 'task', prompt } : null
case 'schedule': {
const when = every(str(o.every))
return prompt && when ? { kind: 'schedule', name: line(o.title, 80), prompt, schedule: when } : null
}
}
return null
}
/** What a row is about, for its mark: the input it opens, if it opens one. */
const about = (act: Act, inputs: Input[]): Input | undefined => (act.kind === 'open' ? inputs.find((i) => i.to === act.to && i.item === act.item) : undefined)
/**
* The rows of a chat completion, validated: each with every field, an address
* that is one of the inputs' own (or none), its icon from that input, one row
* per source, at most `MOST`. Anything else is dropped, never repaired.
* The rows of a chat completion, validated: each with words and an action that
* binds (an open names an input's key; a send, a task or a schedule carries its
* prompt, and a schedule a time it reads), one row per thing, at most `MOST`.
* Anything else is dropped, never repaired.
*/
export function answer(raw: unknown, inputs: Input[]): Suggestion[] {
const by = new Map(inputs.filter((i) => i.href).map((i) => [i.href, i]))
const rows = obj(json(content(raw))).rows
const out: Suggestion[] = []
for (const r of Array.isArray(rows) ? rows : []) {
const o = obj(r)
const title = line(o.title, 80)
const action = line(o.action, 80).replace(/\.$/, '')
const prompt = str(o.prompt).trim().slice(0, 2000)
if (!title || !action || !prompt) continue
const said = str(o.href).trim()
const source = said ? by.get(said) : undefined
if (said && !source) continue
// GitHub's mark only beside GitHub's own address: a row without one is a task at most.
const title = line(o.title, 80).replace(/\.$/, '')
const act = bind(o, inputs)
if (!title || !act) continue
const source = about(act, inputs)
// GitHub's mark only beside GitHub's own address: anything else is a task at most.
const named = ICONS.includes(o.icon as Icon) ? (o.icon as Icon) : 'chat'
const icon = source ? iconOf(source) : named === 'github' ? 'task' : named
const id = idOf(said, title)
const id = idOf(act.kind === 'open' ? `${act.to}${act.item ?? ''}` : '', title)
if (out.some((x) => x.id === id)) continue
out.push({ id, icon, title: title.replace(/\.$/, ''), href: said, summary: line(o.summary, 600), action, prompt })
out.push({ id, icon, title, summary: line(o.summary, 600), act })
if (out.length === MOST) break
}
return out
}
/** One kept row, or null for one that does not read as a row. */
/** A kept action, or null for one that does not read as one. */
function actOf(raw: unknown): Act | null {
const o = obj(raw)
const prompt = str(o.prompt)
switch (str(o.kind)) {
case 'open':
return str(o.to) ? { kind: 'open', to: str(o.to), ...(str(o.item) ? { item: str(o.item) } : {}) } : null
case 'send':
case 'task':
return prompt ? { kind: str(o.kind) as 'send' | 'task', prompt } : null
case 'schedule': {
const sc = obj(o.schedule)
const kind = str(sc.kind) as Schedule['kind']
return prompt && kind ? { kind: 'schedule', name: str(o.name), prompt, schedule: { kind, ...(str(sc.at) ? { at: str(sc.at) } : {}), ...(str(sc.day) ? { day: str(sc.day) } : {}) } } : null
}
}
return null
}
/** One kept row, or null for one that does not read as a row (a row from before rows were actions, among them). */
function row(raw: unknown): Suggestion | null {
const o = obj(raw)
const title = str(o.title)
const action = str(o.action)
const prompt = str(o.prompt)
const href = str(o.href)
if (!title || !action || !prompt || (href && https(href) !== href)) return null
const act = actOf(o.act)
if (!title || !act) return null
return {
id: str(o.id) || idOf(href, title),
id: str(o.id) || idOf(act.kind === 'open' ? act.to : '', title),
icon: ICONS.includes(o.icon as Icon) ? (o.icon as Icon) : 'chat',
title,
href,
summary: str(o.summary),
action,
prompt,
act,
}
}
@@ -431,11 +504,11 @@ export function kept(raw: unknown): Kept | undefined {
/** The rows to draw: those not dismissed. */
export const shown = (k: Kept | null | undefined): Suggestion[] => (k ? k.rows.filter((r) => !k.dismissed.includes(r.id)).slice(0, MOST) : [])
/** What Hanzo does, three ways in: what fills the rows a person's activity and signup do not. */
/** What Hanzo does, three ways in, each done at once: what fills the rows a person's activity and signup do not. */
export const STARTERS: readonly Suggestion[] = [
{ id: 'start:chat', icon: 'chat', title: 'Think something through', href: '', summary: '', action: 'Ask Hanzo', prompt: 'Help me think through ' },
{ id: 'start:dev', icon: 'agent', title: 'Build an app or a site', href: '', summary: '', action: 'Describe it', prompt: 'Build me ' },
{ id: 'start:research', icon: 'file', title: 'Research a topic', href: '', summary: '', action: 'Get a cited brief', prompt: 'Research this and give me a short brief with sources: ' },
{ id: 'start:chat', icon: 'chat', title: 'Ask what Hanzo can do for you', summary: '', act: { kind: 'send', prompt: 'What can you do for me? Give me three concrete things to try today.' } },
{ id: 'start:dev', icon: 'agent', title: 'Build a landing page with Dev', summary: '', act: { kind: 'task', prompt: 'Build a one-page landing site for my project and give me the preview link.' } },
{ id: 'start:research', icon: 'file', title: 'Get a cited brief on AI agents', summary: '', act: { kind: 'send', prompt: 'Research the state of AI coding agents this month and give me a short brief with sources.' } },
]
/** The kept rows, filled to `MOST` with the starters not dismissed: never one lonely row. */
@@ -455,7 +528,7 @@ export function skip(k: Kept | null | undefined, inputs: Input[]): string[] {
if (!k) return []
const gone = new Set(k.dismissed)
const out = new Set<string>()
for (const i of inputs) if (i.href && gone.has(idOf(i.href, ''))) out.add(i.href)
for (const r of k.rows) if (gone.has(r.id)) out.add(r.href || r.title)
for (const i of inputs) if (i.to && gone.has(idOf(`${i.to}${i.item ?? ''}`, ''))) out.add(i.key)
for (const r of k.rows) if (gone.has(r.id)) out.add(r.title)
return [...out]
}
+97 -81
View File
@@ -1,8 +1,9 @@
/**
* The suggestions under the composer (above it on a phone): up to three rows,
* each the source's mark, what happened linked to where it happened (its
* summary on hover or focus), then the next step. Choosing a row puts its
* prompt in the composer; its × dismisses it, and the account remembers.
* each the source's mark and one short action, its summary on hover. Pressing
* a row DOES it (suggest.ts `Act`): it opens what it names, sends its prompt as
* a new chat, starts its run, or makes its scheduled task. Nothing only fills
* the composer. Its × dismisses it, and the account remembers.
*
* `useSuggestions` reads the kept rows from the account (`suggest` in /v1/pref)
* and, when they are due (suggest.ts `due`), reads the person's activity and
@@ -18,13 +19,14 @@
* POST /v1/iam/preferences {} what signup kept (`onboarding`)
* POST /v1/chat/completions the rows, as `MODEL` writes them
* PATCH /v1/pref {suggest} the rows kept, and each dismissal
* POST /v1/auto/automations a `schedule` row's task
*/
import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui'
import { ArrowUpRight, Bot, CalendarClock, FileText, Github, MessageSquare, X } from '@hanzogui/lucide-icons-2'
import { Bot, CalendarClock, FileText, Github, MessageSquare, X } from '@hanzogui/lucide-icons-2'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@hanzo/ui'
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { automations } from './api/auto.ts'
import { automations, create, type Automation, type Source } from './api/auto.ts'
import { call, Refusal, unwrap, type Target } from './api/call.ts'
import { conversations } from './api/conversations.ts'
import { items } from './api/library.ts'
@@ -32,8 +34,7 @@ import { read, save, type Prefs } from './api/pref.ts'
import { list } from './api/sessions.ts'
import { everything } from './api/work.ts'
import { claims } from './claims.ts'
import { Out } from './out.tsx'
import { answer, dismissed, due, fill, inputs, request, seed, sig, skip, stale, TAKE, type Icon, type Input, type Kept, type Seed, type Suggestion } from './suggest.ts'
import { answer, dismissed, due, fill, inputs, request, seed, sig, skip, stale, TAKE, type Act, type Icon, type Input, type Kept, type Seed, type Suggestion } from './suggest.ts'
/** A source that refuses this person (a 4xx other than an expired sign-in or a rate) has nothing for them; any other failure leaves it unknown. */
function soft<T>(p: Promise<T>, none: T): Promise<T> {
@@ -151,6 +152,50 @@ export function useSuggestions(t: Target): Suggested {
return useMemo(() => ({ rows, dismiss, error }), [rows, dismiss, error])
}
/** Where a row leaves its act for the product that does it. */
const HAND = 'hanzo.suggest.hand'
/**
* Leave `prompt` for the product `kind` names, which does it on arrival, once:
* Chat sends a `send` as a new chat, Dev starts a `task` as a run. Same-tab
* session storage, written by the person's own press, so an address can never
* carry one (a link is not consent to spend).
*/
export function hand(kind: 'send' | 'task', prompt: string): void {
try {
window.sessionStorage.setItem(HAND, JSON.stringify({ kind, prompt }))
} catch {
// Without storage the product opens without it.
}
}
/** The prompt left for `kind`, taken so it is done once, or ''. */
export function taken(kind: 'send' | 'task'): string {
try {
const v = JSON.parse(window.sessionStorage.getItem(HAND) ?? 'null') as { kind?: unknown; prompt?: unknown } | null
if (v?.kind !== kind || typeof v.prompt !== 'string') return ''
window.sessionStorage.removeItem(HAND)
return v.prompt.trim()
} catch {
return ''
}
}
/** Make a `schedule` row's task: on, asking before it acts, and notifying its person. */
export function book(t: Target, act: Extract<Act, { kind: 'schedule' }>, source: Source): Promise<Automation> {
return create(t, {
name: act.name || act.prompt.slice(0, 80),
instructions: act.prompt,
model: null,
schedule: { ...act.schedule, tz: Intl.DateTimeFormat().resolvedOptions().timeZone },
permissions: 'ask',
notify: true,
postTo: [],
enabled: true,
source,
})
}
const MARKS: Record<Icon, typeof Github> = {
github: Github,
agent: Bot,
@@ -164,74 +209,81 @@ const PITCH = 44
export interface SuggestionsProps {
rows: readonly Suggestion[]
/** A row was chosen: its prompt, for the composer. */
onPick: (prompt: string) => void
/** A row was pressed: do what it says. */
onAct: (act: Act, row: Suggestion) => void
onDismiss: (id: string) => void
}
/** The rows, or nothing when there are none. */
export function Suggestions({ rows, onPick, onDismiss }: SuggestionsProps) {
export function Suggestions({ rows, onAct, onDismiss }: SuggestionsProps) {
// A phone has no hover: its × stays in sight.
const touch = !useMedia().sm
if (!rows.length) return null
return (
<YStack role="list" aria-label="Suggestions" data-slot="suggestions">
{rows.map((r) => (
<Row key={r.id} row={r} touch={touch} onPick={onPick} onDismiss={onDismiss} />
<Row key={r.id} row={r} touch={touch} onAct={onAct} onDismiss={onDismiss} />
))}
</YStack>
)
}
/**
* One row. The whole row is one button, laid under the row's content (which is
* positioned after it, so it paints above, and lets presses through), and over
* it the title's link and the × are their own: three stops for the keyboard,
* none inside another.
*/
function Row({ row, touch, onPick, onDismiss }: { row: Suggestion; touch: boolean; onPick: (prompt: string) => void; onDismiss: (id: string) => void }) {
/** One row: the whole of it one button that does the act, and its × beside it. Two stops for the keyboard, neither inside the other. */
function Row({ row, touch, onAct, onDismiss }: { row: Suggestion; touch: boolean; onAct: (act: Act, row: Suggestion) => void; onDismiss: (id: string) => void }) {
const [near, setNear] = useState(false)
const Mark = MARKS[row.icon]
const button = (
<XStack
render="button"
data-act={row.act.kind}
aria-label={row.title}
onPress={() => onAct(row.act, row)}
flex={1}
minW={0}
self="stretch"
items="center"
gap="$3"
pl="$3"
rounded="$4"
cursor="pointer"
focusVisibleStyle={{ outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }}
>
<Mark size={18} color="$soft" />
<SizableText fontSize={15} color="$soft" numberOfLines={1} shrink={1} minW={0}>
{row.title}
</SizableText>
</XStack>
)
return (
<XStack
role="listitem"
data-slot="suggestion"
position="relative"
items="center"
gap="$3"
gap="$1"
minH={PITCH}
px="$3"
pr="$2"
rounded="$4"
hoverStyle={{ bg: '$hover' }}
onPointerEnter={() => setNear(true)}
onPointerLeave={() => setNear(false)}
>
<YStack
render="button"
aria-label={`${row.title}. ${row.action}`}
onPress={() => onPick(row.prompt)}
position="absolute"
t={0}
r={0}
b={0}
l={0}
rounded="$4"
cursor="pointer"
focusVisibleStyle={{ outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }}
/>
<XStack position="relative" flex={1} minW={0} items="center" gap="$3" pointerEvents="none">
<Mark size={18} color="$soft" />
<XStack flex={1} minW={0} items="center">
<Title row={row} />
<SizableText fontSize={15} color="$soft" numberOfLines={1} shrink={1} minW={0}>
{`. ${row.action}`}
</SizableText>
</XStack>
</XStack>
{row.summary ? (
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger flex={1} minW={0} self="stretch" flexDirection="row">
{button}
</HoverCardTrigger>
<HoverCardContent side="top" align="start" sideOffset={8} width={470} maxW="calc(100vw - 32px)" rounded="$6" p="$4">
<SizableText fontSize={16} lineHeight={26} color="$ink">
{row.summary}
</SizableText>
</HoverCardContent>
</HoverCard>
) : (
button
)}
<XStack
render="button"
aria-label={`Dismiss ${row.title}`}
position="relative"
onPress={() => onDismiss(row.id)}
p="$1.5"
rounded="$2"
@@ -245,39 +297,3 @@ function Row({ row, touch, onPick, onDismiss }: { row: Suggestion; touch: boolea
</XStack>
)
}
/** What happened, linked to its source with a ↗ when it has one, its summary in a card above it. It keeps its words before the step does, up to 70% of the line. */
function Title({ row }: { row: Suggestion }) {
const words = (
<SizableText fontSize={15} color="$soft" numberOfLines={1} textDecorationLine={row.href ? 'underline' : 'none'} textDecorationStyle="dotted">
{row.title}
</SizableText>
)
const text: ReactNode = row.href ? (
<Out href={row.href} label={`${row.title}, opens the source`}>
{words}
<ArrowUpRight size={12} color="$soft" />
</Out>
) : (
words
)
if (!row.summary) {
return (
<XStack shrink={0} maxW="70%" minW={0} pointerEvents="auto">
{text}
</XStack>
)
}
return (
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger shrink={0} maxW="70%" minW={0} pointerEvents="auto">
{text}
</HoverCardTrigger>
<HoverCardContent side="top" align="start" sideOffset={8} width={470} maxW="calc(100vw - 32px)" rounded="$6" p="$4">
<SizableText fontSize={16} lineHeight={26} color="$ink">
{row.summary}
</SizableText>
</HoverCardContent>
</HoverCard>
)
}