mirror of
https://github.com/hanzoai/build.git
synced 2026-10-23 02:18:08 +00:00
i18n: the composer speaks the reader's language
The empty Chat's composer, its + menu, dictation, voice mode, the row under the frame (model, effort, mode) and the starters draw their words through @hanzo/i18n's t; a starter's prompt is sent in the words pressed. Right to left, the panel stands at the line's start and its grip turns. @hanzo/i18n is a peer, so the host's provider is the one asked.
This commit is contained in:
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/build",
|
||||
"version": "0.2.80",
|
||||
"version": "0.2.81",
|
||||
"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.",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@hanzo/composer": "catalog:",
|
||||
"@hanzo/font": "catalog:",
|
||||
"@hanzo/gui": "catalog:",
|
||||
"@hanzo/i18n": "catalog:",
|
||||
"@hanzo/iam": "catalog:",
|
||||
"@hanzo/ui": "catalog:",
|
||||
"@hanzo/vite": "catalog:",
|
||||
@@ -89,6 +90,7 @@
|
||||
"@hanzogui/lucide-icons-2": ">=8.3.5",
|
||||
"react": ">=19.2.8",
|
||||
"@hanzo/voice": ">=0.1.19",
|
||||
"@hanzo/composer": ">=0.2.0"
|
||||
"@hanzo/composer": ">=0.2.0",
|
||||
"@hanzo/i18n": ">=0.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+18
@@ -12,6 +12,9 @@ catalogs:
|
||||
'@hanzo/font':
|
||||
specifier: 1.9.3
|
||||
version: 1.9.3
|
||||
'@hanzo/i18n':
|
||||
specifier: 0.4.0
|
||||
version: 0.4.0
|
||||
'@hanzo/iam':
|
||||
specifier: 0.21.28
|
||||
version: 0.21.28
|
||||
@@ -96,6 +99,9 @@ importers:
|
||||
'@hanzo/gui':
|
||||
specifier: 8.3.5
|
||||
version: 8.3.5([email protected]([email protected]))([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected])
|
||||
'@hanzo/i18n':
|
||||
specifier: 'catalog:'
|
||||
version: 0.4.0([email protected])
|
||||
'@hanzo/iam':
|
||||
specifier: 'catalog:'
|
||||
version: 0.21.28([email protected]([email protected]))([email protected])
|
||||
@@ -760,6 +766,14 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=19.2.8'
|
||||
|
||||
'@hanzo/[email protected]':
|
||||
resolution: {integrity: sha512-NMG+UTRvH3Gef+W8cSFrXpOLrQbe55lDoUbnUcxOt5o/KrgJ9mpo/nMzxm6GlZrDirSEiB5ky/+ZRtMuBVlnfw==}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@hanzo/[email protected]':
|
||||
resolution: {integrity: sha512-cDCSLTfcE14dROiDp7wEAONy+ZRN78UXO/XycltXUeM49dtBnk7Gp41r+b0kTb0fc84yVjApLPK7TzufTxc76Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4131,6 +4145,10 @@ snapshots:
|
||||
- react-dom
|
||||
- react-native
|
||||
|
||||
'@hanzo/[email protected]([email protected])':
|
||||
optionalDependencies:
|
||||
react: 19.2.8
|
||||
|
||||
'@hanzo/[email protected]([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
jose: 6.2.12
|
||||
|
||||
@@ -31,6 +31,7 @@ catalog:
|
||||
'@hanzo/composer': 0.2.0
|
||||
'@hanzo/font': 1.9.3
|
||||
'@hanzo/gui': 8.3.5
|
||||
'@hanzo/i18n': 0.4.0
|
||||
'@hanzo/iam': 0.21.28
|
||||
'@hanzo/ui': 8.29.0
|
||||
'@hanzo/vite': 0.1.4
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* An empty Chat's composer in the reader's language (language.tsx), as a host
|
||||
* that mounts @hanzo/i18n draws it: the panel toggle, the composer's
|
||||
* placeholder, `+` and its menu, dictation and voice mode, the row under the
|
||||
* frame (model, effort, mode) and its menus, and the starters — each word the
|
||||
* catalog's, a model's name its own, and a starter's prompt sent in the words
|
||||
* pressed. Right to left, the frame mirrors: `+` at the right, the row's
|
||||
* choices at the left, nothing past the edge.
|
||||
*/
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from './fixture.ts'
|
||||
import type { Lang } from './language.tsx'
|
||||
|
||||
/** The model list the row reads. */
|
||||
const CATALOG = { data: [{ id: 'enso-auto', name: 'Enso', family: 'enso', class: 'ours', supports_reasoning: true }] }
|
||||
|
||||
/** A French catalog this spec writes, so what is drawn is checked against what was written. */
|
||||
const FR: Record<string, string> = {
|
||||
'Show tabs': 'Afficher les onglets',
|
||||
'Show tabs ({keys})': 'Afficher les onglets ({keys})',
|
||||
'Type / for skills': 'Tapez / pour les compétences',
|
||||
'Add files, agents, skills and more': 'Ajouter des fichiers, des agents, des compétences et plus',
|
||||
'Upload or add files': 'Importer ou ajouter des fichiers',
|
||||
Skills: 'Compétences',
|
||||
'No skills yet': 'Aucune compétence pour l’instant',
|
||||
Research: 'Recherche',
|
||||
Memory: 'Mémoire',
|
||||
'Dictation settings': 'Réglages de la dictée',
|
||||
'Voice mode': 'Mode vocal',
|
||||
Send: 'Envoyer',
|
||||
'Model: {name}': 'Modèle : {name}',
|
||||
'Effort: {level}': 'Réflexion : {level}',
|
||||
Effort: 'Réflexion',
|
||||
Medium: 'Moyenne',
|
||||
'Thinks before it answers': 'Réfléchit avant de répondre',
|
||||
'Mode: {mode}': 'Mode : {mode}',
|
||||
Auto: 'Automatique',
|
||||
Manual: 'Manuel',
|
||||
Suggestions: 'Idées',
|
||||
'Ask what Hanzo can do for you': 'Demandez ce que Hanzo peut faire pour vous',
|
||||
'What can you do for me? Give me three concrete things to try today.': 'Que peux-tu faire pour moi ? Donne-moi trois choses concrètes à essayer aujourd’hui.',
|
||||
'Dismiss {suggestion}': 'Ignorer {suggestion}',
|
||||
}
|
||||
|
||||
const AR: Record<string, string> = {
|
||||
'Type / for skills': 'اكتب / للمهارات',
|
||||
'Add files, agents, skills and more': 'أضف ملفات ووكلاء ومهارات والمزيد',
|
||||
'Voice mode': 'الوضع الصوتي',
|
||||
Medium: 'متوسط',
|
||||
Auto: 'تلقائي',
|
||||
}
|
||||
|
||||
async function open(p: Page, lang: Lang): Promise<void> {
|
||||
await p.route(
|
||||
(u) => u.pathname.startsWith('/v1/'),
|
||||
(r) => {
|
||||
const path = new URL(r.request().url()).pathname
|
||||
if (path === '/v1/models') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(CATALOG) })
|
||||
return r.fulfill({ status: 404, contentType: 'application/json', body: '{"detail":"not here"}' })
|
||||
},
|
||||
)
|
||||
await p.addInitScript((l) => {
|
||||
;(window as unknown as { lang: Lang }).lang = l
|
||||
}, lang)
|
||||
await p.route('**/-/language', async (r) => {
|
||||
const res = await r.fetch({ url: new URL('/', r.request().url()).href })
|
||||
const html = (await res.text()).replace('/src/app/main.tsx', '/src/e2e/language.tsx')
|
||||
await r.fulfill({ response: res, body: html })
|
||||
})
|
||||
await p.goto('/-/language')
|
||||
await expect(p.getByRole('textbox', { name: 'Message' })).toBeVisible({ timeout: 45_000 })
|
||||
await expect(p.locator('html')).toHaveAttribute('lang', lang.locale)
|
||||
}
|
||||
|
||||
const went = (p: Page) => p.evaluate(() => window.went.map((w) => JSON.parse(w) as unknown))
|
||||
const shot = (p: Page, name: string) => p.screenshot({ path: test.info().outputPath(name) })
|
||||
|
||||
test('in French, every word of an empty Chat’s composer is the catalog’s', async ({ page }) => {
|
||||
await open(page, { locale: 'fr-FR', catalog: FR })
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Message' })).toHaveAttribute('placeholder', FR['Type / for skills']!)
|
||||
const tabs = page.getByRole('button', { name: FR['Show tabs'], exact: true })
|
||||
await expect(tabs).toHaveAttribute('title', /^Afficher les onglets \((Ctrl\+Shift\+B|⌘⇧B)\)$/)
|
||||
await expect(page.getByRole('button', { name: FR['Voice mode'] })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: FR['Dictation settings'] })).toBeVisible()
|
||||
|
||||
// The row under the frame: the model by its own name, the effort and the mode in French.
|
||||
await expect(page.getByLabel('Modèle : Enso', { exact: true })).toHaveText('Enso')
|
||||
const effort = page.getByLabel('Réflexion : Moyenne', { exact: true })
|
||||
await expect(effort).toHaveText('Moyenne')
|
||||
await expect(page.getByLabel('Mode : Automatique', { exact: true })).toHaveText('Automatique')
|
||||
await effort.click()
|
||||
const efforts = page.getByRole('listbox', { name: 'Réflexion' })
|
||||
await expect(efforts.getByRole('option', { name: /Moyenne/ })).toContainText('Réfléchit avant de répondre')
|
||||
// A word the catalog lacks is its English, never a key.
|
||||
await expect(efforts.getByRole('option', { name: /High/ })).toContainText('Slow and careful, for hard problems')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// `+`, its rows, and a shelf's empty sentence.
|
||||
await page.getByRole('button', { name: FR['Add files, agents, skills and more'] }).click()
|
||||
for (const word of [FR['Upload or add files']!, FR.Skills!, FR.Research!, FR.Memory!]) await expect(page.getByRole('menuitem', { name: word }).or(page.getByRole('menuitemcheckbox', { name: word }))).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: FR.Skills }).hover()
|
||||
await expect(page.getByText(FR['No skills yet']!)).toBeVisible()
|
||||
await shot(page, 'language-fr-plus.png')
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// The starters: drawn in French, and the one pressed sends its prompt in French.
|
||||
await expect(page.getByRole('list', { name: FR.Suggestions })).toBeVisible()
|
||||
const ask = FR['Ask what Hanzo can do for you']!
|
||||
await expect(page.getByRole('button', { name: `Ignorer ${ask}` })).toHaveCount(1)
|
||||
await expect(page.getByRole('button', { name: 'Build a landing page with Dev', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: ask, exact: true }).click()
|
||||
expect(await went(page)).toEqual([{ kind: 'send', prompt: FR['What can you do for me? Give me three concrete things to try today.'] }])
|
||||
|
||||
// With words in the draft, the round is Send.
|
||||
await page.getByRole('textbox', { name: 'Message' }).fill('Bonjour')
|
||||
await expect(page.getByRole('button', { name: FR.Send })).toBeVisible()
|
||||
await shot(page, 'language-fr.png')
|
||||
})
|
||||
|
||||
test('in Arabic, the frame reads right to left: `+` at its right, the row’s choices at its left, nothing past the edge', async ({ page }) => {
|
||||
await open(page, { locale: 'ar', catalog: AR })
|
||||
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl')
|
||||
await expect(page.getByRole('textbox', { name: 'Message' })).toHaveAttribute('placeholder', AR['Type / for skills']!)
|
||||
|
||||
const frame = (await page.locator('[data-slot="composer"]').first().boundingBox())!
|
||||
const plus = (await page.getByRole('button', { name: AR['Add files, agents, skills and more'] }).boundingBox())!
|
||||
const talk = (await page.getByRole('button', { name: AR['Voice mode'] }).boundingBox())!
|
||||
const mind = (await page.locator('[data-slot="mind"]').boundingBox())!
|
||||
const middle = frame.x + frame.width / 2
|
||||
expect(plus.x).toBeGreaterThan(middle)
|
||||
expect(talk.x).toBeLessThan(middle)
|
||||
expect(mind.x + mind.width).toBeLessThan(middle)
|
||||
await expect(page.getByLabel(/متوسط/)).toHaveText('متوسط')
|
||||
await expect(page.getByLabel(/تلقائي/)).toHaveText('تلقائي')
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 844 })
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width)
|
||||
await shot(page, `language-ar-${width}.png`)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* An empty Chat's composer in a language, for language.spec.ts: the panel
|
||||
* toggle, the composer with its `+`, dictation, voice mode and the row under
|
||||
* it (model, effort, mode), and the starters under that, inside @hanzo/i18n's
|
||||
* provider as a host mounts it. A press on a starter records its act in
|
||||
* `window.went`, as JSON.
|
||||
*
|
||||
* `window.lang` before the page loads: the locale, and the catalog the
|
||||
* provider loads for it (the spec writes its own, so it reads what it wrote).
|
||||
*/
|
||||
import '@hanzo/font/css'
|
||||
import '@hanzo/ui/theme.css'
|
||||
import '@hanzo/ui/styles/motion.css'
|
||||
import '@hanzo/i18n/rtl.css'
|
||||
import { I18nProvider } from '@hanzo/i18n/react'
|
||||
import { Hanzo, XStack, YStack } from '@hanzo/ui'
|
||||
import { useDictation, useTalk, useVoice } from '@hanzo/voice'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import type { Target } from '../api/call.ts'
|
||||
import { CHAT_MODES, type Mode } from '../gear.ts'
|
||||
import { useMind } from '../mind.ts'
|
||||
import { PanelToggle } from '../panel.tsx'
|
||||
import { Prompt, useEar } from '../prompt.tsx'
|
||||
import { STARTERS } from '../suggest.ts'
|
||||
import { Suggestions } from '../suggestions.tsx'
|
||||
|
||||
export interface Lang {
|
||||
locale: string
|
||||
catalog: Record<string, string>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
lang: Lang
|
||||
went: string[]
|
||||
}
|
||||
}
|
||||
|
||||
window.went = []
|
||||
|
||||
/** A token whose claims say acme/u1; the routed platform reads nothing else of it. */
|
||||
const TOKEN = `x.${btoa(JSON.stringify({ owner: 'acme', sub: 'u1' })).replace(/=+$/, '')}.x`
|
||||
|
||||
function Page() {
|
||||
const t: Target = useMemo(() => ({ api: window.location.origin, token: () => TOKEN, org: 'acme' }), [])
|
||||
const [draft, setDraft] = useState('')
|
||||
const [mode, setMode] = useState<Mode>('auto')
|
||||
const [memory, setMemory] = useState(true)
|
||||
const [research, setResearch] = useState(false)
|
||||
const mind = useMind(t, 'pro')
|
||||
const voice = useVoice({ speech: useEar(t), language: 'en', ...useDictation(draft, setDraft) })
|
||||
const talk = useTalk({ baseUrl: t.api, token: () => TOKEN, org: 'acme' })
|
||||
return (
|
||||
<YStack flex={1} height="100dvh" bg="$background" p="$4" gap="$2">
|
||||
<XStack justify="flex-end">
|
||||
<PanelToggle open={false} onToggle={() => window.went.push('"tabs"')} />
|
||||
</XStack>
|
||||
<YStack flex={1} />
|
||||
<Prompt
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSend={() => window.went.push(JSON.stringify({ sent: draft }))}
|
||||
label="Message"
|
||||
plus={{
|
||||
upload: () => window.went.push('"upload"'),
|
||||
skills: { rows: [], pick: () => {} },
|
||||
research: { on: research, set: setResearch },
|
||||
memory: { on: memory, set: setMemory },
|
||||
}}
|
||||
mind={mind}
|
||||
mode={{ value: mode, set: setMode, items: CHAT_MODES }}
|
||||
voice={voice}
|
||||
talk={talk}
|
||||
below={<Suggestions rows={STARTERS} onAct={(act) => window.went.push(JSON.stringify(act))} onDismiss={() => {}} />}
|
||||
/>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<I18nProvider userId="u1" initialLocale={window.lang.locale} loadCatalog={async () => window.lang.catalog}>
|
||||
<Hanzo theme="dark">
|
||||
<Page />
|
||||
</Hanzo>
|
||||
</I18nProvider>,
|
||||
)
|
||||
+6
-4
@@ -15,6 +15,7 @@
|
||||
* Memory, the mode); a skill or a plugin is picked for the conversation it was
|
||||
* picked in.
|
||||
*/
|
||||
import { phrase } from '@hanzo/i18n'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
|
||||
import { call, type Target } from './api/call.ts'
|
||||
@@ -26,12 +27,13 @@ export type Mode = 'auto' | 'manual' | 'plan'
|
||||
* The modes, one line each. Chat offers Auto and Manual; Dev adds Plan. Manual
|
||||
* is a real pause: before a plugin runs for a message (a search, a site read, a
|
||||
* deck), the person allows it once, always in this conversation, or not, and a
|
||||
* Dev run is asked for before it starts.
|
||||
* Dev run is asked for before it starts. Written in English and marked
|
||||
* (`phrase`): the composer draws each word through `t`.
|
||||
*/
|
||||
export const MODES: readonly { id: Mode; label: string; note: string }[] = [
|
||||
{ id: 'auto', label: 'Auto', note: 'Runs tools on its own; pauses only for anything risky.' },
|
||||
{ id: 'manual', label: 'Manual', note: 'Asks before each tool, connector or site it opens.' },
|
||||
{ id: 'plan', label: 'Plan', note: 'Writes the plan and waits for your go before changing anything.' },
|
||||
{ id: 'auto', label: phrase('Auto'), note: phrase('Runs tools on its own; pauses only for anything risky.') },
|
||||
{ id: 'manual', label: phrase('Manual'), note: phrase('Asks before each tool, connector or site it opens.') },
|
||||
{ id: 'plan', label: phrase('Plan'), note: phrase('Writes the plan and waits for your go before changing anything.') },
|
||||
]
|
||||
|
||||
/** The modes each surface offers. */
|
||||
|
||||
+16
-9
@@ -8,10 +8,14 @@
|
||||
* every capture opens it; unchosen, the system's own input. The meter listens
|
||||
* only while the menu is open, and only where the person has already let the
|
||||
* page use a microphone: opening a menu never asks for permission.
|
||||
*
|
||||
* Its words are the reader's language (@hanzo/i18n `t`); a microphone's name
|
||||
* is the device's and a language's is its own, drawn as given.
|
||||
*/
|
||||
import { SizableText, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, ChevronDown, Mic as MicIcon } from '@hanzogui/lucide-icons-2'
|
||||
import { Control } from '@hanzo/composer'
|
||||
import { useI18n } from '@hanzo/i18n/react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -79,6 +83,8 @@ function useInputs(open: boolean): { inputs: MediaDeviceInfo[]; allowed: boolean
|
||||
|
||||
/** The chosen microphone's level while the menu is open, drawn without re-rendering. */
|
||||
function Meter({ device, on }: { device: string | null; on: boolean }) {
|
||||
// The level grows from the line's start: the right edge in a right-to-left language.
|
||||
const { dir } = useI18n()
|
||||
const bar = useRef<HTMLDivElement | null>(null)
|
||||
useEffect(() => {
|
||||
if (!on) return
|
||||
@@ -123,7 +129,7 @@ function Meter({ device, on }: { device: string | null; on: boolean }) {
|
||||
width="100%"
|
||||
bg="$ink"
|
||||
// inline-style: the level is written per frame onto the node; a style prop would mint a class per value.
|
||||
style={{ transform: 'scaleX(0)', transformOrigin: 'left' }}
|
||||
style={{ transform: 'scaleX(0)', transformOrigin: dir === 'rtl' ? 'right' : 'left' }}
|
||||
/>
|
||||
</YStack>
|
||||
</XStack>
|
||||
@@ -142,6 +148,7 @@ export interface DictationProps {
|
||||
|
||||
/** The mic and its chevron, one control on the composer's row. */
|
||||
export function Dictation({ voice, disabled = false, narrow = false, language = null, onLanguage }: DictationProps) {
|
||||
const { t } = useI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
// The Language list, drawn only while open (see plus.tsx).
|
||||
const [lang, setLang] = useState(false)
|
||||
@@ -166,7 +173,7 @@ export function Dictation({ voice, disabled = false, narrow = false, language =
|
||||
onBlur={() => setNear(false)}
|
||||
>
|
||||
<Control asChild size={narrow ? 44 : 30} {...PLAIN}>
|
||||
<Voice voice={voice} disabled={disabled} says={{ idle: 'Dictate', listening: 'Dictating — click to stop' }}>
|
||||
<Voice voice={voice} disabled={disabled} says={{ idle: t('Dictate'), listening: t('Dictating — click to stop') }}>
|
||||
{(state) => <MicIcon size={16} color="var(--foreground)" fill={state === 'idle' ? 'none' : 'var(--foreground)'} />}
|
||||
</Voice>
|
||||
</Control>
|
||||
@@ -174,7 +181,7 @@ export function Dictation({ voice, disabled = false, narrow = false, language =
|
||||
<DropdownMenuTrigger asChild disabled={disabled}>
|
||||
<XStack
|
||||
render="button"
|
||||
aria-label="Dictation settings"
|
||||
aria-label={t('Dictation settings')}
|
||||
data-slot="dictation-menu"
|
||||
width={16}
|
||||
height={30}
|
||||
@@ -194,7 +201,7 @@ export function Dictation({ voice, disabled = false, narrow = false, language =
|
||||
{inputs.length === 0 ? (
|
||||
<DropdownMenuLabel>
|
||||
<SizableText fontSize={14} color="$hint">
|
||||
No devices found
|
||||
{t('No devices found')}
|
||||
</SizableText>
|
||||
</DropdownMenuLabel>
|
||||
) : (
|
||||
@@ -211,7 +218,7 @@ export function Dictation({ voice, disabled = false, narrow = false, language =
|
||||
>
|
||||
<XStack flex={1} items="center" gap={8}>
|
||||
<SizableText fontSize={14} color="$ink" numberOfLines={1} flex={1}>
|
||||
{d.label || `Microphone ${i + 1}`}
|
||||
{d.label || t('Microphone {n}', { n: i + 1 })}
|
||||
</SizableText>
|
||||
{on ? <Check size={14} color="var(--primary)" aria-hidden /> : null}
|
||||
</XStack>
|
||||
@@ -224,23 +231,23 @@ export function Dictation({ voice, disabled = false, narrow = false, language =
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>
|
||||
<SizableText fontSize={13} color="$hint">
|
||||
Dictation settings
|
||||
{t('Dictation settings')}
|
||||
</SizableText>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSub open={lang} onOpenChange={setLang}>
|
||||
<DropdownMenuSubTrigger data-slot="dictation-language" onPress={() => setLang(true)}>
|
||||
<XStack flex={1} items="center" gap={8}>
|
||||
<SizableText fontSize={14} color="$ink" flex={1}>
|
||||
Language
|
||||
{t('Language')}
|
||||
</SizableText>
|
||||
<SizableText fontSize={14} color="$hint">
|
||||
{spoken?.label ?? 'Auto'}
|
||||
{spoken?.label ?? t('Auto')}
|
||||
</SizableText>
|
||||
</XStack>
|
||||
</DropdownMenuSubTrigger>
|
||||
{lang ? (
|
||||
<DropdownMenuSubContent minW={160} pointerEvents="auto">
|
||||
{[{ id: '', label: 'Auto' }, ...LANGUAGES].map((l) => (
|
||||
{[{ id: '', label: t('Auto') }, ...LANGUAGES].map((l) => (
|
||||
<DropdownMenuItem key={l.id || 'auto'} onSelect={() => onLanguage(l.id || null)}>
|
||||
<XStack flex={1} items="center" gap={8}>
|
||||
<SizableText fontSize={14} color="$ink" flex={1}>
|
||||
|
||||
+5
-3
@@ -16,6 +16,7 @@
|
||||
* lists as not reasoning offers none; Enso and a model the catalog does not
|
||||
* describe offer all three.
|
||||
*/
|
||||
import { phrase } from '@hanzo/i18n'
|
||||
import { parseModels } from '@hanzo/ui/models/catalog'
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react'
|
||||
|
||||
@@ -25,10 +26,11 @@ import { usePick } from './pick.ts'
|
||||
|
||||
export type Effort = 'low' | 'medium' | 'high'
|
||||
|
||||
/** The efforts, in English and marked (`phrase`): the composer draws each word through `t`. */
|
||||
export const EFFORTS: readonly { id: Effort; label: string; note: string }[] = [
|
||||
{ id: 'low', label: 'Low', note: 'A quick pass' },
|
||||
{ id: 'medium', label: 'Medium', note: 'Thinks before it answers' },
|
||||
{ id: 'high', label: 'High', note: 'Slow and careful, for hard problems' },
|
||||
{ id: 'low', label: phrase('Low'), note: phrase('A quick pass') },
|
||||
{ id: 'medium', label: phrase('Medium'), note: phrase('Thinks before it answers') },
|
||||
{ id: 'high', label: phrase('High'), note: phrase('Slow and careful, for hard problems') },
|
||||
]
|
||||
|
||||
/** What a person chose: the model (Enso routes) and the effort. */
|
||||
|
||||
+6
-3
@@ -22,6 +22,7 @@
|
||||
* panel is the whole window instead (`sheet`).
|
||||
*/
|
||||
import { SizableText, XStack, YStack } from '@hanzo/gui'
|
||||
import { useI18n } from '@hanzo/i18n/react'
|
||||
import { ArrowLeft, ArrowRight, ChevronRight, Columns2, ExternalLink, Globe, Maximize2, Minimize2, Minus, MoreHorizontal, PanelLeftOpen, Pin, Plus, RotateCcw, RotateCw, SquarePlus, X } from '@hanzogui/lucide-icons-2'
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -98,19 +99,21 @@ const quiet = (on = false, disabled = false) =>
|
||||
/**
|
||||
* The header's control for the tabs, while they are hidden: an icon among the
|
||||
* header's icons. Open, the strip's own toggle stands at the panel's end and
|
||||
* this one is not drawn, so the page carries one toggle at a time.
|
||||
* this one is not drawn, so the page carries one toggle at a time. Its words
|
||||
* are the reader's language; the key chord is an argument, as this platform writes it.
|
||||
*/
|
||||
export function PanelToggle({ open, onToggle }: { open: boolean; onToggle: () => void }) {
|
||||
const { t } = useI18n()
|
||||
if (open) return null
|
||||
return (
|
||||
<XStack
|
||||
render="button"
|
||||
role="button"
|
||||
data-slot="side-toggle"
|
||||
aria-label="Show tabs"
|
||||
aria-label={t('Show tabs')}
|
||||
aria-expanded={false}
|
||||
aria-keyshortcuts={SHORTCUTS}
|
||||
{...({ title: `Show tabs (${KEYS})` } as object)}
|
||||
{...({ title: t('Show tabs ({keys})', { keys: KEYS }) } as object)}
|
||||
onPress={onToggle}
|
||||
width={32}
|
||||
height={32}
|
||||
|
||||
+34
-20
@@ -7,8 +7,14 @@
|
||||
*
|
||||
* On a laptop each shelf opens beside the menu; on a phone it is a page of the
|
||||
* same menu, with Back.
|
||||
*
|
||||
* The menu's own words are the reader's language (@hanzo/i18n `t`); what a
|
||||
* shelf holds — its rows, its links, its search and empty words — is the
|
||||
* surface's, drawn as given.
|
||||
*/
|
||||
import { SizableText, useMedia, XStack } from '@hanzo/gui'
|
||||
import { phrase } from '@hanzo/i18n'
|
||||
import { useI18n } from '@hanzo/i18n/react'
|
||||
import {
|
||||
Blocks,
|
||||
Bot,
|
||||
@@ -91,16 +97,22 @@ export interface PlusProps {
|
||||
|
||||
const ICON = 16
|
||||
|
||||
/** The shelves, in the menu's order, with their names and marks. */
|
||||
/**
|
||||
* The shelves, in the menu's order, with their names and marks, and the three
|
||||
* sentences a shelf says of itself: its search's name, while it reads, and
|
||||
* when it holds nothing. Whole sentences, so a language puts the noun where it goes.
|
||||
*/
|
||||
const SHELVES = [
|
||||
{ key: 'agents', label: 'Agents', icon: <Bot size={ICON} /> },
|
||||
{ key: 'skills', label: 'Skills', icon: <ScrollText size={ICON} /> },
|
||||
{ key: 'connectors', label: 'Connectors', icon: <Blocks size={ICON} /> },
|
||||
{ key: 'design', label: 'Design system', icon: <Palette size={ICON} /> },
|
||||
{ key: 'plugins', label: 'Plugins', icon: <Plug size={ICON} /> },
|
||||
{ key: 'devices', label: 'Devices', icon: <Monitor size={ICON} /> },
|
||||
{ key: 'agents', label: phrase('Agents'), search: phrase('Search agents'), reading: phrase('Reading agents'), none: phrase('No agents yet'), icon: <Bot size={ICON} /> },
|
||||
{ key: 'skills', label: phrase('Skills'), search: phrase('Search skills'), reading: phrase('Reading skills'), none: phrase('No skills yet'), icon: <ScrollText size={ICON} /> },
|
||||
{ key: 'connectors', label: phrase('Connectors'), search: phrase('Search connectors'), reading: phrase('Reading connectors'), none: phrase('No connectors yet'), icon: <Blocks size={ICON} /> },
|
||||
{ key: 'design', label: phrase('Design system'), search: phrase('Search design system'), reading: phrase('Reading design system'), none: phrase('No design system yet'), icon: <Palette size={ICON} /> },
|
||||
{ key: 'plugins', label: phrase('Plugins'), search: phrase('Search plugins'), reading: phrase('Reading plugins'), none: phrase('No plugins yet'), icon: <Plug size={ICON} /> },
|
||||
{ key: 'devices', label: phrase('Devices'), search: phrase('Search devices'), reading: phrase('Reading devices'), none: phrase('No devices yet'), icon: <Monitor size={ICON} /> },
|
||||
] as const
|
||||
|
||||
type Words = (typeof SHELVES)[number]
|
||||
|
||||
type Key = (typeof SHELVES)[number]['key']
|
||||
|
||||
/** A row's anatomy: its mark, its label, a dim note, and a check when it is on. */
|
||||
@@ -129,17 +141,18 @@ function Line({ icon, label, note, on, tail }: { icon?: ReactNode; label: string
|
||||
}
|
||||
|
||||
/** A shelf's rows, its status, then its ways to more. */
|
||||
function Contents({ shelf, name, close }: { shelf: Shelf; name: string; close: () => void }) {
|
||||
function Contents({ shelf, words, close }: { shelf: Shelf; words: Words; close: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const [q, setQ] = useState('')
|
||||
const needle = q.trim().toLowerCase()
|
||||
const rows = needle ? shelf.rows.filter((r) => `${r.label} ${r.note ?? ''}`.toLowerCase().includes(needle)) : shelf.rows
|
||||
const links = [shelf.create, shelf.manage, shelf.browse].filter((l): l is Link => Boolean(l))
|
||||
return (
|
||||
<>
|
||||
{shelf.search ? <DropdownMenuSearch value={q} onValueChange={setQ} placeholder={shelf.search} aria-label={`Search ${name.toLowerCase()}`} /> : null}
|
||||
{shelf.loading && !shelf.rows.length ? <DropdownMenuLoading>Reading {name.toLowerCase()}</DropdownMenuLoading> : null}
|
||||
{shelf.search ? <DropdownMenuSearch value={q} onValueChange={setQ} placeholder={shelf.search} aria-label={t(words.search)} /> : null}
|
||||
{shelf.loading && !shelf.rows.length ? <DropdownMenuLoading>{t(words.reading)}</DropdownMenuLoading> : null}
|
||||
{shelf.error ? <DropdownMenuEmpty>{shelf.error}</DropdownMenuEmpty> : null}
|
||||
{!shelf.loading && !shelf.error && !rows.length ? <DropdownMenuEmpty>{needle ? 'Nothing matches' : (shelf.empty ?? `No ${name.toLowerCase()} yet`)}</DropdownMenuEmpty> : null}
|
||||
{!shelf.loading && !shelf.error && !rows.length ? <DropdownMenuEmpty>{needle ? t('Nothing matches') : (shelf.empty ?? t(words.none))}</DropdownMenuEmpty> : null}
|
||||
{rows.map((r) => (
|
||||
<DropdownMenuItem key={r.id} preventCloseOnSelect onSelect={() => shelf.pick(r.id)} aria-checked={r.on ?? false} data-slot="plus-row">
|
||||
<Line icon={r.icon} label={r.label} note={r.note} on={r.on} />
|
||||
@@ -163,6 +176,7 @@ function Contents({ shelf, name, close }: { shelf: Shelf; name: string; close: (
|
||||
|
||||
/** The `+` and its menu. */
|
||||
export function Plus(p: PlusProps) {
|
||||
const { t } = useI18n()
|
||||
const media = useMedia()
|
||||
const wide = media.sm
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -171,8 +185,8 @@ export function Plus(p: PlusProps) {
|
||||
const close = () => setOpen(false)
|
||||
const shelves = useMemo(() => SHELVES.filter((s) => p[s.key as Key]), [p])
|
||||
const switches = [
|
||||
p.research ? { key: 'research', label: 'Research', icon: <Telescope size={ICON} />, s: p.research } : null,
|
||||
p.memory ? { key: 'memory', label: 'Memory', icon: <Brain size={ICON} />, s: p.memory } : null,
|
||||
p.research ? { key: 'research', label: t('Research'), icon: <Telescope size={ICON} />, s: p.research } : null,
|
||||
p.memory ? { key: 'memory', label: t('Memory'), icon: <Brain size={ICON} />, s: p.memory } : null,
|
||||
].filter((x): x is NonNullable<typeof x> => x !== null)
|
||||
|
||||
const main = (
|
||||
@@ -185,7 +199,7 @@ export function Plus(p: PlusProps) {
|
||||
p.upload?.()
|
||||
}}
|
||||
>
|
||||
<Line icon={<Paperclip size={ICON} />} label="Upload or add files" tail={<DropdownMenuShortcut>Ctrl+U</DropdownMenuShortcut>} />
|
||||
<Line icon={<Paperclip size={ICON} />} label={t('Upload or add files')} tail={<DropdownMenuShortcut data-i18n-ltr>Ctrl+U</DropdownMenuShortcut>} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{p.upload && shelves.length ? <DropdownMenuSeparator /> : null}
|
||||
@@ -193,18 +207,18 @@ export function Plus(p: PlusProps) {
|
||||
wide ? (
|
||||
<DropdownMenuSub key={s.key} open={sub === s.key} onOpenChange={(o) => setSub(o ? s.key : sub === s.key ? null : sub)}>
|
||||
<DropdownMenuSubTrigger data-slot={`plus-${s.key}`} onPress={() => setSub(s.key)}>
|
||||
<Line icon={s.icon} label={s.label} />
|
||||
<Line icon={s.icon} label={t(s.label)} />
|
||||
</DropdownMenuSubTrigger>
|
||||
{/* Drawn only while open: a closed shelf stays laid out under the menu, and its rows would take its clicks. */}
|
||||
{sub === s.key ? (
|
||||
<DropdownMenuSubContent minW={200} maxW={340} maxH={420} pointerEvents="auto" style={{ overflowY: "auto" }}>
|
||||
<Contents shelf={p[s.key as Key]!} name={s.label} close={close} />
|
||||
<Contents shelf={p[s.key as Key]!} words={s} close={close} />
|
||||
</DropdownMenuSubContent>
|
||||
) : null}
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
<DropdownMenuItem key={s.key} page={s.key} data-slot={`plus-${s.key}`}>
|
||||
<Line icon={s.icon} label={s.label} />
|
||||
<Line icon={s.icon} label={t(s.label)} />
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
)}
|
||||
@@ -223,7 +237,7 @@ export function Plus(p: PlusProps) {
|
||||
<XStack
|
||||
render="button"
|
||||
data-slot="plus"
|
||||
aria-label="Add files, agents, skills and more"
|
||||
aria-label={t('Add files, agents, skills and more')}
|
||||
width={32}
|
||||
height={32}
|
||||
rounded={8}
|
||||
@@ -248,8 +262,8 @@ export function Plus(p: PlusProps) {
|
||||
<DropdownMenuPage id="main">{main}</DropdownMenuPage>
|
||||
{shelves.map((s) => (
|
||||
<DropdownMenuPage key={s.key} id={s.key}>
|
||||
<DropdownMenuBack>{s.label}</DropdownMenuBack>
|
||||
<Contents shelf={p[s.key as Key]!} name={s.label} close={close} />
|
||||
<DropdownMenuBack>{t(s.label)}</DropdownMenuBack>
|
||||
<Contents shelf={p[s.key as Key]!} words={s} close={close} />
|
||||
</DropdownMenuPage>
|
||||
))}
|
||||
</>
|
||||
|
||||
+66
-39
@@ -16,8 +16,16 @@
|
||||
* composer through `Chat` (its `composer` prop) and one that draws it directly
|
||||
* (`<Prompt>`) draw the same thing. The frame is as wide as the text above it
|
||||
* (`measure`).
|
||||
*
|
||||
* Its words are the reader's language (@hanzo/i18n): the components here ask
|
||||
* `useI18n` themselves, and `prompt`, which is not a component, takes `t` from
|
||||
* `useMention`'s state, English without one. Model names, a host's own words
|
||||
* (its placeholder, a shelf's rows and links) and what a person typed are
|
||||
* drawn as given.
|
||||
*/
|
||||
import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui'
|
||||
import { phrase, translate } from '@hanzo/i18n'
|
||||
import { useI18n, type Say } from '@hanzo/i18n/react'
|
||||
import { ArrowUp, AudioLines, Check, Square, X } from '@hanzogui/lucide-icons-2'
|
||||
import { Control } from '@hanzo/composer'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSearch, DropdownMenuTrigger, Popover, PopoverContent, PopoverTrigger } from '@hanzo/ui'
|
||||
@@ -60,7 +68,10 @@ const QUIET = { bg: 'transparent', color: 'var(--foreground)', hoverStyle: { bg:
|
||||
const SHOWN = 40
|
||||
|
||||
/** What every Composer placeholder says, unless a surface names who is listening. */
|
||||
export const ASK = 'Type / for skills'
|
||||
export const ASK = phrase('Type / for skills')
|
||||
|
||||
/** `t` where no provider answers: the English, its arguments filled. */
|
||||
const english = ((source: string, values?: Readonly<Record<string, string | number>>) => translate({}, source, values)) as Say
|
||||
|
||||
/**
|
||||
* The frame as a pane of its own, floating on the pane it is drawn in: the
|
||||
@@ -161,7 +172,7 @@ function Word({ children, dim = false, label, open }: { children: ReactNode; dim
|
||||
)
|
||||
}
|
||||
|
||||
/** A titled list of choices, each with a line saying what it does and a check on the one in force. */
|
||||
/** A titled list of choices, each with a line saying what it does and a check on the one in force. The items' words are marked (`phrase`); the title comes translated. */
|
||||
function Choices<T extends string>({
|
||||
title,
|
||||
items,
|
||||
@@ -173,6 +184,7 @@ function Choices<T extends string>({
|
||||
value: T
|
||||
onPick: (id: T) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
return (
|
||||
<YStack role={"listbox" as never} aria-label={title} gap={2} data-slot="choices">
|
||||
<SizableText fontSize={13} color="$hint" px={10} pt={6} pb={4}>
|
||||
@@ -197,11 +209,11 @@ function Choices<T extends string>({
|
||||
hoverStyle={c.off ? undefined : { bg: '$hover' }}
|
||||
>
|
||||
<YStack flex={1} minW={0} items="flex-start" gap={2}>
|
||||
<SizableText fontSize={14} color="$ink" style={{ textAlign: 'left' }}>
|
||||
{c.label}
|
||||
<SizableText fontSize={14} color="$ink" style={{ textAlign: 'start' }}>
|
||||
{t(c.label)}
|
||||
</SizableText>
|
||||
<SizableText fontSize={13} color="$hint" style={{ textAlign: 'left' }}>
|
||||
{c.note}
|
||||
<SizableText fontSize={13} color="$hint" style={{ textAlign: 'start' }}>
|
||||
{t(c.note)}
|
||||
</SizableText>
|
||||
</YStack>
|
||||
{c.id === value ? <Check size={16} color="var(--primary)" mt={2} aria-hidden /> : null}
|
||||
@@ -232,6 +244,7 @@ function Opens({ label, word, dim, children }: { label: string; word: ReactNode;
|
||||
|
||||
/** The model's name, and the catalog behind it: Free's lane to pick from, the rest an upgrade. */
|
||||
function Model({ mind, onUpgrade, limits }: { mind: Mind; onUpgrade?: (model: string) => void; limits?: PauseSource | null }) {
|
||||
const { t } = useI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [q, setQ] = useState('')
|
||||
// The catalog is hundreds long, and drawing all of it is what made the menu slow
|
||||
@@ -255,13 +268,13 @@ function Model({ mind, onUpgrade, limits }: { mind: Mind; onUpgrade?: (model: st
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} placement="top-end">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<XStack data-slot="mind">
|
||||
<Word label={`Model: ${nameOf(mind.model, mind.models)}`} open={open}>
|
||||
<Word label={t('Model: {name}', { name: nameOf(mind.model, mind.models) })} open={open}>
|
||||
{nameOf(mind.model, mind.models)}
|
||||
</Word>
|
||||
</XStack>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" minW={280} data-slot="model-menu">
|
||||
<DropdownMenuSearch value={q} onValueChange={setQ} placeholder="Search models" />
|
||||
<DropdownMenuSearch value={q} onValueChange={setQ} placeholder={t('Search models')} />
|
||||
{/* The catalog is hundreds long: the rows scroll under the search, which stays. */}
|
||||
<YStack maxH="min(420px, 60vh)" overflowY="auto" overflowX="hidden" data-slot="model-rows">
|
||||
{!mind.free && !q.trim() ? (
|
||||
@@ -272,7 +285,7 @@ function Model({ mind, onUpgrade, limits }: { mind: Mind; onUpgrade?: (model: st
|
||||
Enso
|
||||
</SizableText>
|
||||
<SizableText fontSize={12} color="$hint">
|
||||
Picks the model for each message and step
|
||||
{t('Picks the model for each message and step')}
|
||||
</SizableText>
|
||||
</YStack>
|
||||
{routed ? <Check size={14} color="var(--primary)" aria-hidden /> : null}
|
||||
@@ -302,12 +315,12 @@ function Model({ mind, onUpgrade, limits }: { mind: Mind; onUpgrade?: (model: st
|
||||
</SizableText>
|
||||
{ok ? null : (
|
||||
<SizableText fontSize={12} color="$hint" px={6} py={1} rounded={6} borderWidth={1} borderColor="$borderColor">
|
||||
Upgrade
|
||||
{t('Upgrade')}
|
||||
</SizableText>
|
||||
)}
|
||||
{ok && paused ? (
|
||||
<SizableText fontSize={12} color="$hint">
|
||||
Paused
|
||||
{t('Paused')}
|
||||
</SizableText>
|
||||
) : null}
|
||||
{m.id === mind.model ? <Check size={14} color="var(--primary)" aria-hidden /> : null}
|
||||
@@ -319,7 +332,7 @@ function Model({ mind, onUpgrade, limits }: { mind: Mind; onUpgrade?: (model: st
|
||||
))}
|
||||
{more > 0 ? (
|
||||
<SizableText fontSize={12} color="$hint" px={8} py={8}>
|
||||
{`Type to search ${more} more models`}
|
||||
{t('Type to search {count} more models', { count: more })}
|
||||
</SizableText>
|
||||
) : null}
|
||||
</YStack>
|
||||
@@ -330,8 +343,11 @@ function Model({ mind, onUpgrade, limits }: { mind: Mind; onUpgrade?: (model: st
|
||||
|
||||
/** The row under the frame: where (Dev), then the model, the effort and the mode. */
|
||||
export function Tune({ mind, mode, where, onUpgrade, limits }: { mind?: Mind | null; mode?: PromptOptions['mode']; where?: ReactNode; onUpgrade?: (model: string) => void; limits?: PauseSource | null }) {
|
||||
const { t } = useI18n()
|
||||
const thinks = mind ? reasons(mind.model, mind.models) : false
|
||||
const pace = mind ? EFFORTS.find((e) => e.id === mind.effort)! : null
|
||||
const now = mode ? MODES.find((m) => m.id === mode.value) : undefined
|
||||
const way = now ? t(now.label) : ''
|
||||
return (
|
||||
<XStack data-slot="tune" width="100%" items="center" gap={2} flexWrap="wrap" rowGap={4}>
|
||||
{where ? (
|
||||
@@ -341,13 +357,13 @@ export function Tune({ mind, mode, where, onUpgrade, limits }: { mind?: Mind | n
|
||||
</XStack>
|
||||
) : null}
|
||||
<XStack flex={1} $max-sm={{ display: 'none' }} />
|
||||
<XStack items="center" gap={2} ml="auto" shrink={0}>
|
||||
<XStack items="center" gap={2} marginInlineStart="auto" shrink={0}>
|
||||
{mind ? <Model mind={mind} onUpgrade={onUpgrade} limits={limits} /> : null}
|
||||
{mind && thinks && pace ? (
|
||||
<Opens label={`Effort: ${pace.label}`} word={pace.label} dim>
|
||||
<Opens label={t('Effort: {level}', { level: t(pace.label) })} word={t(pace.label)} dim>
|
||||
{(close) => (
|
||||
<Choices
|
||||
title="Effort"
|
||||
title={t('Effort')}
|
||||
items={EFFORTS}
|
||||
value={mind.effort}
|
||||
onPick={(e) => {
|
||||
@@ -364,11 +380,11 @@ export function Tune({ mind, mode, where, onUpgrade, limits }: { mind?: Mind | n
|
||||
<XStack
|
||||
render="button"
|
||||
data-slot="mode"
|
||||
aria-label={`Mode: ${MODES.find((m) => m.id === mode.value)?.label}`}
|
||||
aria-label={t('Mode: {mode}', { mode: way })}
|
||||
items="center"
|
||||
height={24}
|
||||
px={8}
|
||||
ml={4}
|
||||
marginInlineStart={4}
|
||||
rounded={6}
|
||||
bg="$hover"
|
||||
cursor="pointer"
|
||||
@@ -377,12 +393,12 @@ export function Tune({ mind, mode, where, onUpgrade, limits }: { mind?: Mind | n
|
||||
$max-sm={{ height: 44, px: 12 }}
|
||||
>
|
||||
<SizableText fontSize={14} color="$ink">
|
||||
{MODES.find((m) => m.id === mode.value)?.label}
|
||||
{way}
|
||||
</SizableText>
|
||||
</XStack>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent solid p={6} width={288} maxW="calc(100vw - 24px)">
|
||||
<Choices title="Mode" items={mode.items ? MODES.filter((m) => mode.items!.includes(m.id)) : MODES} value={mode.value} onPick={mode.set} />
|
||||
<Choices title={t('Mode')} items={mode.items ? MODES.filter((m) => mode.items!.includes(m.id)) : MODES} value={mode.value} onPick={mode.set} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
@@ -397,12 +413,14 @@ export function trigger(value: string): { char: '/' | '@'; query: string; at: nu
|
||||
return m ? { char: m[2] as '/' | '@', query: m[3]!.toLowerCase(), at: m.index + m[1]!.length } : null
|
||||
}
|
||||
|
||||
/** The list `/` or `@` opens above the frame. */
|
||||
function Mention({ shelf, rows, index, onPick, tag, name }: { shelf: Shelf; rows: Shelf['rows']; index: number; onPick: (id: string) => void; tag: string; name: string }) {
|
||||
/** The list `/` (the skills) or `@` (the plugins) opens above the frame. */
|
||||
function Mention({ shelf, rows, index, onPick, skills }: { shelf: Shelf; rows: Shelf['rows']; index: number; onPick: (id: string) => void; skills: boolean }) {
|
||||
const { t } = useI18n()
|
||||
const tag = t(skills ? 'Skill' : 'Plugin')
|
||||
return (
|
||||
<YStack
|
||||
role={"listbox" as never}
|
||||
aria-label={name}
|
||||
aria-label={t(skills ? 'Skills' : 'Plugins')}
|
||||
data-slot="mention"
|
||||
width="100%"
|
||||
maxH={240}
|
||||
@@ -416,7 +434,7 @@ function Mention({ shelf, rows, index, onPick, tag, name }: { shelf: Shelf; rows
|
||||
$max-sm={{ rounded: CORNER_PHONE }}
|
||||
>
|
||||
<SizableText fontSize={13} color="$hint" px={8} pt={4} pb={6}>
|
||||
{shelf.search ?? `Type to search ${name.toLowerCase()}`}
|
||||
{shelf.search ?? t(skills ? 'Type to search skills' : 'Type to search plugins')}
|
||||
</SizableText>
|
||||
{rows.length ? (
|
||||
rows.map((r, i) => (
|
||||
@@ -441,26 +459,34 @@ function Mention({ shelf, rows, index, onPick, tag, name }: { shelf: Shelf; rows
|
||||
{r.icon}
|
||||
</XStack>
|
||||
) : null}
|
||||
{/* A button centres its words unless told: a list reads from the left. */}
|
||||
<SizableText fontSize={15} color="$ink" numberOfLines={1} flex={1} style={{ textAlign: 'left' }}>
|
||||
{/* A button centres its words unless told: a list reads from its start. */}
|
||||
<SizableText fontSize={15} color="$ink" numberOfLines={1} flex={1} style={{ textAlign: 'start' }}>
|
||||
{r.label}
|
||||
</SizableText>
|
||||
<SizableText fontSize={13} color="$hint">
|
||||
{r.on ? 'On' : tag}
|
||||
{r.on ? t('On') : tag}
|
||||
</SizableText>
|
||||
</XStack>
|
||||
))
|
||||
) : (
|
||||
<SizableText fontSize={14} color="$hint" px={8} py={8}>
|
||||
{shelf.loading ? 'Reading…' : 'Nothing matches'}
|
||||
{t(shelf.loading ? 'Reading…' : 'Nothing matches')}
|
||||
</SizableText>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** What `useMention` hands `prompt`: the lit row of a `/` or `@` list, and the reader's `t`. */
|
||||
export interface Mentioned {
|
||||
index: number
|
||||
closed: string | null
|
||||
t?: Say
|
||||
}
|
||||
|
||||
/** @hanzo/ui `Composer` props for the one composer. */
|
||||
export function prompt(o: PromptOptions, state?: { index: number; closed: string | null }): ComposerProps {
|
||||
export function prompt(o: PromptOptions, state?: Mentioned): ComposerProps {
|
||||
const t = state?.t ?? english
|
||||
const lit = o.ready ?? Boolean(o.value.trim())
|
||||
const busy = Boolean(o.busy)
|
||||
const disabled = Boolean(o.disabled)
|
||||
@@ -480,7 +506,7 @@ export function prompt(o: PromptOptions, state?: { index: number; closed: string
|
||||
|
||||
const mention =
|
||||
listed && shelf && typed ? (
|
||||
<Mention shelf={shelf} rows={listed} index={index} onPick={choose} tag={typed.char === '/' ? 'Skill' : 'Plugin'} name={typed.char === '/' ? 'Skills' : 'Plugins'} />
|
||||
<Mention shelf={shelf} rows={listed} index={index} onPick={choose} skills={typed.char === '/'} />
|
||||
) : null
|
||||
|
||||
const head =
|
||||
@@ -512,7 +538,7 @@ export function prompt(o: PromptOptions, state?: { index: number; closed: string
|
||||
fill
|
||||
rounded={999}
|
||||
data-slot="composer-send"
|
||||
aria-label={busy ? 'Stop' : 'Send'}
|
||||
aria-label={t(busy ? 'Stop' : 'Send')}
|
||||
aria-disabled={!live || undefined}
|
||||
opacity={live ? 1 : 0.4}
|
||||
onClick={() => {
|
||||
@@ -535,7 +561,7 @@ export function prompt(o: PromptOptions, state?: { index: number; closed: string
|
||||
...(o.onStop ? { onStop: o.onStop } : {}),
|
||||
busy,
|
||||
disabled,
|
||||
placeholder: o.placeholder ?? ASK,
|
||||
placeholder: o.placeholder ?? t(ASK),
|
||||
...(o.label ? { label: o.label } : {}),
|
||||
// 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,
|
||||
@@ -564,12 +590,12 @@ export function prompt(o: PromptOptions, state?: { index: number; closed: string
|
||||
<XStack items="center" gap={4} data-slot="prompt-start" flex={1} minW={0} flexWrap="wrap">
|
||||
{o.plus ? <Plus {...o.plus} disabled={disabled || o.plus.disabled} /> : null}
|
||||
{(o.on ?? []).map((c) => (
|
||||
<XStack key={c.id} data-slot="prompt-chip" items="center" gap={6} pl={8} pr={4} height={28} rounded={8} bg="$hover" maxW={180}>
|
||||
<XStack key={c.id} data-slot="prompt-chip" items="center" gap={6} paddingInlineStart={8} paddingInlineEnd={4} height={28} rounded={8} bg="$hover" maxW={180}>
|
||||
{c.icon ? <XStack aria-hidden>{c.icon}</XStack> : null}
|
||||
<SizableText fontSize={13} color="$ink" numberOfLines={1} shrink={1}>
|
||||
{c.label}
|
||||
</SizableText>
|
||||
<XStack render="button" aria-label={`Remove ${c.label}`} onPress={c.onRemove} width={20} height={20} rounded={6} items="center" justify="center" hoverStyle={{ bg: '$raised' }} cursor="pointer">
|
||||
<XStack render="button" aria-label={t('Remove {name}', { name: c.label })} onPress={c.onRemove} width={20} height={20} rounded={6} items="center" justify="center" hoverStyle={{ bg: '$raised' }} cursor="pointer">
|
||||
<X size={12} color="$hint" />
|
||||
</XStack>
|
||||
</XStack>
|
||||
@@ -586,7 +612,7 @@ export function prompt(o: PromptOptions, state?: { index: number; closed: string
|
||||
voice={o.talk}
|
||||
disabled={disabled}
|
||||
data-slot="composer-talk"
|
||||
says={{ idle: 'Voice mode', listening: 'In conversation — click to hang up', speaking: 'Hanzo is speaking — talk to interrupt' }}
|
||||
says={{ idle: t('Voice mode'), listening: t('In conversation — click to hang up'), speaking: t('Hanzo is speaking — talk to interrupt') }}
|
||||
>
|
||||
{() => <AudioLines size={16} color={narrow ? ON : 'var(--foreground)'} />}
|
||||
</Voice>
|
||||
@@ -617,11 +643,12 @@ export function useUpload(upload: (() => void) | undefined): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer's own state — which row of a `/` or `@` list is lit — and the
|
||||
* keys that move it. A surface that hands `prompt` to `Chat` calls this and
|
||||
* passes its answer through; `<Prompt>` does it for itself.
|
||||
* The composer's own state — which row of a `/` or `@` list is lit, and the
|
||||
* reader's `t` — and the keys that move it. A surface that hands `prompt` to
|
||||
* `Chat` calls this and passes its answer through; `<Prompt>` does it for itself.
|
||||
*/
|
||||
export function useMention(o: PromptOptions): { state: { index: number; closed: string | null }; field: ComposerProps['field'] } {
|
||||
export function useMention(o: PromptOptions): { state: Mentioned; field: ComposerProps['field'] } {
|
||||
const { t } = useI18n()
|
||||
const [index, setIndex] = useState(0)
|
||||
const [closed, setClosed] = useState<string | null>(null)
|
||||
useUpload(o.plus?.upload)
|
||||
@@ -645,7 +672,7 @@ export function useMention(o: PromptOptions): { state: { index: number; closed:
|
||||
} else (o.field as { onKeyDown?: (e: unknown) => void } | undefined)?.onKeyDown?.(e)
|
||||
},
|
||||
} as ComposerProps['field']
|
||||
return { state: { index, closed }, field }
|
||||
return { state: { index, closed, t }, field }
|
||||
}
|
||||
|
||||
/** The composer drawn on its own. */
|
||||
|
||||
+2
-2
@@ -584,8 +584,8 @@ export function Run({ id }: { id: string }) {
|
||||
data-slot="desk"
|
||||
ref={split.aside}
|
||||
{...pane}
|
||||
// A pane of its own beside the run's, the gutter between them.
|
||||
ml={gap}
|
||||
// A pane of its own beside the run's, the gutter between them (useSplit reads it as the inline-start margin).
|
||||
marginInlineStart={gap}
|
||||
position="relative"
|
||||
shrink={0}
|
||||
minH={0}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { I18nProvider } from '@hanzo/i18n/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useSplit, type Split } from './side.tsx'
|
||||
|
||||
/** The grip `useSplit` hands its host, drawn under `wrap`. */
|
||||
function grip(wrap: (node: ReactNode) => ReactNode): Split['grip'] {
|
||||
let got: Split['grip'] = null
|
||||
function Probe() {
|
||||
got = useSplit(true, 'chat', () => {}).grip
|
||||
return null
|
||||
}
|
||||
renderToString(wrap(<Probe />))
|
||||
return got
|
||||
}
|
||||
|
||||
describe('the side column', () => {
|
||||
it('stands at the line’s end: its grip on its left edge, and on its right edge right to left', () => {
|
||||
expect(grip((n) => n)?.side).toBe('right')
|
||||
expect(grip((n) => <I18nProvider initialLocale="fr-FR">{n}</I18nProvider>)?.side).toBe('right')
|
||||
expect(grip((n) => <I18nProvider initialLocale="ar">{n}</I18nProvider>)?.side).toBe('left')
|
||||
})
|
||||
})
|
||||
+12
-6
@@ -22,7 +22,12 @@
|
||||
* measured by a ResizeObserver, after layout, so a frame never reads layout
|
||||
* it has just dirtied. A window narrowed under the panel is painted the same
|
||||
* way: the panel gives way, and the person's width comes back with the room.
|
||||
*
|
||||
* The panel stands at the line's end and the gutter is its inline-start margin,
|
||||
* so a right-to-left page puts it on the left and its edge on its right
|
||||
* (@hanzo/i18n `dir`), and the grip grows it rightwards.
|
||||
*/
|
||||
import { useI18n } from '@hanzo/i18n/react'
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
|
||||
import { usePrefs } from './prefs.tsx'
|
||||
@@ -58,8 +63,8 @@ export const useSide = (): Side | null => useContext(Context)
|
||||
/** The panel's own properties, at a width or with the room. Every key every time, so React and a drag write one set. */
|
||||
function look(width: number, hidden: boolean): Record<string, string> {
|
||||
return hidden
|
||||
? { width: 'auto', flexGrow: '1', flexShrink: '1', minWidth: '0px', maxWidth: 'none', marginLeft: '0px', gridColumnStart: 'pane' }
|
||||
: { width: `${width}px`, flexGrow: '', flexShrink: '0', minWidth: `${FLOOR}px`, maxWidth: `calc(100vw - ${MAIN}px)`, marginLeft: '', gridColumnStart: '' }
|
||||
? { width: 'auto', flexGrow: '1', flexShrink: '1', minWidth: '0px', maxWidth: 'none', marginInlineStart: '0px', gridColumnStart: 'pane' }
|
||||
: { width: `${width}px`, flexGrow: '', flexShrink: '0', minWidth: `${FLOOR}px`, maxWidth: `calc(100vw - ${MAIN}px)`, marginInlineStart: '', gridColumnStart: '' }
|
||||
}
|
||||
|
||||
/** Over the whole window, opaque, above the frame and under the kit's menus. */
|
||||
@@ -70,7 +75,7 @@ const FULL = {
|
||||
width: 'auto',
|
||||
maxWidth: 'none',
|
||||
minWidth: '0px',
|
||||
marginLeft: '0px',
|
||||
marginInlineStart: '0px',
|
||||
borderRadius: '0px',
|
||||
backgroundColor: 'var(--background)',
|
||||
backdropFilter: 'none',
|
||||
@@ -90,7 +95,7 @@ export interface Split {
|
||||
aside: (el: unknown) => void
|
||||
asideStyle: CSSProperties
|
||||
/** The kit `Grip`'s props, but its label and its place; none while the panel is full screen. */
|
||||
grip: { side: 'right'; span: number; floor: number; ceil: number; reset: number; onSpan: (n: number) => void; onKeep: (n: number) => void; onShut: () => void } | null
|
||||
grip: { side: 'left' | 'right'; span: number; floor: number; ceil: number; reset: number; onSpan: (n: number) => void; onKeep: (n: number) => void; onShut: () => void } | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +104,7 @@ export interface Split {
|
||||
* the panel away (its edge pulled past its floor).
|
||||
*/
|
||||
export function useSplit(open: boolean, main: string, onShut: () => void): Split {
|
||||
const { dir } = useI18n()
|
||||
const prefs = usePrefs(false)
|
||||
const kept = prefs?.prefs.side?.width
|
||||
const [width, setWidth] = useState(() => Math.max(FLOOR, kept ?? WIDTH))
|
||||
@@ -140,7 +146,7 @@ export function useSplit(open: boolean, main: string, onShut: () => void): Split
|
||||
// Full screen is the window's, not the room's.
|
||||
if (aside.style.position === 'fixed') return
|
||||
const shown = main.style.display !== 'none'
|
||||
if (shown) now.current.gap = parseFloat(getComputedStyle(aside).marginLeft) || 0
|
||||
if (shown) now.current.gap = parseFloat(getComputedStyle(aside).marginInlineStart) || 0
|
||||
const total = Math.round((shown ? main.getBoundingClientRect().width + now.current.gap : 0) + aside.getBoundingClientRect().width)
|
||||
// A drag moves width between the two and leaves their sum; only a new window is news.
|
||||
if (Math.abs(total - now.current.room) < 2) return
|
||||
@@ -227,6 +233,6 @@ export function useSplit(open: boolean, main: string, onShut: () => void): Split
|
||||
asideStyle: (side.full ? { ...look(across, side.hidden), ...FULL } : look(across, side.hidden)) as CSSProperties,
|
||||
grip: side.full
|
||||
? null
|
||||
: { side: 'right', span: side.hidden ? room : across, floor: FLOOR, ceil: Math.max(FLOOR, room), reset: WIDTH, onSpan: (n) => void paint(n), onKeep: keep, onShut },
|
||||
: { side: dir === 'rtl' ? 'left' : 'right', span: side.hidden ? room : across, floor: FLOOR, ceil: Math.max(FLOOR, room), reset: WIDTH, onSpan: (n) => void paint(n), onKeep: keep, onShut },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
shown,
|
||||
fill,
|
||||
STARTERS,
|
||||
starter,
|
||||
sig,
|
||||
skip,
|
||||
stale,
|
||||
@@ -310,6 +311,12 @@ describe('what the account keeps', () => {
|
||||
for (const r of STARTERS) expect(r.title.split(' ').length).toBeLessThanOrEqual(7)
|
||||
})
|
||||
|
||||
it('knows a starter, whose words are authored and translated, from a row of the person’s own', () => {
|
||||
expect(STARTERS.every(starter)).toBe(true)
|
||||
expect(STARTERS.map((r) => r.title)).toEqual(['Ask what Hanzo can do for you', 'Build a landing page with Dev', 'Get a cited brief on AI agents'])
|
||||
expect(starter({ ...STARTERS[0]!, id: idOf('', STARTERS[0]!.title) })).toBe(false)
|
||||
})
|
||||
|
||||
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], dismissed: [] }
|
||||
expect(fill(one).map((r) => r.id)).toEqual(['r1', STARTERS[0]!.id, STARTERS[1]!.id])
|
||||
|
||||
+14
-4
@@ -25,6 +25,8 @@
|
||||
* 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 { phrase } from '@hanzo/i18n'
|
||||
|
||||
import type { Automation, Schedule } from './api/auto.ts'
|
||||
import type { Conversation } from './api/conversations.ts'
|
||||
import type { Item } from './api/library.ts'
|
||||
@@ -504,13 +506,21 @@ 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, each done at once: 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. The one copy here that is authored, so
|
||||
* its words are marked (`phrase`) and drawn in the reader's language, the
|
||||
* prompt too: it is sent as their message and shown as theirs.
|
||||
*/
|
||||
export const STARTERS: readonly Suggestion[] = [
|
||||
{ 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.' } },
|
||||
{ id: 'start:chat', icon: 'chat', title: phrase('Ask what Hanzo can do for you'), summary: '', act: { kind: 'send', prompt: phrase('What can you do for me? Give me three concrete things to try today.') } },
|
||||
{ id: 'start:dev', icon: 'agent', title: phrase('Build a landing page with Dev'), summary: '', act: { kind: 'task', prompt: phrase('Build a one-page landing site for my project and give me the preview link.') } },
|
||||
{ id: 'start:research', icon: 'file', title: phrase('Get a cited brief on AI agents'), summary: '', act: { kind: 'send', prompt: phrase('Research the state of AI coding agents this month and give me a short brief with sources.') } },
|
||||
]
|
||||
|
||||
/** Whether a row is a starter: authored, so drawn through `t`; every other row is the person's own words. */
|
||||
export const starter = (r: Suggestion): boolean => STARTERS.some((s) => s.id === r.id)
|
||||
|
||||
/** The kept rows, filled to `MOST` with the starters not dismissed: never one lonely row. */
|
||||
export const fill = (k: Kept | null | undefined): Suggestion[] => {
|
||||
if (!k) return []
|
||||
|
||||
+15
-6
@@ -5,6 +5,10 @@
|
||||
* a new chat, starts its run, or makes its scheduled task. Nothing only fills
|
||||
* the composer. Its × dismisses it, and the account remembers.
|
||||
*
|
||||
* A row's words are the person's own, made from their activity, and drawn as
|
||||
* given; a starter's are authored (suggest.ts `STARTERS`), so drawn and sent in
|
||||
* the reader's language (@hanzo/i18n `t`).
|
||||
*
|
||||
* `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
|
||||
* asks Hanzo's model for new ones. One document per page, read once per person
|
||||
@@ -23,6 +27,7 @@
|
||||
*/
|
||||
import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui'
|
||||
import { Bot, CalendarClock, FileText, Github, MessageSquare, X } from '@hanzogui/lucide-icons-2'
|
||||
import { useI18n } from '@hanzo/i18n/react'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@hanzo/ui'
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
|
||||
@@ -34,7 +39,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 { 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'
|
||||
import { answer, dismissed, due, fill, inputs, request, seed, sig, skip, stale, starter, 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> {
|
||||
@@ -216,11 +221,12 @@ export interface SuggestionsProps {
|
||||
|
||||
/** The rows, or nothing when there are none. */
|
||||
export function Suggestions({ rows, onAct, onDismiss }: SuggestionsProps) {
|
||||
const { t } = useI18n()
|
||||
// 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">
|
||||
<YStack role="list" aria-label={t('Suggestions')} data-slot="suggestions">
|
||||
{rows.map((r) => (
|
||||
<Row key={r.id} row={r} touch={touch} onAct={onAct} onDismiss={onDismiss} />
|
||||
))}
|
||||
@@ -229,8 +235,11 @@ export function Suggestions({ rows, onAct, onDismiss }: SuggestionsProps) {
|
||||
}
|
||||
|
||||
/** 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 }) {
|
||||
function Row({ row: kept, touch, onAct, onDismiss }: { row: Suggestion; touch: boolean; onAct: (act: Act, row: Suggestion) => void; onDismiss: (id: string) => void }) {
|
||||
const { t } = useI18n()
|
||||
const [near, setNear] = useState(false)
|
||||
// A starter in the reader's words, its prompt too: what is sent is what they pressed.
|
||||
const row = starter(kept) ? { ...kept, title: t(kept.title), act: 'prompt' in kept.act ? { ...kept.act, prompt: t(kept.act.prompt) } : kept.act } : kept
|
||||
const Mark = MARKS[row.icon]
|
||||
const button = (
|
||||
<XStack
|
||||
@@ -243,7 +252,7 @@ function Row({ row, touch, onAct, onDismiss }: { row: Suggestion; touch: boolean
|
||||
self="stretch"
|
||||
items="center"
|
||||
gap="$3"
|
||||
pl="$3"
|
||||
paddingInlineStart="$3"
|
||||
rounded="$4"
|
||||
cursor="pointer"
|
||||
focusVisibleStyle={{ outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }}
|
||||
@@ -261,7 +270,7 @@ function Row({ row, touch, onAct, onDismiss }: { row: Suggestion; touch: boolean
|
||||
items="center"
|
||||
gap="$1"
|
||||
minH={PITCH}
|
||||
pr="$2"
|
||||
paddingInlineEnd="$2"
|
||||
rounded="$4"
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
onPointerEnter={() => setNear(true)}
|
||||
@@ -283,7 +292,7 @@ function Row({ row, touch, onAct, onDismiss }: { row: Suggestion; touch: boolean
|
||||
)}
|
||||
<XStack
|
||||
render="button"
|
||||
aria-label={`Dismiss ${row.title}`}
|
||||
aria-label={t('Dismiss {suggestion}', { suggestion: row.title })}
|
||||
onPress={() => onDismiss(row.id)}
|
||||
p="$1.5"
|
||||
rounded="$2"
|
||||
|
||||
Reference in New Issue
Block a user