mirror of
https://github.com/hanzoai/build.git
synced 2026-10-23 02:18:08 +00:00
@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:
+1
-1
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user