Merge pull request #2 from hanzoai/i18n

@hanzo/build 0.2.81: every word the builder authors is the reader's language
This commit is contained in:
z
2026-10-22 19:18:02 -07:00
committed by GitHub
122 changed files with 4374 additions and 2672 deletions
+13
View File
@@ -100,6 +100,19 @@ nothing behind a More; `useSessions(host)` is the runs — the org's coding runs
kept live by the org's feed — for a host that draws the rows itself. `administers(token, org)` reads the org-admin bit off a
hanzo.id token for `Host.admin`. One left column, never two.
## Words
Every word the builder authors is said through @hanzo/i18n, a required peer
(`>=0.4.0`): the host's `I18nProvider` is the one asked, and without one the
builder reads in English, unchanged. A component asks `useI18n()`; copy held as
data is `phrase()` where it is written and drawn through `t`; a helper outside a
render (`nav()`, `own()`, `tabs()`, `library()`, turn.ts, the plan sentences)
takes the caller's `t` last and falls back to `say.ts`'s `english`. Names, model
ids, what a person typed, what the platform said and figures are drawn as given;
prompts sent to a model stay in English. `src/e2e/language*.spec.ts` and
`translated.spec.ts` hold the composer, Projects, Skills and the Library in
French and right to left in Arabic.
## The model, the composer, the panel
One of each, shared with Chat (@hanzo/rooms imports them):
+4 -2
View File
@@ -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"
}
}
+18
View File
@@ -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
+1
View File
@@ -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
+14 -5
View File
@@ -3,15 +3,24 @@
* took, what it cost, and the one line under its title. Pure, so the screen and
* its tests read the same sentences.
*/
import { phrase } from '@hanzo/i18n'
import type { Say } from '@hanzo/i18n/react'
import type { SessionStatus } from '@hanzo/ui/chat'
import { ago } from './ago.ts'
import type { Job, State } from './api/auto.ts'
import { SOURCE } from './api/library.ts'
import { english } from './say.ts'
/** A job's state as the rail's dot draws it, and in words. */
export const DOTS: Record<State, SessionStatus> = { scheduled: 'idle', running: 'running', succeeded: 'done', failed: 'error', cancelled: 'stopped' }
export const SAID: Record<State, string> = { scheduled: 'Scheduled', running: 'Running', succeeded: 'Succeeded', failed: 'Failed', cancelled: 'Cancelled' }
export const SAID: Record<State, string> = {
scheduled: phrase('Scheduled'),
running: phrase('Running'),
succeeded: phrase('Succeeded'),
failed: phrase('Failed'),
cancelled: phrase('Cancelled'),
}
/** How long a run took, or has taken so far. */
export function lasted(started: string, finished: string | null, now = Date.now()): string {
@@ -29,10 +38,10 @@ export function lasted(started: string, finished: string | null, now = Date.now(
/** Cents as dollars. */
export const dollars = (cents: number): string => `$${(cents / 100).toFixed(2)}`
/** One line about a job: when it started, how long, what it cost, what it made. */
export function about(j: Job, now = Date.now()): string {
const made = j.outputs.length ? `${j.outputs.length} ${j.outputs.length === 1 ? 'output' : 'outputs'}` : ''
return [SOURCE[j.product], j.started ? ago(j.started) : '', lasted(j.started, j.finished, now), j.cost !== null ? dollars(j.cost) : '', made].filter(Boolean).join(' · ')
/** One line about a job: when it started, how long, what it cost, what it made. Its words in the language of `t`. */
export function about(j: Job, now = Date.now(), t: Say = english): string {
const made = j.outputs.length ? (j.outputs.length === 1 ? t('{n} output', { n: 1 }) : t('{n} outputs', { n: j.outputs.length })) : ''
return [SOURCE[j.product] ? t(SOURCE[j.product]) : '', j.started ? ago(j.started) : '', lasted(j.started, j.finished, now), j.cost !== null ? dollars(j.cost) : '', made].filter(Boolean).join(' · ')
}
/** The jobs, newest first, with `j` in its place. */
+18 -8
View File
@@ -17,6 +17,8 @@
* (`serverExecuted`) makes a working agent; the others hand their calls back to
* the client that asked, so they are not offered here.
*/
import { phrase } from '@hanzo/i18n'
import { call, seg, type Target } from './call.ts'
export type Period = 'day' | 'week' | 'month'
@@ -115,15 +117,23 @@ export function draft(a: Agent): Draft {
export const EMPTY: Draft = { name: '', description: '', model: '', instructions: '', tools: [], cap: '', task: '', period: 'month', avatar: '' }
/** Why a draft cannot be saved as it stands, or '' when it can. */
/** What a budget left unset says, and one run that outspends its period, by the period: whole sentences, so a language says the period its own way. */
const UNSET: Record<Period, string> = { day: phrase('Set what it may spend each day'), week: phrase('Set what it may spend each week'), month: phrase('Set what it may spend each month') }
const OVER: Record<Period, string> = {
day: phrase('One run cannot spend more than the whole day'),
week: phrase('One run cannot spend more than the whole week'),
month: phrase('One run cannot spend more than the whole month'),
}
/** Why a draft cannot be saved as it stands, or '' when it can: English, marked (`phrase`), so a screen says it through `t`. */
export function refuse(d: Draft): string {
if (!NAME.test(d.name)) return 'A name is letters, digits, . _ or -, starting with a letter or digit'
if (new TextEncoder().encode(d.instructions).length > MAX) return 'Instructions are at most 32 KB'
if (!NAME.test(d.name)) return phrase('A name is letters, digits, . _ or -, starting with a letter or digit')
if (new TextEncoder().encode(d.instructions).length > MAX) return phrase('Instructions are at most 32 KB')
const cap = micros(d.cap)
const task = micros(d.task)
if (!(cap > 0)) return 'Set what it may spend each ' + d.period
if (!(task > 0)) return 'Set what one run may spend'
if (task > cap) return 'One run cannot spend more than the whole ' + d.period
if (!(cap > 0)) return UNSET[d.period]
if (!(task > 0)) return phrase('Set what one run may spend')
if (task > cap) return OVER[d.period]
return ''
}
@@ -224,7 +234,7 @@ export async function use(t: Target, c: Cast, base: string): Promise<Agent> {
export function person(name: string, about: string, writing: string, avatar = ''): Cast {
const who = name.trim()
const lines = [`You are ${who}.`, about.trim(), writing.trim() && `Writing by ${who}:\n\n${writing.trim()}`]
return { name: who, title: who, description: about.trim(), category: 'Person', model: '', instructions: lines.filter(Boolean).join('\n\n'), avatar }
return { name: who, title: who, description: about.trim(), category: phrase('Person'), model: '', instructions: lines.filter(Boolean).join('\n\n'), avatar }
}
export async function create(t: Target, d: Draft): Promise<Agent> {
@@ -280,5 +290,5 @@ export async function presets(t: Target): Promise<Preset[]> {
/** A preset as a template. */
export function fromPreset(p: Preset): Cast {
return { name: p.id, title: p.title || p.id, description: p.title, category: 'Preset', model: '', instructions: p.prompt }
return { name: p.id, title: p.title || p.id, description: p.title, category: phrase('Preset'), model: '', instructions: p.prompt }
}
+44 -26
View File
@@ -19,6 +19,10 @@
* saved what it does. A draft is a flow named and not yet told what to do: it
* runs once its instructions are saved.
*/
import { phrase } from '@hanzo/i18n'
import type { Say } from '@hanzo/i18n/react'
import { english } from '../say.ts'
import { call, headers, query, reason, Refusal, seg, type Target } from './call.ts'
import { inApp, type Source } from './library.ts'
import { read as stream } from './sse.ts'
@@ -157,13 +161,13 @@ export async function automations(t: Target): Promise<Automation[]> {
export async function automation(t: Target, id: string): Promise<Automation> {
const a = read(await call<unknown>(t, 'GET', `/v1/auto/automations/${seg(id)}`))
if (!a) throw new Error('The automation came back without an id')
if (!a) throw new Error(phrase('The automation came back without an id'))
return a
}
export async function create(t: Target, d: Draft): Promise<Automation> {
const a = read(await call<unknown>(t, 'POST', '/v1/auto/automations', d))
if (!a) throw new Error('The automation was created and came back without an id')
if (!a) throw new Error(phrase('The automation was created and came back without an id'))
return a
}
@@ -179,7 +183,7 @@ export function changes(was: Draft, now: Draft): Partial<Draft> {
/** Change an automation. A change to anything but `enabled` makes the caller the person it runs as. */
export async function save(t: Target, id: string, change: Partial<Draft>): Promise<Automation> {
const a = read(await call<unknown>(t, 'PATCH', `/v1/auto/automations/${seg(id)}`, change))
if (!a) throw new Error('The automation was saved and came back without an id')
if (!a) throw new Error(phrase('The automation was saved and came back without an id'))
return a
}
@@ -215,39 +219,53 @@ export async function review(t: Target, id: string, runId: string, post: boolean
return run(obj(await call<unknown>(t, 'POST', `/v1/auto/automations/${seg(id)}/runs/${seg(runId)}/review`, { post })))
}
const DAYS: Record<string, string> = { mon: 'Mondays', tue: 'Tuesdays', wed: 'Wednesdays', thu: 'Thursdays', fri: 'Fridays', sat: 'Saturdays', sun: 'Sundays' }
/** A once schedule's local date and time, as a person reads it. */
function once(at: string): string {
const m = at.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/)
if (!m) return at
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]))
return `${d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })} at ${m[4]}:${m[5]}`
/** A weekly schedule's sentence by its day: one sentence each, so a language says the day its own way. */
const DAYS: Record<string, string> = {
mon: phrase('Mondays at {time} · {zone}'),
tue: phrase('Tuesdays at {time} · {zone}'),
wed: phrase('Wednesdays at {time} · {zone}'),
thu: phrase('Thursdays at {time} · {zone}'),
fri: phrase('Fridays at {time} · {zone}'),
sat: phrase('Saturdays at {time} · {zone}'),
sun: phrase('Sundays at {time} · {zone}'),
}
/** A schedule the way a person says it. */
export function words(s: Schedule): string {
const zone = ` · ${s.tz || 'UTC'}`
/** A once schedule's local date, as a person reads it, and its time. */
function once(at: string): { date: string; time: string } | null {
const m = at.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/)
if (!m) return null
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]))
return { date: d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }), time: `${m[4]}:${m[5]}` }
}
/** A schedule the way a person says it, in the words of the `t` handed (English without one). */
export function words(s: Schedule, t: Say = english): string {
const zone = s.tz || 'UTC'
switch (s.kind) {
case 'manual':
return 'When you run it'
case 'once':
return s.at ? `Once, ${once(s.at)}${zone}` : 'Once, now'
return t('When you run it')
case 'once': {
if (!s.at) return t('Once, now')
const when = once(s.at)
return when ? t('Once, {date} at {time} · {zone}', { ...when, zone }) : t('Once, {at} · {zone}', { at: s.at, zone })
}
case 'hourly':
return `Every hour at :${(s.at ?? '00:00').slice(3) || '00'}`
return t('Every hour at :{minute}', { minute: (s.at ?? '00:00').slice(3) || '00' })
case 'daily':
return `Every day at ${s.at}${zone}`
return t('Every day at {time} · {zone}', { time: s.at ?? '', zone })
case 'weekdays':
return `Weekdays at ${s.at}${zone}`
case 'weekly':
return `${DAYS[s.day ?? ''] ?? s.day} at ${s.at}${zone}`
return t('Weekdays at {time} · {zone}', { time: s.at ?? '', zone })
case 'weekly': {
const day = DAYS[s.day ?? '']
return day ? t(day, { time: s.at ?? '', zone }) : t('{day} at {time} · {zone}', { day: s.day ?? '', time: s.at ?? '', zone })
}
case 'cron': {
// The two a person says most, in their words; any other expression as written.
const minutes = (s.cron ?? '').match(/^\*\/(\d+) \* \* \* \*$/)
if (minutes) return `Every ${minutes[1]} minutes${zone}`
if (minutes) return t('Every {n} minutes · {zone}', { n: minutes[1]!, zone })
const hours = (s.cron ?? '').match(/^0 \*\/(\d+) \* \* \*$/)
if (hours) return `Every ${hours[1]} hours${zone}`
return `Cron ${s.cron}${zone}`
if (hours) return t('Every {n} hours · {zone}', { n: hours[1]!, zone })
return t('Cron {cron} · {zone}', { cron: s.cron ?? '', zone })
}
}
}
@@ -381,7 +399,7 @@ export async function jobs(t: Target, f: JobFilter = {}): Promise<Job[]> {
export async function one(t: Target, id: string): Promise<Job> {
const j = job(await call<unknown>(t, 'GET', `/v1/auto/jobs/${seg(id)}`))
if (!j) throw new Error('The job came back without an id')
if (!j) throw new Error(phrase('The job came back without an id'))
return j
}
+7 -5
View File
@@ -33,6 +33,8 @@
* an org or a subject. No card number passes through this file: a card is saved
* from the token the processor's own form returns.
*/
import { phrase } from '@hanzo/i18n'
import { call, headers, reason, Refusal, seg, type Target } from './call.ts'
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
@@ -172,7 +174,7 @@ export interface Purchase {
/** Buy a plan. No amount is sent: the catalog prices it, and the platform charges that. */
export async function subscribe(t: Target, p: Purchase): Promise<{ subscription: string; charged: number }> {
if (!p.method) throw new Error('Add a card before choosing a paid plan')
if (!p.method) throw new Error(phrase('Add a card before choosing a paid plan'))
const raw = obj(
await call<unknown>(t, 'POST', '/v1/billing/subscribe/card', { planId: p.plan, paymentMethodId: p.method, interval: p.interval }),
)
@@ -273,7 +275,7 @@ export function chosen(list: Method[]): Method | null {
/** Save a card from the single-use token the processor's form returned, optionally with billing info. */
export async function save(t: Target, token: string, billingAddress?: Address): Promise<Method> {
if (!token) throw new Error('The card form returned no token')
if (!token) throw new Error(phrase('The card form returned no token'))
const body: Record<string, unknown> = { type: 'card', providerRef: token }
if (billingAddress && Object.values(billingAddress).some(Boolean)) {
body.billingAddress = billingAddress
@@ -470,8 +472,8 @@ export async function arm(t: Target, rule: { enabled: boolean; threshold: number
/** Charge a saved card and add the amount to the balance; answers the balance after. */
export async function topup(t: Target, amount: number, card: string): Promise<number> {
if (!Number.isInteger(amount) || amount <= 0) throw new Error('Enter an amount to add')
if (!card) throw new Error('Add a card before buying more')
if (!Number.isInteger(amount) || amount <= 0) throw new Error(phrase('Enter an amount to add'))
if (!card) throw new Error(phrase('Add a card before buying more'))
const r = obj(await call<unknown>(t, 'POST', '/v1/billing/topup', { amountCents: amount, paymentMethodId: card }))
return num(r.balanceCents)
}
@@ -622,7 +624,7 @@ export function monthly(list: Cap[]): Cap | null {
/** Set the org's monthly cap: change the one there is, or open one that refuses spend past it. */
export async function limit(t: Target, list: Cap[], threshold: number): Promise<Cap> {
if (!Number.isInteger(threshold) || threshold <= 0) throw new Error('Enter a monthly limit')
if (!Number.isInteger(threshold) || threshold <= 0) throw new Error(phrase('Enter a monthly limit'))
const was = monthly(list)
if (was) return cap(await call<unknown>(t, 'PATCH', `/v1/billing/alerts/${seg(was.id)}`, { threshold }))
return cap(
+34 -1
View File
@@ -5,7 +5,17 @@
* no-store cache rule and the reading of a refusal are decided once. The
* platform answers a refusal as RFC 9457 problem details; older handlers still
* answer `{error:{message}}` or `{msg}`, and all three read the same way here.
*
* A sentence this client writes is English, marked (`phrase`) for the
* translator: a module is not a component and asks no `t`, so a screen says
* what a module threw through its own (`told`), and a module that builds text
* takes the screen's `t` as its last argument, English (../say.ts `english`)
* when none is handed.
*/
import { phrase, translate } from '@hanzo/i18n'
import type { Say } from '@hanzo/i18n/react'
import { english } from '../say.ts'
/** Where the platform is and who is asking. The host supplies it. */
export interface Target {
@@ -28,6 +38,29 @@ export class Refusal extends Error {
}
}
/**
* A sentence this client wrote with something of the moment in it (a file's
* name, an address), thrown where no `t` is at hand: its message is the
* English, filled, and a screen says it from its source and values.
*/
export class Sentence extends Error {
constructor(
readonly source: string,
readonly values: Readonly<Record<string, string | number>> = {},
) {
super(translate({}, source, values))
}
}
/**
* An error's own sentence, as the screen that caught it says it: one this
* client wrote, in the reader's language; the platform's, which is no key, as given.
*/
export function told(e: unknown, t: Say = english): string {
if (e instanceof Sentence) return t(e.source, e.values)
return t(e instanceof Error ? e.message : String(e))
}
export type Query = Record<string, string | number | boolean | null | undefined>
/** `?a=1&b=2` from the defined, non-empty entries of `q`, or ''. */
@@ -107,6 +140,6 @@ export async function call<T>(
*/
export function unwrap(raw: unknown): unknown {
const r = (raw && typeof raw === 'object' ? raw : {}) as { status?: unknown; msg?: unknown; data?: unknown }
if (r.status === 'error') throw new Refusal(400, typeof r.msg === 'string' && r.msg ? r.msg : 'The platform refused that')
if (r.status === 'error') throw new Refusal(400, typeof r.msg === 'string' && r.msg ? r.msg : phrase('The platform refused that'))
return r.data
}
+3 -1
View File
@@ -9,6 +9,8 @@
* refused when an agent calls it. At most 256 names go in one request, so a
* larger change is sent in turns.
*/
import { phrase } from '@hanzo/i18n'
import { call, type Target } from './call.ts'
/** Where a tool comes from, in the order the platform ranks them. */
@@ -40,7 +42,7 @@ export async function tools(t: Target): Promise<Tool[]> {
/** Switch every tool in `names` on (or off), and answer the org's activated set. */
export async function activate(t: Target, names: string[], on: boolean): Promise<string[]> {
if (!names.length) throw new Error('There is nothing to switch')
if (!names.length) throw new Error(phrase('There is nothing to switch'))
let enabled: string[] = []
for (let i = 0; i < names.length; i += BATCH) {
const part = names.slice(i, i + BATCH)
+4 -2
View File
@@ -8,6 +8,8 @@
* the request, so the name is the whole address — `owner/name` is a path the
* engine refuses.
*/
import { phrase } from '@hanzo/i18n'
import { call, seg, type Target } from './call.ts'
export interface Codebase {
@@ -100,10 +102,10 @@ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
/** Provision an empty repository. The name is the handle and the last path segment. */
export async function create(t: Target, name: string, description = ''): Promise<Codebase> {
const handle = name.trim().replace(/\.git$/, '')
if (!NAME.test(handle)) throw new Error('A repository name starts with a letter or number, and may contain letters, numbers, dots, underscores and hyphens.')
if (!NAME.test(handle)) throw new Error(phrase('A repository name starts with a letter or number, and may contain letters, numbers, dots, underscores and hyphens.'))
const raw = await call<unknown>(t, 'POST', '/v1/git/repos', { name: handle, description: description.trim() })
const row = codebase(raw, t.org ?? '')
if (!row) throw new Error('The forge created a repository and did not name it')
if (!row) throw new Error(phrase('The forge created a repository and did not name it'))
return row
}
+16 -8
View File
@@ -28,15 +28,23 @@
* A sandbox run keeps its sandbox after it ends, so `after` continues the earlier
* run in that same sandbox and working tree, resumed if it parked.
*/
import { phrase } from '@hanzo/i18n'
import type { Say } from '@hanzo/i18n/react'
import { english } from '../say.ts'
import { call, headers, reason, Refusal, seg, type Target } from './call.ts'
export type Mode = 'build' | 'plan' | 'setup'
/** Why this mode cannot run where it is sent, or '' when it can. */
export const unhonoured = (mode: Mode | undefined, target?: string): string =>
(mode === 'plan' || mode === 'setup') && target?.trim()
? `A ${mode} runs in the Hanzo sandbox: a machine clones and pushes with its own credential. Choose Cloud, or switch to Build.`
: ''
/** Why this mode cannot run where it is sent, in the words of the `t` handed (English without one), or '' when it can. */
export const unhonoured = (mode: Mode | undefined, target?: string, t: Say = english): string =>
!target?.trim()
? ''
: mode === 'plan'
? t('A plan runs in the Hanzo sandbox: a machine clones and pushes with its own credential. Choose Cloud, or switch to Build.')
: mode === 'setup'
? t('A setup runs in the Hanzo sandbox: a machine clones and pushes with its own credential. Choose Cloud, or switch to Build.')
: ''
export interface Ask {
/** The task, in the words you would use with a colleague. */
@@ -88,12 +96,12 @@ export function body(ask: Ask): Record<string, string | number | boolean> {
}
export async function start(t: Target, ask: Ask): Promise<Run> {
if (!ask.prompt.trim()) throw new Refusal(400, 'Say what the run should do')
if (!ask.prompt.trim()) throw new Refusal(400, phrase('Say what the run should do'))
const why = unhonoured(ask.mode, ask.targetId)
if (why) throw new Refusal(400, why)
const r = await call<Partial<Record<string, unknown>>>(t, 'POST', '/v1/agent/coding', body(ask))
const s = (k: string) => (typeof r?.[k] === 'string' ? (r[k] as string) : '')
if (!s('sessionId')) throw new Refusal(502, 'The run was admitted but named no session')
if (!s('sessionId')) throw new Refusal(502, phrase('The run was admitted but named no session'))
return {
session: s('sessionId'),
repo: s('repo'),
@@ -228,7 +236,7 @@ export async function artifacts(t: Target, session: string): Promise<Left> {
/** One stored artifact's bytes. */
export async function artifact(t: Target, session: string, name: string): Promise<Blob> {
const parts = name.split('/')
if (parts.some((p) => p === '' || p === '.' || p === '..')) throw new Refusal(400, 'That artifact has no name to fetch it by')
if (parts.some((p) => p === '' || p === '.' || p === '..')) throw new Refusal(400, phrase('That artifact has no name to fetch it by'))
const res = await fetch(`${t.api}/v1/agent/coding/${seg(session)}/artifacts/${parts.map(seg).join('/')}`, { headers: headers(t), cache: 'no-store' })
if (!res.ok) throw new Refusal(res.status, (await reason(res)) || `The artifact answered ${res.status}`)
// Bytes, whatever type the store named: a page is saved, never opened here.
+6 -4
View File
@@ -14,6 +14,8 @@
* ships only a package needs somewhere to run first. The fleet's own servers,
* on for every run, are mcp.ts.
*/
import { phrase } from '@hanzo/i18n'
import { call, query, seg, type Target } from './call.ts'
export interface Server {
@@ -136,13 +138,13 @@ export interface Adding {
secret?: string
}
/** Why a server cannot be added as it stands, or '' when it can. */
/** Why a server cannot be added as it stands, or '' when it can. English, marked (`phrase`): a screen says it through `t`. */
export function refuse(a: Adding): string {
if (!a.listing) {
if (!a.name?.trim()) return 'A connector needs a name'
if (!/^https?:\/\/[^\s/]+/.test(a.url?.trim() ?? '')) return 'The URL is an http(s) address'
if (!a.name?.trim()) return phrase('A connector needs a name')
if (!/^https?:\/\/[^\s/]+/.test(a.url?.trim() ?? '')) return phrase('The URL is an http(s) address')
}
if (a.secret && !a.header?.trim()) return 'Name the header the secret is sent in'
if (a.secret && !a.header?.trim()) return phrase('Name the header the secret is sent in')
return ''
}
+24 -19
View File
@@ -14,7 +14,11 @@
* here as `proposal` until someone saves it. Secrets are names only; their values
* are sealed in KMS and reach the run's environment, never this record.
*/
import { call, Refusal, seg, type Target } from './call.ts'
import { phrase } from '@hanzo/i18n'
import type { Say } from '@hanzo/i18n/react'
import { english } from '../say.ts'
import { call, Refusal, seg, told, type Target } from './call.ts'
export type State = 'none' | 'proposed' | 'ready'
@@ -71,24 +75,25 @@ export const unset = (e: Environment): boolean => e.state === 'none' && !e.updat
/**
* What a refused save or setup run says to the person who pressed it: what is
* wrong and what to do, in the reader's words rather than the platform's.
* wrong and what to do, in the reader's words rather than the platform's, and
* in the language of the `t` handed (English without one).
*/
export function plain(e: unknown, repo: string, act: 'save' | 'start' | 'run'): string {
export function plain(e: unknown, repo: string, act: 'save' | 'start' | 'run', t: Say = english): string {
// This page's own refusals (a secret's name, an empty value) are already a reader's words.
if (e instanceof Error && !(e instanceof Refusal) && e.name === 'Error' && e.message) return e.message
if (e instanceof Error && !(e instanceof Refusal) && e.name === 'Error' && e.message) return told(e, t)
if (e instanceof Refusal) {
const why = e.message
if (e.status === 401) return 'Your session ended. Sign in again, then try again.'
if (e.status === 403 && act === 'save') return 'Only an organization admin saves an environment. Start the agent instead; an admin reviews what it proposes.'
if (e.status === 402) return 'This run needs a plan with runs left. See Plans to change it.'
if (e.status === 403) return `This account cannot start a run on ${repo}. Ask an organization admin for access.`
if (e.status === 429) return 'Too many runs are going right now. Try again in a minute.'
if (e.status >= 500) return act === 'save' ? 'The environment store is not answering right now. Try again shortly.' : 'The code workspace is not answering right now. Try again shortly.'
if (/no repository named|not one of this organization|is not a repository/.test(why)) return `${repo} is not a repository this organization has on the forge. Choose one of its repositories.`
if (/no account on the forge|verify .* first|no forge identity/.test(why)) return 'Your forge account is not ready yet: sign in to the forge once, then try again.'
if (e.status === 401) return t('Your session ended. Sign in again, then try again.')
if (e.status === 403 && act === 'save') return t('Only an organization admin saves an environment. Start the agent instead; an admin reviews what it proposes.')
if (e.status === 402) return t('This run needs a plan with runs left. See Plans to change it.')
if (e.status === 403) return t('This account cannot start a run on {repo}. Ask an organization admin for access.', { repo })
if (e.status === 429) return t('Too many runs are going right now. Try again in a minute.')
if (e.status >= 500) return act === 'save' ? t('The environment store is not answering right now. Try again shortly.') : t('The code workspace is not answering right now. Try again shortly.')
if (/no repository named|not one of this organization|is not a repository/.test(why)) return t('{repo} is not a repository this organization has on the forge. Choose one of its repositories.', { repo })
if (/no account on the forge|verify .* first|no forge identity/.test(why)) return t('Your forge account is not ready yet: sign in to the forge once, then try again.')
}
if (act === 'save') return `The environment for ${repo} could not be saved. Try again.`
return act === 'start' ? `The setup run on ${repo} could not start. Try again.` : `The run on ${repo} could not start. Try again.`
if (act === 'save') return t('The environment for {repo} could not be saved. Try again.', { repo })
return act === 'start' ? t('The setup run on {repo} could not start. Try again.', { repo }) : t('The run on {repo} could not start. Try again.', { repo })
}
export async function environments(t: Target): Promise<Environment[]> {
@@ -112,18 +117,18 @@ export async function forget(t: Target, repo: string): Promise<void> {
export const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/
const RESERVED = ['PATH', 'HOME', 'USER', 'SHELL', 'PWD', 'HOSTNAME', 'IFS', 'ENV', 'BASH_ENV']
/** Why a secret name cannot be set, or '' when it can. */
export function refuse(name: string): string {
if (!NAME.test(name)) return `${name || 'That'} is not an environment variable name`
/** Why a secret name cannot be set, in the words of the `t` handed, or '' when it can. */
export function refuse(name: string, t: Say = english): string {
if (!NAME.test(name)) return name ? t('{name} is not an environment variable name', { name }) : t('That is not an environment variable name')
const up = name.toUpperCase()
if (up.startsWith('HANZO_') || RESERVED.includes(up)) return `${name} is reserved for the run itself`
if (up.startsWith('HANZO_') || RESERVED.includes(up)) return t('{name} is reserved for the run itself', { name })
return ''
}
export async function setSecret(t: Target, repo: string, name: string, value: string): Promise<Environment> {
const why = refuse(name)
if (why) throw new Error(why)
if (!value) throw new Error('A secret needs a value')
if (!value) throw new Error(phrase('A secret needs a value'))
return environment(await call<unknown>(t, 'PUT', `/v1/environment/${seg(repo)}/secrets/${seg(name)}`, { value }), repo)
}
+4 -2
View File
@@ -20,6 +20,8 @@
* page; the console's /connectors otherwise) with `?complete=github&grant=<id>`,
* and that page completes it (`complete`, and `landed` in ../back.ts).
*/
import { phrase } from '@hanzo/i18n'
import { call, query, seg, type Target } from './call.ts'
import { codebases } from './codebases.ts'
import { links, type Link } from './links.ts'
@@ -169,7 +171,7 @@ export async function connection(t: Target): Promise<Connection> {
export async function connect(t: Target, back?: string): Promise<string> {
const raw = obj(await call<unknown>(t, 'POST', '/v1/provider/github/user/connect', back ? { return: back } : undefined))
const url = str(raw.authorizeUrl)
if (!/^https:\/\/github\.com\//.test(url)) throw new Error('The platform did not name a GitHub address to connect at')
if (!/^https:\/\/github\.com\//.test(url)) throw new Error(phrase('The platform did not name a GitHub address to connect at'))
return url
}
@@ -317,7 +319,7 @@ export interface Queued {
/** Queue a mirror of the named repositories onto the forge. The names are `owner/name`. */
export async function bring(t: Target, repos: string[]): Promise<Queued> {
const names = repos.map((n) => n.trim()).filter(Boolean)
if (names.length === 0) throw new Error('Choose a repository first')
if (names.length === 0) throw new Error(phrase('Choose a repository first'))
const raw = obj(await call<unknown>(t, 'POST', '/v1/provider/github/repos/import', { repos: names }))
const said = Array.isArray(raw.repos) ? raw.repos : []
const made = Array.isArray(raw.codebases) ? raw.codebases : []
+5 -3
View File
@@ -12,7 +12,9 @@
* whole. A limit only narrows what a key may reach — `model:zen5`,
* `project:acme`, `product:train`, `read:*`.
*/
import { call, type Target } from './call.ts'
import { phrase } from '@hanzo/i18n'
import { call, Sentence, type Target } from './call.ts'
export const KINDS = ['secret', 'publishable'] as const
export type Kind = (typeof KINDS)[number]
@@ -78,7 +80,7 @@ export function limits(raw: string): string[] {
.map((s) => s.trim())
.filter(Boolean)
const bad = out.find((s) => !LIMIT.test(s))
if (bad) throw new Error(`${bad} is not a limit — write it as kind:name, like model:zen5`)
if (bad) throw new Sentence(phrase('{limit} is not a limit — write it as kind:name, like model:zen5'), { limit: bad })
return out
}
@@ -89,7 +91,7 @@ export async function mint(t: Target, type: Kind, name = '', limit: string[] = [
if (limit.length) body.limit = limit
const r = await call<Record<string, unknown>>(t, 'POST', '/v1/account/keys', body)
const key = str(r?.key)
if (!key) throw new Error('The platform answered no key')
if (!key) throw new Error(phrase('The platform answered no key'))
return { id: str(r?.id), name: str(r?.name), type: kind(r?.type) ?? type, key, limit: list(r?.limit) }
}
+24 -22
View File
@@ -12,22 +12,24 @@
* shares it, and changes it never. Its `url`, `download` and `preview` are
* signed and stop working at `expires`: read the item again for fresh ones.
*/
import { call, query, seg, type Target } from './call.ts'
import { phrase } from '@hanzo/i18n'
import { call, query, seg, Sentence, type Target } from './call.ts'
export const KIND_IDS = ['image', 'video', 'audio', 'document', 'slides', 'type', 'model', 'code', 'file'] as const
export type Kind = (typeof KIND_IDS)[number]
/** The kinds a reader narrows to, in the order the Library lists them, '' being all. */
/** The kinds a reader narrows to, in the order the Library lists them, '' being all; each label English and marked (`phrase`), drawn through `t`. */
export const KINDS: readonly { id: Kind | ''; label: string }[] = [
{ id: '', label: 'All' },
{ id: 'image', label: 'Images' },
{ id: 'video', label: 'Videos' },
{ id: 'audio', label: 'Audio' },
{ id: 'document', label: 'Documents' },
{ id: 'slides', label: 'Slides' },
{ id: 'model', label: '3D' },
{ id: 'code', label: 'Code' },
{ id: 'file', label: 'Files' },
{ id: '', label: phrase('All') },
{ id: 'image', label: phrase('Images') },
{ id: 'video', label: phrase('Videos') },
{ id: 'audio', label: phrase('Audio') },
{ id: 'document', label: phrase('Documents') },
{ id: 'slides', label: phrase('Slides') },
{ id: 'model', label: phrase('3D') },
{ id: 'code', label: phrase('Code') },
{ id: 'file', label: phrase('Files') },
]
export const SOURCES = ['chat', 'dev', 'studio', 'workflow'] as const
@@ -35,8 +37,8 @@ export type Source = (typeof SOURCES)[number]
export type Scope = 'all' | 'mine' | 'shared'
/** A product, as a person names it. */
export const SOURCE: Record<Source, string> = { chat: 'Chat', dev: 'Dev', studio: 'Studio', workflow: 'Workflow' }
/** A product, as a person names it: Chat, Dev and Studio are names, and Workflow a word, marked (`phrase`). */
export const SOURCE: Record<Source, string> = { chat: 'Chat', dev: 'Dev', studio: 'Studio', workflow: phrase('Workflow') }
export interface Item {
id: string
@@ -165,7 +167,7 @@ export async function items(t: Target, f: Filter = {}, init?: { signal?: AbortSi
export async function get(t: Target, id: string): Promise<Item> {
const x = item(await call<unknown>(t, 'GET', `/v1/library/${seg(id)}`))
if (!x) throw new Error('The item came back without an id')
if (!x) throw new Error(phrase('The item came back without an id'))
return x
}
@@ -196,8 +198,8 @@ export function base64(bytes: Uint8Array): string {
/** Keep a file in the Library. */
export async function upload(t: Target, file: { name: string; type: string; bytes: Uint8Array }, made: Made): Promise<Item> {
if (!file.bytes.length) throw new Error(`${file.name} is empty`)
if (file.bytes.length > INLINE) throw new Error(`${file.name} is larger than 10 MB`)
if (!file.bytes.length) throw new Sentence(phrase('{name} is empty'), { name: file.name })
if (file.bytes.length > INLINE) throw new Sentence(phrase('{name} is larger than 10 MB'), { name: file.name })
const x = item(
await call<unknown>(t, 'POST', '/v1/library', {
name: file.name,
@@ -206,7 +208,7 @@ export async function upload(t: Target, file: { name: string; type: string; byte
...made,
}),
)
if (!x) throw new Error('The item was kept and came back without an id')
if (!x) throw new Error(phrase('The item was kept and came back without an id'))
return x
}
@@ -218,7 +220,7 @@ export interface Change {
export async function change(t: Target, id: string, c: Change): Promise<Item> {
const x = item(await call<unknown>(t, 'PATCH', `/v1/library/${seg(id)}`, c))
if (!x) throw new Error('The item was changed and came back without an id')
if (!x) throw new Error(phrase('The item was changed and came back without an id'))
return x
}
@@ -239,14 +241,14 @@ export function size(bytes: number): string {
return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[u]}`
}
/** The day an instant falls on, as a section of the Library: Today, Yesterday, then the month. */
/** The day an instant falls on, as a section of the Library: Today, Yesterday (English, marked), then the month. */
export function day(iso: string, now = new Date()): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return 'Earlier'
if (Number.isNaN(d.getTime())) return phrase('Earlier')
const start = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
const days = Math.round((start(now) - start(d)) / 86_400_000)
if (days <= 0) return 'Today'
if (days === 1) return 'Yesterday'
if (days <= 0) return phrase('Today')
if (days === 1) return phrase('Yesterday')
return d.toLocaleDateString(undefined, d.getFullYear() === now.getFullYear() ? { month: 'long' } : { month: 'long', year: 'numeric' })
}
+7 -5
View File
@@ -13,6 +13,8 @@
* said as that rule. `hanzo link`, run on the machine, registers it under its
* hostname and keeps it online while it runs.
*/
import { phrase } from '@hanzo/i18n'
import { call, Refusal, seg, type Target } from './call.ts'
export const KINDS = ['laptop', 'gpu', 'cloud', 'cluster', 'machine'] as const
@@ -212,7 +214,7 @@ async function owned<T>(p: Promise<T>): Promise<T> {
try {
return await p
} catch (e) {
if (e instanceof Refusal && e.status === 404) throw new Refusal(404, 'This machine is gone, or not yours to change: only the member who linked it, or an org admin, can.')
if (e instanceof Refusal && e.status === 404) throw new Refusal(404, phrase('This machine is gone, or not yours to change: only the member who linked it, or an org admin, can.'))
throw e
}
}
@@ -224,7 +226,7 @@ async function owned<T>(p: Promise<T>): Promise<T> {
export async function add(t: Target, what: { label: string; kind: Kind; host?: string; capacity?: string }): Promise<Machine> {
const host = (what.host ?? '').trim()
const label = what.label.trim() || host
if (!label) throw new Error('A machine needs a name or a hostname')
if (!label) throw new Error(phrase('A machine needs a name or a hostname'))
const body: Record<string, unknown> = { label, kind: what.kind }
if (host) body.host = host
if (what.capacity?.trim()) body.capacity = what.capacity.trim()
@@ -237,7 +239,7 @@ export async function add(t: Target, what: { label: string; kind: Kind; host?: s
* once one has; before that it is `not seen yet`, and nothing is there to take a run.
*/
export function state(m: Machine): string {
return m.status === 'online' && !m.seen ? 'not seen yet' : m.status
return m.status === 'online' && !m.seen ? phrase('not seen yet') : m.status
}
/** Rename a machine, or drain it (`draining`) and bring it back (`online`). */
@@ -249,7 +251,7 @@ export async function change(
const body: Record<string, unknown> = {}
if (what.label !== undefined) {
const label = what.label.trim()
if (!label) throw new Error('A machine needs a name')
if (!label) throw new Error(phrase('A machine needs a name'))
body.label = label
}
if (what.status !== undefined) body.status = what.status
@@ -271,6 +273,6 @@ export async function remove(t: Target, id: string): Promise<void> {
export async function key(t: Target, id: string): Promise<string> {
const r = await owned(call<{ claimKey?: unknown }>(t, 'POST', `/v1/agent/targets/${seg(id)}/key`))
const k = str(r?.claimKey)
if (!k) throw new Error('The platform answered no key')
if (!k) throw new Error(phrase('The platform answered no key'))
return k
}
+4 -2
View File
@@ -7,6 +7,8 @@
* starts a server the first time it calls it; the server stays up afterwards.
* `describe` is how a caller reads one operation's schema, so it is not a server.
*/
import { phrase } from '@hanzo/i18n'
import { call, Refusal, type Target } from './call.ts'
export interface Native {
@@ -23,11 +25,11 @@ export function nativesOf(body: unknown): Native[] {
const err = root.error
if (err && typeof err === 'object') {
const message = (err as { message?: unknown }).message
throw new Refusal(200, typeof message === 'string' && message ? message : 'The MCP server refused the list')
throw new Refusal(200, typeof message === 'string' && message ? message : phrase('The MCP server refused the list'))
}
const result = root.result && typeof root.result === 'object' ? (root.result as Record<string, unknown>) : null
const tools = result && Array.isArray(result.tools) ? result.tools : null
if (!tools) throw new Refusal(502, 'The MCP server did not list its tools')
if (!tools) throw new Refusal(502, phrase('The MCP server did not list its tools'))
const out: Native[] = []
for (const raw of tools) {
if (!raw || typeof raw !== 'object') continue
+5 -3
View File
@@ -13,7 +13,9 @@
* An invitation is a code IAM redeems when someone signs up with it; IAM sends
* no email of its own.
*/
import { call, query, seg, type Target } from './call.ts'
import { phrase } from '@hanzo/i18n'
import { call, query, seg, Sentence, type Target } from './call.ts'
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
@@ -47,7 +49,7 @@ export function member(raw: unknown): Member {
/** The org's people, owners first. Grants to a team or narrowed to one project are not the org's roster. */
export async function roster(t: Target, org: string): Promise<Member[]> {
const raw = obj(await call<unknown>(t, 'GET', `/v1/iam/memberships${query({ org })}`))
if (raw.status === 'error') throw new Error(str(raw.msg) || 'IAM refused the roster')
if (raw.status === 'error') throw new Error(str(raw.msg) || phrase('IAM refused the roster'))
const rank = (r: Role) => ROLES.indexOf(r)
return arr(raw.data)
.map(obj)
@@ -102,7 +104,7 @@ function hex(bytes: number): string {
/** Invite one address into the org: one seat, redeemable now, pinned to that address. */
export async function invite(t: Target, org: string, email: string): Promise<Invitation> {
const address = email.trim().toLowerCase()
if (!EMAIL.test(address)) throw new Error(`${email.trim() || 'That'} is not an email address`)
if (!EMAIL.test(address)) throw email.trim() ? new Sentence(phrase('{email} is not an email address'), { email: email.trim() }) : new Error(phrase('That is not an email address'))
const local = address.slice(0, address.indexOf('@')).replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24)
return invitation(
await call<unknown>(t, 'POST', '/v1/iam/invitations', {
+3 -1
View File
@@ -9,6 +9,8 @@
* request, so these read and change the caller's own and nobody else's. A
* refusal arrives in the envelope, usually under a 200.
*/
import { phrase } from '@hanzo/i18n'
import { call, query, unwrap, type Target } from './call.ts'
export interface Memory {
@@ -38,7 +40,7 @@ export async function memories(t: Target, limit = 100): Promise<Memory[]> {
export async function remember(t: Target, content: string): Promise<Memory | null> {
const said = content.trim()
if (!said) throw new Error('Say what Hanzo should remember')
if (!said) throw new Error(phrase('Say what Hanzo should remember'))
return memoryOf(unwrap(await call<unknown>(t, 'POST', '/v1/ai/memory/remember', { content: said })))
}
+4 -1
View File
@@ -7,6 +7,8 @@
* names no target — so it is the first place here and carries no id. A machine
* can take a run only while it is online.
*/
import { phrase } from '@hanzo/i18n'
import type { Target } from './call.ts'
import { machines, state } from './machines.ts'
@@ -22,7 +24,8 @@ export interface Place {
capacity: string
}
export const SANDBOX: Place = { id: '', label: 'Hanzo sandbox', kind: 'sandbox', status: 'online', capacity: '' }
/** The sandbox, named in English and marked (`phrase`): a screen draws its label through `t`. */
export const SANDBOX: Place = { id: '', label: phrase('Hanzo sandbox'), kind: 'sandbox', status: 'online', capacity: '' }
export async function places(t: Target): Promise<Place[]> {
const list = await machines(t)
+5 -3
View File
@@ -13,6 +13,8 @@
* credential: it names the connectors provider whose credential it reads when it
* runs. The mounted list is the deployment's own subsystems, read-only.
*/
import { phrase } from '@hanzo/i18n'
import { call, seg, type Target } from './call.ts'
export interface Plugin {
@@ -75,10 +77,10 @@ export interface Build {
spec: string
}
/** Why a plugin cannot be built as it stands, or '' when it can. */
/** Why a plugin cannot be built as it stands, or '' when it can. English, marked (`phrase`): a screen says it through `t`. */
export function refuse(b: Build): string {
if (!NAME.test(b.name)) return 'A name is one lowercase word: letters, digits, _ or -'
if (!b.source.trim() === !b.spec.trim()) return 'Give the TypeScript or the API it calls, one of the two'
if (!NAME.test(b.name)) return phrase('A name is one lowercase word: letters, digits, _ or -')
if (!b.source.trim() === !b.spec.trim()) return phrase('Give the TypeScript or the API it calls, one of the two')
return ''
}
+6 -4
View File
@@ -8,6 +8,8 @@
* Both act on the caller alone: neither request names a person. The email is a
* sign-in identifier and changes only through IAM's verification, never here.
*/
import { phrase } from '@hanzo/i18n'
import { call, headers, reason, Refusal, unwrap, type Target } from './call.ts'
export interface Profile {
@@ -21,7 +23,7 @@ const obj = (v: unknown): Record<string, unknown> => (v && typeof v === 'object'
/** Save the name the person is shown by. An empty name is refused here. */
export async function rename(t: Target, name: string): Promise<Profile> {
const displayName = name.trim()
if (!displayName) throw new Error('A name cannot be empty')
if (!displayName) throw new Error(phrase('A name cannot be empty'))
const p = obj(unwrap(await call<unknown>(t, 'PUT', '/v1/iam/account', { displayName })))
return { displayName: str(p.displayName), avatar: str(p.avatar) }
}
@@ -32,13 +34,13 @@ const LIMIT = 8 << 20
/** Store a new photo and answer its address. */
export async function photo(t: Target, file: Blob): Promise<string> {
if (!file.size) throw new Error('That file is empty')
if (file.size > LIMIT) throw new Error('A photo is at most 8 MB')
if (!file.size) throw new Error(phrase('That file is empty'))
if (file.size > LIMIT) throw new Error(phrase('A photo is at most 8 MB'))
const form = new FormData()
form.append('file', file)
const res = await fetch(`${t.api}/v1/account/avatar`, { method: 'POST', headers: headers(t), body: form, cache: 'no-store' })
if (!res.ok) throw new Refusal(res.status, (await reason(res)) || `POST /v1/account/avatar answered ${res.status}`)
const url = str(obj(await res.json()).avatar)
if (!url.startsWith('https://')) throw new Refusal(502, 'The photo was stored without an address this page can show')
if (!url.startsWith('https://')) throw new Refusal(502, phrase('The photo was stored without an address this page can show'))
return url
}
+3 -1
View File
@@ -12,6 +12,8 @@
* deletes it. Private is paid, and an unfunded org is refused rather than left
* public without being told.
*/
import { phrase } from '@hanzo/i18n'
import { call, seg, type Target } from './call.ts'
export interface Project {
@@ -93,7 +95,7 @@ export async function change(t: Target, slug: string, what: { name?: string; vis
const body: { name?: string; visibility?: Visibility } = {}
if (what.name !== undefined) {
const n = what.name.trim()
if (!n) throw new Error('A project needs a name')
if (!n) throw new Error(phrase('A project needs a name'))
body.name = n
}
if (what.visibility !== undefined) body.visibility = what.visibility
+13 -10
View File
@@ -11,7 +11,9 @@
* Connecting leaves this page once, for the provider's consent screen, and only
* for that provider's own host.
*/
import { call, query, seg, type Target } from './call.ts'
import { phrase } from '@hanzo/i18n'
import { call, query, seg, Sentence, type Target } from './call.ts'
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const obj = (v: unknown): Record<string, unknown> => (v && typeof v === 'object' ? (v as Record<string, unknown>) : {})
@@ -63,25 +65,26 @@ const CONSENT: Record<string, RegExp> = {
* The social accounts an organization connects. An automation posts text to
* the ones that take it; Instagram and TikTok take only images and video, and
* say so. `needs` is what connecting waits on while Hanzo's app with the
* platform is not set up.
* platform is not set up. Its sentences are English and marked (`phrase`):
* a screen draws them through `t`.
*/
export const SOCIAL: readonly { id: string; name: string; posts: boolean; needs: string; takes?: string }[] = [
{ id: 'x', name: 'X', posts: true, needs: 'X posting needs Hanzo’s X developer app with paid API access; it is not set up yet.' },
{ id: 'linkedin', name: 'LinkedIn', posts: true, needs: 'LinkedIn posting needs Hanzo’s LinkedIn app with Share on LinkedIn; it is not set up yet.' },
{ id: 'facebook', name: 'Facebook', posts: true, needs: 'Facebook Page posting needs Hanzo’s Meta app, approved by Meta App Review to publish to Pages; it is not set up yet.' },
{ id: 'x', name: 'X', posts: true, needs: phrase('X posting needs Hanzo’s X developer app with paid API access; it is not set up yet.') },
{ id: 'linkedin', name: 'LinkedIn', posts: true, needs: phrase('LinkedIn posting needs Hanzo’s LinkedIn app with Share on LinkedIn; it is not set up yet.') },
{ id: 'facebook', name: 'Facebook', posts: true, needs: phrase('Facebook Page posting needs Hanzo’s Meta app, approved by Meta App Review to publish to Pages; it is not set up yet.') },
{
id: 'instagram',
name: 'Instagram',
posts: false,
needs: 'Instagram posting needs Hanzo’s Meta app, approved by Meta App Review; it is not set up yet.',
takes: 'Instagram takes photos and reels, not text, so automations do not post to it.',
needs: phrase('Instagram posting needs Hanzo’s Meta app, approved by Meta App Review; it is not set up yet.'),
takes: phrase('Instagram takes photos and reels, not text, so automations do not post to it.'),
},
{
id: 'tiktok',
name: 'TikTok',
posts: false,
needs: 'TikTok posting needs TikTok’s audit of Hanzo’s app; it is not set up yet.',
takes: 'TikTok takes videos and photos, not text, so automations do not post to it.',
needs: phrase('TikTok posting needs TikTok’s audit of Hanzo’s app; it is not set up yet.'),
takes: phrase('TikTok takes videos and photos, not text, so automations do not post to it.'),
},
]
@@ -89,7 +92,7 @@ export const SOCIAL: readonly { id: string; name: string; posts: boolean; needs:
export async function authorize(t: Target, id: string, back?: string): Promise<string> {
const url = str(obj(await call<unknown>(t, 'POST', `/v1/provider/${seg(id)}/connect`, back ? { return: back } : {})).authorizeUrl)
const host = CONSENT[id]
if (!host || !host.test(url)) throw new Error(`The platform did not name a ${id} address to connect at`)
if (!host || !host.test(url)) throw new Sentence(phrase('The platform did not name a {provider} address to connect at'), { provider: id })
return url
}
+6 -4
View File
@@ -15,6 +15,8 @@
* A door or a preview asked of a sandbox that stopped as it was asked is 409: it
* is resumed, and asked again once.
*/
import { phrase } from '@hanzo/i18n'
import { call, Refusal, seg, type Target } from './call.ts'
export type Door = 'screen' | 'terminal'
@@ -72,7 +74,7 @@ async function up(t: Target, id: string): Promise<void> {
await new Promise((r) => setTimeout(r, 1500))
b = await sandbox(t, id)
}
if (b.state !== 'running') throw new Refusal(503, b.error || 'The sandbox did not start again. Try again.')
if (b.state !== 'running') throw new Refusal(503, b.error || phrase('The sandbox did not start again. Try again.'))
}
/** Asks what needs a running sandbox; a parked one is resumed first. */
@@ -93,7 +95,7 @@ export const NAME = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$/
export async function door(t: Target, id: string, which: Door, session = ''): Promise<string> {
const r = await running(t, id, () => call<{ url?: unknown }>(t, 'POST', `/v1/sandbox/${seg(id)}/${which}/ticket`))
const path = typeof r?.url === 'string' ? r.url : ''
if (!path.startsWith('/v1/')) throw new Refusal(502, 'The sandbox answered with no address to open')
if (!path.startsWith('/v1/')) throw new Refusal(502, phrase('The sandbox answered with no address to open'))
const arg = which === 'terminal' && NAME.test(session) ? `&arg=${session}` : ''
return `${t.api}${path}${arg}`
}
@@ -130,9 +132,9 @@ export async function preview(t: Target, id: string, port: number): Promise<stri
try {
url = new URL(typeof r?.url === 'string' ? r.url : '')
} catch {
throw new Refusal(502, 'The sandbox answered with no address to open')
throw new Refusal(502, phrase('The sandbox answered with no address to open'))
}
if (url.protocol !== 'https:') throw new Refusal(502, 'The sandbox answered with no address to open')
if (url.protocol !== 'https:') throw new Refusal(502, phrase('The sandbox answered with no address to open'))
return url.href
}
+6 -4
View File
@@ -15,6 +15,8 @@
* falls 256 frames behind is dropped. So `watch` reconnects, and every
* reconnect is reported, which is the caller's cue to re-read the detail.
*/
import { phrase } from '@hanzo/i18n'
import { call, headers, query, Refusal, seg, type Target } from './call.ts'
import { read } from './sse.ts'
@@ -165,7 +167,7 @@ export async function get(t: Target, id: string): Promise<Detail> {
export async function message(t: Target, id: string, text: string): Promise<void> {
const m = text.trim()
if (!m) throw new Refusal(400, 'Say something to the run')
if (!m) throw new Refusal(400, phrase('Say something to the run'))
await call<unknown>(t, 'POST', `/v1/agent/sessions/${seg(id)}/message`, { message: m })
}
@@ -184,8 +186,8 @@ export async function resume(t: Target, id: string): Promise<void> {
/** A new title, up to the platform's 512 characters. */
export async function rename(t: Target, id: string, title: string): Promise<Session> {
const name = title.trim()
if (!name) throw new Refusal(400, 'Give the run a name')
if (name.length > 512) throw new Refusal(400, 'A run’s name is at most 512 characters')
if (!name) throw new Refusal(400, phrase('Give the run a name'))
if (name.length > 512) throw new Refusal(400, phrase('A run’s name is at most 512 characters'))
return session(await call<unknown>(t, 'PATCH', `/v1/agent/sessions/${seg(id)}`, { title: name }))
}
@@ -223,7 +225,7 @@ export async function watch(t: Target, root: string, on: Watch, signal: AbortSig
cache: 'no-store',
signal,
})
if (res.status === 401 || res.status === 403) throw new Refusal(res.status, 'This account cannot follow this run')
if (res.status === 401 || res.status === 403) throw new Refusal(res.status, phrase('This account cannot follow this run'))
if (!res.ok || !res.body) throw new Error(`stream answered ${res.status}`)
on.open?.(opened++)
fails = 0
+6 -4
View File
@@ -12,6 +12,8 @@
* that is how a catalogue skill is added to an org and how an org's own skill is
* switched off. The brand's skill wins a name an org's own skill also takes.
*/
import { phrase } from '@hanzo/i18n'
import { call, reason, Refusal, seg, type Target } from './call.ts'
import { toolsOf, type Tool } from './tools.ts'
@@ -100,11 +102,11 @@ export async function authored(t: Target): Promise<Skill[]> {
return rows(obj(await call<unknown>(t, 'GET', '/v1/tool/skills/authored')).skills).map(skill).filter((s) => s.name)
}
/** Why a skill cannot be written as it stands, or '' when it can. */
/** Why a skill cannot be written as it stands, or '' when it can. English, marked (`phrase`): a screen says it through `t`. */
export function refuse(s: { name: string; content: string }): string {
if (!NAME.test(s.name)) return 'A name is one lowercase word: letters, digits, _ or -'
if (!s.content.trim()) return 'A skill needs its SKILL.md'
if (new TextEncoder().encode(s.content).length > MAX) return 'A SKILL.md is at most 256 KB'
if (!NAME.test(s.name)) return phrase('A name is one lowercase word: letters, digits, _ or -')
if (!s.content.trim()) return phrase('A skill needs its SKILL.md')
if (new TextEncoder().encode(s.content).length > MAX) return phrase('A SKILL.md is at most 256 KB')
return ''
}
+7 -5
View File
@@ -12,7 +12,9 @@
* signed and expires, their `origin` is the app path that opens the run in
* Studio and does not.
*/
import { call, Refusal, seg, type Target } from './call.ts'
import { phrase } from '@hanzo/i18n'
import { call, Refusal, seg, Sentence, type Target } from './call.ts'
import { item, type Item } from './library.ts'
export interface Run {
@@ -119,23 +121,23 @@ const pause = (ms: number, signal?: AbortSignal) =>
export async function make(t: Target, s: Start, signal?: AbortSignal): Promise<Run> {
let r = run(
await call<unknown>(t, 'POST', '/v1/studio/runs', s, { signal }).catch((e: unknown) => {
throw e instanceof Refusal && e.status === 402 ? new Error('Your balance does not cover it. Add credit in Settings › Billing.') : e
throw e instanceof Refusal && e.status === 402 ? new Error(phrase('Your balance does not cover it. Add credit in Settings › Billing.')) : e
}),
)
if (!r.id) throw new Error('Studio started the run and named no id')
if (!r.id) throw new Error(phrase('Studio started the run and named no id'))
const id = r.id
const stop = () => void call(t, 'POST', `/v1/studio/runs/${seg(id)}/cancel`, {}).catch(() => undefined)
signal?.addEventListener('abort', stop, { once: true })
try {
const until = Date.now() + LONGEST
while (!ended(r)) {
if (Date.now() > until) throw new Error('Studio is still making it. It will be in your Library when it is done.')
if (Date.now() > until) throw new Error(phrase('Studio is still making it. It will be in your Library when it is done.'))
await pause(EVERY, signal)
r = run(await call<unknown>(t, 'GET', `/v1/studio/runs/${seg(id)}`, undefined, { signal }))
}
} finally {
signal?.removeEventListener('abort', stop)
}
if (r.status !== 'succeeded') throw new Error(r.error || `Studio could not make it (${r.status})`)
if (r.status !== 'succeeded') throw r.error ? new Error(r.error) : new Sentence(phrase('Studio could not make it ({status})'), { status: r.status })
return r
}
+85 -49
View File
@@ -20,7 +20,17 @@
*
* `cards` is the transcript as it is drawn: the run's steps and the agent's
* items, never its log.
*
* The sentences are this client's, said in the language of the `t` a caller
* hands (@hanzo/i18n; English without one); what the run and the agent said
* is drawn as given. A step's name is a name the transcript keys on (`Error`
* opens itself), so it stays English, marked (`phrase`), and the transcript
* draws it through `t`.
*/
import { phrase } from '@hanzo/i18n'
import type { Say } from '@hanzo/i18n/react'
import { english } from '../say.ts'
import type { Event } from './sessions.ts'
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
@@ -49,37 +59,63 @@ const base = (path: string): string => {
return cut.slice(cut.lastIndexOf('/') + 1)
}
function status(b: Record<string, unknown>, mode: string): string {
/**
* A finished run's line, by whether it pushed a branch (Done when it did not),
* its pull request (none, one that could not be opened, its address) and its
* site (none, not published and why, published): each a whole line, so a
* language puts the parts in its own order.
*/
const FINISHED: Record<'pushed' | 'done', Record<'none' | 'broke' | 'pr', Record<'none' | 'out' | 'live', string>>> = {
pushed: {
none: { none: phrase('Pushed {branch}'), out: phrase('Pushed {branch} — not published: {why}'), live: phrase('Pushed {branch} — published') },
broke: {
none: phrase('Pushed {branch} — the pull request could not be opened'),
out: phrase('Pushed {branch} — the pull request could not be opened — not published: {why}'),
live: phrase('Pushed {branch} — the pull request could not be opened — published'),
},
pr: { none: phrase('Pushed {branch} — {pr}'), out: phrase('Pushed {branch} — {pr} — not published: {why}'), live: phrase('Pushed {branch} — {pr} — published') },
},
done: {
none: { none: phrase('Done'), out: phrase('Done — not published: {why}'), live: phrase('Done — published') },
broke: {
none: phrase('Done — the pull request could not be opened'),
out: phrase('Done — the pull request could not be opened — not published: {why}'),
live: phrase('Done — the pull request could not be opened — published'),
},
pr: { none: phrase('Done — {pr}'), out: phrase('Done — {pr} — not published: {why}'), live: phrase('Done — {pr} — published') },
},
}
function status(b: Record<string, unknown>, mode: string, t: Say): string {
const branch = str(b.branch)
switch (str(b.status)) {
case 'started':
return branch ? `Started on ${branch}` : 'Started'
return branch ? t('Started on {branch}', { branch }) : t('Started')
case 'routed':
return 'Sent to a machine'
return t('Sent to a machine')
case 'done': {
// A plan's answer IS its final status: the run read and wrote nothing.
// Whether the run planned is the RECORD's word — any member of the org
// can append an event saying `mode: plan`, and its text is not an answer.
// A setup is a plan put to one question, and answers the same way.
if (mode === 'plan') return str(b.plan) || 'Planned — the run answered with no plan'
if (mode === 'setup') return str(b.plan) || 'Set up — the run answered with no environment'
if (b.changed === false) return 'Done — nothing to change'
if (mode === 'plan') return str(b.plan) || t('Planned — the run answered with no plan')
if (mode === 'setup') return str(b.plan) || t('Set up — the run answered with no environment')
if (b.changed === false) return t('Done — nothing to change')
const pr = str(b.pr)
const tail = str(b.prError) ? ' — the pull request could not be opened' : pr ? ` — ${pr}` : ''
// A run on a project with a site publishes what it pushed, or says why not.
const why = str(b.unpublished)
const site = why ? ` — not published: ${why}` : str(b.live) ? ' — published' : ''
return branch ? `Pushed ${branch}${tail}${site}` : `Done${tail}${site}`
const line = FINISHED[branch ? 'pushed' : 'done'][str(b.prError) ? 'broke' : pr ? 'pr' : 'none'][why ? 'out' : str(b.live) ? 'live' : 'none']
return t(line, { branch, pr, why })
}
case 'error':
return plain(str(b.error))
return plain(str(b.error), t)
case 'stopped':
return branch && b.changed ? `Stopped — work kept on ${branch}` : 'Stopped'
return branch && b.changed ? t('Stopped — work kept on {branch}', { branch }) : t('Stopped')
case 'paused':
return branch && b.changed ? `Paused — work kept on ${branch}` : 'Paused'
return branch && b.changed ? t('Paused — work kept on {branch}', { branch }) : t('Paused')
case 'followed':
// A run redirected from a chat thread is carried on by a follow-up (apps/coding steer.go).
return str(b.next) ? 'Continued in a follow-up run' : 'The follow-up run could not start'
return str(b.next) ? t('Continued in a follow-up run') : t('The follow-up run could not start')
}
return str(b.status)
}
@@ -95,31 +131,31 @@ export function vendor(error: string): boolean {
}
/** An error as the run page may show it: its own words, or the plain line when they are a vendor's. */
export const scrub = (error: string): string => (vendor(error) ? plain(error) : error)
export const scrub = (error: string, t: Say = english): string => (vendor(error) ? plain(error, t) : error)
/**
* A run's error as one plain line with what to do, never the log it came from:
* that goes in the Error step beside it (cards), unless it is a vendor's.
*/
export function plain(error: string): string {
if (!error) return 'The run hit an error'
export function plain(error: string, t: Say = english): string {
if (!error) return t('The run hit an error')
const ours = /providers_exhausted|every provider refused|temporarily unavailable/i.test(error)
if (/free sandboxes .*are used/i.test(error)) return 'This plan’s free sandboxes are used for now. Add credit to keep building, or try again later.'
if (!ours && /\b402 Payment Required|insufficient (credit|balance)|top up/i.test(error)) return 'Your balance is out of credit. Add credit, then try again.'
if (/free sandboxes .*are used/i.test(error)) return t('This plan’s free sandboxes are used for now. Add credit to keep building, or try again later.')
if (!ours && /\b402 Payment Required|insufficient (credit|balance)|top up/i.test(error)) return t('Your balance is out of credit. Add credit, then try again.')
if (ours || /\b(429 Too Many Requests|503 Service Unavailable)|stream disconnected|retries exhausted|rate limit/i.test(error))
return 'The model did not answer. Try again in a moment, or pick another model.'
return 'The run stopped with an error. Try again, or follow up with what to change.'
return t('The model did not answer. Try again in a moment, or pick another model.')
return t('The run stopped with an error. Try again, or follow up with what to change.')
}
/**
* One event's sentence, or '' when it says nothing a person needs. `mode` is
* the run's, from its record: a plan run's final status says the plan.
*/
export function said(e: Pick<Event, 'kind' | 'payload'>, mode = ''): string {
export function said(e: Pick<Event, 'kind' | 'payload'>, mode = '', t: Say = english): string {
const body = decode(e.payload)
if (typeof body === 'string') return body
if (!body) return ''
if (e.kind === 'status') return status(body, mode)
if (e.kind === 'status') return status(body, mode, t)
for (const key of ['message', 'text', 'content', 'result', 'command']) {
const v = body[key]
if (typeof v === 'string' && v) return v
@@ -127,12 +163,12 @@ export function said(e: Pick<Event, 'kind' | 'payload'>, mode = ''): string {
if (str(body.type) === 'done' && Array.isArray(body.changed)) {
const names = body.changed.map((f) => base(str(f))).filter(Boolean)
return names.length === 0
? 'Done — no files changed'
? t('Done — no files changed')
: names.length <= 3
? `Done — changed ${names.join(', ')}`
: `Done — changed ${names.length} files`
? t('Done — changed {files}', { files: names.join(', ') })
: t('Done — changed {n} files', { n: names.length })
}
if (str(body.type) === 'error') return scrub(str(body.error)) || 'The run hit an error'
if (str(body.type) === 'error') return scrub(str(body.error), t) || t('The run hit an error')
return str(body.step) || str(body.name)
}
@@ -239,13 +275,13 @@ export function outcome(events: Pick<Event, 'kind' | 'payload' | 'seq'>[]): Outc
}
/** Why the run failed, as its last status says it, or '' when that status is not a failure. */
export function failure(events: Pick<Event, 'kind' | 'payload' | 'seq'>[]): string {
export function failure(events: Pick<Event, 'kind' | 'payload' | 'seq'>[], t: Say = english): string {
let out = ''
for (const e of [...events].sort((a, b) => a.seq - b.seq)) {
if (e.kind !== 'status') continue
const b = decode(e.payload)
if (!b || typeof b === 'string' || !str(b.status)) continue
out = str(b.status) === 'error' ? scrub(str(b.error)) || 'The run hit an error' : ''
out = str(b.status) === 'error' ? scrub(str(b.error), t) || t('The run hit an error') : ''
}
return out
}
@@ -304,19 +340,19 @@ export type Card =
| { kind: 'note'; key: string; text: string }
| { kind: 'plan'; key: string; text: string }
/** What the run's own steps are called on a card. */
/** What the run's own steps are called on a card: English, marked (`phrase`); the transcript draws them through `t`. */
const NAMES: Record<string, string> = {
lease: 'Sandbox',
workspace: 'Workspace',
clone: 'Clone',
branch: 'Branch',
secrets: 'Secrets',
install: 'Install',
start: 'Start',
skills: 'Skills',
artifacts: 'Save artifacts',
push: 'Push',
kept: 'Keep the sandbox',
lease: phrase('Sandbox'),
workspace: phrase('Workspace'),
clone: phrase('Clone'),
branch: phrase('Branch'),
secrets: phrase('Secrets'),
install: phrase('Install'),
start: phrase('Start'),
skills: phrase('Skills'),
artifacts: phrase('Save artifacts'),
push: phrase('Push'),
kept: phrase('Keep the sandbox'),
}
/** Steps another card already says: `leased` finishes the lease, `done` is the final status, `ended` and `exit` are housekeeping. */
@@ -357,9 +393,9 @@ function item(it: Record<string, unknown>, key: string): Card | null {
return { kind: 'edit', key, files: changes.map((c) => rel(str(obj(c).path))).filter(Boolean), ran: itemRan(it) }
}
case 'mcp_tool_call':
return { kind: 'step', key, name: str(it.tool) || 'Tool', detail: str(it.server), output: '', ran: itemRan(it) }
return { kind: 'step', key, name: str(it.tool) || phrase('Tool'), detail: str(it.server), output: '', ran: itemRan(it) }
case 'web_search':
return { kind: 'step', key, name: 'Search', detail: str(it.query), output: '', ran: 'done' }
return { kind: 'step', key, name: phrase('Search'), detail: str(it.query), output: '', ran: 'done' }
case 'todo_list': {
const items = Array.isArray(it.items) ? it.items : []
return { kind: 'todo', key, items: items.map((x) => ({ text: str(obj(x).text), done: obj(x).completed === true })).filter((x) => x.text) }
@@ -381,7 +417,7 @@ function item(it: Record<string, unknown>, key: string): Card | null {
* is drawn only if the turn then fails. The final status carries the agent's
* answer, drawn once. `log` is never drawn here.
*/
export function cards(events: Pick<Event, 'kind' | 'payload' | 'seq'>[], mode = ''): Card[] {
export function cards(events: Pick<Event, 'kind' | 'payload' | 'seq'>[], mode = '', t: Say = english): Card[] {
const out: Card[] = []
let seq = 0
let n = 0
@@ -401,9 +437,9 @@ export function cards(events: Pick<Event, 'kind' | 'payload' | 'seq'>[], mode =
const failed = (message: string) => {
if (failures.has(message)) return
failures.add(message)
out.push({ kind: 'note', key: mint(), text: plain(message) })
out.push({ kind: 'note', key: mint(), text: plain(message, t) })
// The reason, read whole, when it is the run's own; a vendor's words stay off the page.
if (message && !vendor(message)) out.push({ kind: 'step', key: mint(), name: 'Error', detail: '', output: message, ran: 'error' })
if (message && !vendor(message)) out.push({ kind: 'step', key: mint(), name: phrase('Error'), detail: '', output: message, ran: 'error' })
}
for (const e of [...events].sort((a, b) => a.seq - b.seq)) {
seq = e.seq
@@ -472,7 +508,7 @@ export function cards(events: Pick<Event, 'kind' | 'payload' | 'seq'>[], mode =
failed(str(b.error))
break
}
const line = status(b, mode)
const line = status(b, mode, t)
if (line) out.push({ kind: 'note', key: mint(), text: line })
break
}
@@ -481,9 +517,9 @@ export function cards(events: Pick<Event, 'kind' | 'payload' | 'seq'>[], mode =
const command = str(b.command)
const said = str(b.message)
if (command === 'message' && said) steered.add(person(said))
else if (command === 'pause') out.push({ kind: 'note', key: mint(), text: 'Pause asked' })
else if (command === 'resume') out.push({ kind: 'note', key: mint(), text: 'Resume asked' })
else if (command === 'stop') out.push({ kind: 'note', key: mint(), text: 'Stop asked' })
else if (command === 'pause') out.push({ kind: 'note', key: mint(), text: t('Pause asked') })
else if (command === 'resume') out.push({ kind: 'note', key: mint(), text: t('Resume asked') })
else if (command === 'stop') out.push({ kind: 'note', key: mint(), text: t('Stop asked') })
break
}
case 'message': {
+3 -1
View File
@@ -7,6 +7,8 @@
* The receipt says whether it landed; a verdict that did not is reported, not
* shown as recorded.
*/
import { phrase } from '@hanzo/i18n'
import { call, type Target } from './call.ts'
export type Verdict = 'up' | 'down' | null
@@ -18,5 +20,5 @@ export async function verdict(t: Target, session: string, project: string, v: Ve
sessionId: session,
properties: { verdict: v ?? 'cleared', project },
})
if (!out || !out.accepted) throw new Error('The verdict was not recorded')
if (!out || !out.accepted) throw new Error(phrase('The verdict was not recorded'))
}
+6 -4
View File
@@ -11,6 +11,8 @@
* The org is the caller's; an endpoint another org owns reads as not found.
* Events are subject patterns (`commerce.order.>`); none means every event.
*/
import { phrase } from '@hanzo/i18n'
import { call, query, seg, type Target } from './call.ts'
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
@@ -65,16 +67,16 @@ export interface Draft {
description: string
}
/** Why a draft cannot be sent, or ''. The platform refuses the same things. */
/** Why a draft cannot be sent, or ''. The platform refuses the same things. English, marked (`phrase`): a screen says it through `t`. */
export function refuse(d: Draft): string {
let u: URL
try {
u = new URL(d.url.trim())
} catch {
return 'Enter the https address to deliver to'
return phrase('Enter the https address to deliver to')
}
if (u.protocol !== 'https:') return 'A webhook is delivered over https only'
if (d.events.length > 64) return 'At most 64 event patterns'
if (u.protocol !== 'https:') return phrase('A webhook is delivered over https only')
if (d.events.length > 64) return phrase('At most 64 event patterns')
return ''
}
+4 -2
View File
@@ -1,3 +1,4 @@
import { useI18n } from '@hanzo/i18n/react'
import { useIam } from '@hanzo/iam/react'
import { Box, Text, YStack } from '@hanzo/ui'
import { useEffect, useState } from 'react'
@@ -18,6 +19,7 @@ import { back } from './enter.ts'
*/
export function Callback() {
const go = useNavigate()
const { t } = useI18n()
const { handleCallback } = useIam()
const [error, setError] = useState<string | null>(null)
@@ -40,10 +42,10 @@ export function Callback() {
<Box flex={1} items="center" justify="center" bg="$background">
<YStack gap="$2" items="center">
<Text fontSize="$5" color="$ink">
{error ?? 'Completing sign-in…'}
{error ?? t('Completing sign-in…')}
</Text>
<Text fontSize="$3" color="$soft">
{error ? 'Try signing in again.' : 'One moment.'}
{error ? t('Try signing in again.') : t('One moment.')}
</Text>
</YStack>
</Box>
+8 -5
View File
@@ -4,6 +4,7 @@
* the reason is read where the act was asked for.
*/
import { SizableText, XStack } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import { Button, Dialog, DialogContent, DialogTitle, Input } from '@hanzo/ui'
import { useEffect, useState } from 'react'
@@ -52,6 +53,7 @@ export function Confirm({
act: string
run: () => Promise<void>
}) {
const { t } = useI18n()
const { working, note, setNote, go } = useAct(() => onOpenChange(false))
const close = (o: boolean) => {
if (working) return
@@ -68,7 +70,7 @@ export function Confirm({
<Said note={note} />
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="ghost" disabled={working} onPress={() => close(false)}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" variant="destructive" disabled={working} onPress={() => void go(run)}>
{act}
@@ -93,6 +95,7 @@ export function Rename({
name: string
run: (next: string) => Promise<void>
}) {
const { t } = useI18n()
const [value, setValue] = useState(name)
const { working, note, setNote, go } = useAct(() => onOpenChange(false))
useEffect(() => {
@@ -105,7 +108,7 @@ export function Rename({
}
const save = () => {
const next = value.trim()
if (!next) return setNote('It needs a name')
if (!next) return setNote(t('It needs a name'))
if (next === name) return close(false)
void go(() => run(next))
}
@@ -117,7 +120,7 @@ export function Rename({
autoFocus
value={value}
onChangeText={setValue}
aria-label="Name"
aria-label={t('Name')}
onKeyDown={(e: { key?: string }) => {
if (e.key === 'Enter') save()
}}
@@ -125,10 +128,10 @@ export function Rename({
<Said note={note} />
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="ghost" disabled={working} onPress={() => close(false)}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" disabled={working} onPress={save}>
Save
{t('Save')}
</Button>
</XStack>
</DialogContent>
+155 -134
View File
@@ -9,9 +9,15 @@
* own: a task is its person's, in the org the builder is in. A save sends only
* what changed; the switch sends `enabled` alone, so turning one on or off never
* changes who it runs as.
*
* Its words are the reader's language (@hanzo/i18n `t`); a task's name, its
* instructions, a run's summary and post and the platform's refusals are drawn
* as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { ChevronLeft } from '@hanzogui/lucide-icons-2'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { Button, Input, Switch, Textarea } from '@hanzo/ui'
import { StatusDot, type SessionStatus } from '@hanzo/ui/chat'
import { ModelPicker } from '@hanzo/ui/models'
@@ -24,7 +30,7 @@ import { current, subscriptions, type Subscription } from './api/billing.ts'
import { SOCIAL } from './api/provider.ts'
import { Out } from './out.tsx'
import { useSocial } from './settings/integrations.tsx'
import { Refusal } from './api/call.ts'
import { Refusal, told } from './api/call.ts'
import { ENSO, limits as readLimits, models } from './api/models.ts'
import { Choice, Confirm, Field, Line, Sheet } from './customize/ui.tsx'
import { useRead } from './data.ts'
@@ -41,41 +47,42 @@ export const zone = (): string => {
}
}
/** The lists' words are English, marked (`phrase`), and drawn through `t`. */
export const KINDS: readonly { id: Kind; label: string }[] = [
{ id: 'manual', label: 'When I run it' },
{ id: 'once', label: 'Once' },
{ id: 'hourly', label: 'Every hour' },
{ id: 'daily', label: 'Every day' },
{ id: 'weekdays', label: 'Weekdays' },
{ id: 'weekly', label: 'Every week' },
{ id: 'cron', label: 'Cron' },
{ id: 'manual', label: phrase('When I run it') },
{ id: 'once', label: phrase('Once') },
{ id: 'hourly', label: phrase('Every hour') },
{ id: 'daily', label: phrase('Every day') },
{ id: 'weekdays', label: phrase('Weekdays') },
{ id: 'weekly', label: phrase('Every week') },
{ id: 'cron', label: phrase('Cron') },
]
const DAYS = [
{ id: 'mon', label: 'Monday' },
{ id: 'tue', label: 'Tuesday' },
{ id: 'wed', label: 'Wednesday' },
{ id: 'thu', label: 'Thursday' },
{ id: 'fri', label: 'Friday' },
{ id: 'sat', label: 'Saturday' },
{ id: 'sun', label: 'Sunday' },
{ id: 'mon', label: phrase('Monday') },
{ id: 'tue', label: phrase('Tuesday') },
{ id: 'wed', label: phrase('Wednesday') },
{ id: 'thu', label: phrase('Thursday') },
{ id: 'fri', label: phrase('Friday') },
{ id: 'sat', label: phrase('Saturday') },
{ id: 'sun', label: phrase('Sunday') },
]
const PERMISSIONS = [
{ id: 'ask', label: 'Ask first' },
{ id: 'auto', label: 'Act on its own' },
{ id: 'ask', label: phrase('Ask first') },
{ id: 'auto', label: phrase('Act on its own') },
] as const
/** A run's status as the rail's dot draws it. */
/** A run's status as the rail's dot draws it, and in words (marked, drawn through `t`). */
export const DOT: Record<string, SessionStatus> = { succeeded: 'done', failed: 'error', running: 'running', queued: 'running', review: 'paused', skipped: 'stopped', refused: 'stopped' }
export const WORD: Record<string, string> = {
succeeded: 'Succeeded',
failed: 'Failed',
running: 'Running',
queued: 'Queued',
review: 'Waiting for your review',
skipped: 'Skipped',
refused: 'Refused',
succeeded: phrase('Succeeded'),
failed: phrase('Failed'),
running: phrase('Running'),
queued: phrase('Queued'),
review: phrase('Waiting for your review'),
skipped: phrase('Skipped'),
refused: phrase('Refused'),
}
const NAME: Record<string, string> = Object.fromEntries(SOCIAL.map((p) => [p.id, p.name]))
export const live = (s: string): boolean => s === 'running' || s === 'queued'
@@ -108,11 +115,11 @@ export function shaped(kind: Kind, was: Schedule): Schedule {
}
}
/** What is wrong with a schedule before it is sent, or ''. */
/** What is wrong with a schedule before it is sent, or '': English, marked (`phrase`), so a screen says it through `t`. */
export function unready(s: Schedule): string {
if ((s.kind === 'daily' || s.kind === 'weekdays' || s.kind === 'weekly') && !/^([01]?\d|2[0-3]):[0-5]\d$/.test(s.at ?? '')) return 'Time of day is HH:MM on a 24-hour clock.'
if (s.kind === 'once' && s.at && !/^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d$/.test(s.at)) return 'Once is a date and a time: YYYY-MM-DDTHH:MM.'
if (s.kind === 'cron' && (s.cron ?? '').trim().split(/\s+/).length !== 5) return 'Cron is five fields: minute, hour, day of month, month, day of week.'
if ((s.kind === 'daily' || s.kind === 'weekdays' || s.kind === 'weekly') && !/^([01]?\d|2[0-3]):[0-5]\d$/.test(s.at ?? '')) return phrase('Time of day is HH:MM on a 24-hour clock.')
if (s.kind === 'once' && s.at && !/^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d$/.test(s.at)) return phrase('Once is a date and a time: YYYY-MM-DDTHH:MM.')
if (s.kind === 'cron' && (s.cron ?? '').trim().split(/\s+/).length !== 5) return phrase('Cron is five fields: minute, hour, day of month, month, day of week.')
return ''
}
@@ -152,16 +159,19 @@ function rebase(cur: Draft, was: Draft, next: Draft): Draft {
return out
}
/** A run the platform refused for want of a plan: a free org with no credit for the run fee. */
/** A run the platform refused for want of a plan: a free org with no credit for the run fee. `PLAN` says so, marked, drawn through `t`. */
export const unplanned = (e: unknown): boolean => e instanceof Refusal && e.status === 402
export const PLAN = 'Automations run on a paid plan; on Free each run needs $1 of credit, and this organization has none. Upgrade to run it.'
export const PLAN = phrase('Automations run on a paid plan; on Free each run needs $1 of credit, and this organization has none. Upgrade to run it.')
const BLANK = (): Draft => ({ name: '', instructions: '', model: null, schedule: { kind: 'manual' }, permissions: 'ask', notify: false, postTo: [], enabled: true, source: 'dev' })
/** When a task runs, chosen: how often, the day and time, and the zone it is read in. */
export function When({ s, onChange, disabled }: { s: Schedule; onChange: (s: Schedule) => void; disabled?: boolean }) {
const { t } = useI18n()
const setWhen = (p: Partial<Schedule>) => onChange({ ...s, ...p })
const kinds = useMemo(() => KINDS.map((k) => ({ ...k, label: t(k.label) })), [t])
const days = useMemo(() => DAYS.map((x) => ({ ...x, label: t(x.label) })), [t])
const zones = useMemo(() => {
try {
return ['UTC', ...Intl.supportedValuesOf('timeZone').filter((z) => z !== 'UTC')].map((z) => ({ id: z, label: z }))
@@ -169,37 +179,37 @@ export function When({ s, onChange, disabled }: { s: Schedule; onChange: (s: Sch
return [{ id: 'UTC', label: 'UTC' }]
}
}, [])
const kind = KINDS.find((k) => k.id === s.kind) ?? KINDS[0]!
const kind = kinds.find((k) => k.id === s.kind) ?? kinds[0]!
return (
<XStack gap="$2" flexWrap="wrap" rowGap="$2" items="center" opacity={disabled ? 0.6 : 1} pointerEvents={disabled ? 'none' : 'auto'}>
<ChipSelect name="When it runs" label={kind.label} chosen={kind} items={KINDS} onChange={(k) => onChange(shaped(k.id, s))} placement="bottom-start" width={200} />
{s.kind === 'weekly' ? <ChipSelect name="Day" label={DAYS.find((x) => x.id === s.day)?.label ?? 'Monday'} chosen={DAYS.find((x) => x.id === s.day)} items={DAYS} onChange={(x) => setWhen({ day: x.id })} placement="bottom-start" width={180} /> : null}
<ChipSelect name={t('When it runs')} label={kind.label} chosen={kind} items={kinds} onChange={(k) => onChange(shaped(k.id, s))} placement="bottom-start" width={200} />
{s.kind === 'weekly' ? <ChipSelect name={t('Day')} label={days.find((x) => x.id === s.day)?.label ?? t('Monday')} chosen={days.find((x) => x.id === s.day)} items={days} onChange={(x) => setWhen({ day: x.id })} placement="bottom-start" width={180} /> : null}
{s.kind === 'daily' || s.kind === 'weekdays' || s.kind === 'weekly' ? (
<YStack width={110}>
<Input value={s.at ?? ''} onChangeText={(v: string) => setWhen({ at: v.trim() })} placeholder="09:00" aria-label="Time of day" inputMode="text" />
<Input value={s.at ?? ''} onChangeText={(v: string) => setWhen({ at: v.trim() })} placeholder="09:00" aria-label={t('Time of day')} inputMode="text" />
</YStack>
) : null}
{s.kind === 'once' ? (
<YStack width={190}>
<Input value={s.at ?? ''} onChangeText={(v: string) => setWhen({ at: v.trim() })} placeholder="2026-10-20T09:00" aria-label="Date and time" autoCapitalize="none" />
<Input value={s.at ?? ''} onChangeText={(v: string) => setWhen({ at: v.trim() })} placeholder="2026-10-20T09:00" aria-label={t('Date and time')} autoCapitalize="none" />
</YStack>
) : null}
{s.kind === 'hourly' ? (
<XStack items="center" gap="$2">
<SizableText size="$2" color="$soft">
at minute
{t('at minute')}
</SizableText>
<YStack width={70}>
<Input value={(s.at ?? '00:00').slice(3)} onChangeText={(v: string) => setWhen({ at: `00:${v.replace(/\D/g, '').slice(0, 2)}` })} placeholder="00" aria-label="Minute" inputMode="numeric" />
<Input value={(s.at ?? '00:00').slice(3)} onChangeText={(v: string) => setWhen({ at: `00:${v.replace(/\D/g, '').slice(0, 2)}` })} placeholder="00" aria-label={t('Minute')} inputMode="numeric" />
</YStack>
</XStack>
) : null}
{s.kind === 'cron' ? (
<YStack width={200}>
<Input value={s.cron ?? ''} onChangeText={(v: string) => setWhen({ cron: v })} placeholder="0 9 * * 1-5" aria-label="Cron" autoCapitalize="none" />
<Input value={s.cron ?? ''} onChangeText={(v: string) => setWhen({ cron: v })} placeholder="0 9 * * 1-5" aria-label={t('Cron')} autoCapitalize="none" />
</YStack>
) : null}
{s.kind !== 'manual' ? <ChipSelect name="Time zone" label={s.tz || 'UTC'} chosen={{ id: s.tz || 'UTC', label: s.tz || 'UTC' }} items={zones} onChange={(z) => setWhen({ tz: z.id })} placeholder="Search zones…" placement="bottom-start" width={260} /> : null}
{s.kind !== 'manual' ? <ChipSelect name={t('Time zone')} label={s.tz || 'UTC'} chosen={{ id: s.tz || 'UTC', label: s.tz || 'UTC' }} items={zones} onChange={(z) => setWhen({ tz: z.id })} placeholder={t('Search zones…')} placement="bottom-start" width={260} /> : null}
</XStack>
)
}
@@ -207,16 +217,17 @@ export function When({ s, onChange, disabled }: { s: Schedule; onChange: (s: Sch
/** One automation: what it does and when, its switch, Run now, and its runs. `new` writes one. */
export function Editor({ id }: { id: string }) {
const host = useHost()
const t = useTarget()
const { t } = useI18n()
const target = useTarget()
const signed = Boolean(host.person)
const fresh = id === 'new'
// `tick` reads the automation again; `beat` reads its runs again.
const [tick, setTick] = useState(0)
const [beat, setBeat] = useState(0)
const saved = useRead(signed && !fresh ? () => automation(t, id) : null, null as Automation | null, [t, signed, id, tick])
const history = useRead(signed && !fresh ? () => runs(t, id) : null, [] as Run[], [t, signed, id, beat])
const catalog = useRead(signed ? () => models(t) : null, [], [t, signed])
const { limits } = useLimits(signed ? (signal) => readLimits(t, signal) : null, t)
const saved = useRead(signed && !fresh ? () => automation(target, id) : null, null as Automation | null, [target, signed, id, tick])
const history = useRead(signed && !fresh ? () => runs(target, id) : null, [] as Run[], [target, signed, id, beat])
const catalog = useRead(signed ? () => models(target) : null, [], [target, signed])
const { limits } = useLimits(signed ? (signal) => readLimits(target, signal) : null, target)
// `base` is what the form started from; a save sends what changed since, and only that.
const [base, setBase] = useState<Draft>(BLANK)
const [d, setD] = useState<Draft>(BLANK)
@@ -228,12 +239,12 @@ export function Editor({ id }: { id: string }) {
const [leaving, setLeaving] = useState('')
const [upgrade, setUpgrade] = useState(false)
const social = useSocial(signed, SOCIAL.filter((p) => p.posts).map((p) => p.id))
const subs = useRead(signed ? () => subscriptions(t) : null, [] as Subscription[], [t, signed])
const subs = useRead(signed ? () => subscriptions(target) : null, [] as Subscription[], [target, signed])
// Free is what the platform says when it names no live subscription; a read that failed says nothing.
const free = !subs.loading && !subs.error && !current(subs.value)
const nameRef = useRef<HTMLInputElement>(null)
const asksRef = useRef<HTMLTextAreaElement>(null)
const offered = useRead(signed && fresh ? () => starters(t) : null, [] as Starter[], [t, signed, fresh])
const offered = useRead(signed && fresh ? () => starters(target) : null, [] as Starter[], [target, signed, fresh])
const here = useRef(true)
useEffect(() => {
here.current = true
@@ -277,7 +288,7 @@ export function Editor({ id }: { id: string }) {
setUpgrade(false)
setD((x) => ({ ...x, ...p }))
}
const wrong = unready(d.schedule)
const wrong = t(unready(d.schedule))
// A draft waits on its instructions: they have the focus when it opens.
const drafty = Boolean(a?.draft)
@@ -294,7 +305,7 @@ export function Editor({ id }: { id: string }) {
} catch (e) {
// Wanting a plan is said in these words only where a run was asked for.
const run = what === 'run' && unplanned(e)
setNote(run ? PLAN : (e as Error).message)
setNote(run ? t(PLAN) : told(e, t))
setUpgrade(run)
} finally {
setBusy('')
@@ -303,7 +314,7 @@ export function Editor({ id }: { id: string }) {
/** What a save still needs, said, with the focus on where to give it; true when nothing. */
const complete = (): boolean => {
const ask = !d.name.trim() ? (['Name it first.', nameRef] as const) : !d.instructions.trim() ? (['Add instructions first.', asksRef] as const) : null
const ask = !d.name.trim() ? ([t('Name it first.'), nameRef] as const) : !d.instructions.trim() ? ([t('Add instructions first.'), asksRef] as const) : null
if (ask) {
setNote(ask[0])
ask[1].current?.focus()
@@ -319,7 +330,7 @@ export function Editor({ id }: { id: string }) {
const keep = () =>
act('save', async () => {
if (fresh) {
const made = await create(t, tidy(d))
const made = await create(target, tidy(d))
// Only while still here: a person who left during the create stays where they went.
if (here.current) host.go(path({ kind: 'automation', id: made.id }), { replace: true })
return
@@ -327,27 +338,27 @@ export function Editor({ id }: { id: string }) {
const snap = d
const send = changes(base, tidy(snap))
if (switchesAlone) delete send.enabled
const now = Object.keys(send).length ? await save(t, id, send) : a!
const now = Object.keys(send).length ? await save(target, id, send) : a!
// What was typed while the save was in flight stands.
setBase(drafted(now))
setD((cur) => rebase(cur, snap, drafted(now)))
setTick((n) => n + 1)
setNote('Saved.')
setNote(t('Saved.'))
})
const flip = (on: boolean) => {
if (!switchesAlone) return set({ enabled: on })
void act('switch', async () => {
await save(t, id, { enabled: on })
await save(target, id, { enabled: on })
setTick((n) => n + 1)
})
}
const go = () =>
act('run', async () => {
const run = await start(t, id)
const run = await start(target, id)
setBeat((n) => n + 1)
if (run.status === 'skipped') setNote('Not started: the previous run is still going.')
if (run.status === 'skipped') setNote(t('Not started: the previous run is still going.'))
})
const leave = (how?: { replace?: boolean }) => host.go(path({ kind: 'screen', screen: 'automations' }), how)
@@ -355,7 +366,7 @@ export function Editor({ id }: { id: string }) {
const [answered, setAnswered] = useState(() => new Set<string>())
const answer = (r: Run, post: boolean) =>
act('review', async () => {
await review(t, id, r.id, post)
await review(target, id, r.id, post)
setAnswered((x) => new Set(x).add(r.id))
setBeat((n) => n + 1)
setTick((n) => n + 1)
@@ -370,9 +381,9 @@ export function Editor({ id }: { id: string }) {
asksRef.current?.focus()
}
if (!signed) return <Page title="Automation" onBack={() => leave()} says="Sign in to see this organization's automations." />
if (!fresh && saved.error && !a) return <Page title="Automation" onBack={() => leave()} says={saved.error.message} />
if (!ready) return <Page title="Automation" onBack={() => leave()} says="Reading the automation…" />
if (!signed) return <Page title={t('Automation')} onBack={() => leave()} says={t("Sign in to see this organization's automations.")} />
if (!fresh && saved.error && !a) return <Page title={t('Automation')} onBack={() => leave()} says={told(saved.error, t)} />
if (!ready) return <Page title={t('Automation')} onBack={() => leave()} says={t('Reading the automation…')} />
const s = d.schedule
// An automation that posts speaks for the organization: only an admin changes it.
@@ -380,16 +391,16 @@ export function Editor({ id }: { id: string }) {
// A new automation or a draft has nothing to switch on until it says what to do.
const can = switchesAlone || Boolean(d.instructions.trim())
const on = switchesAlone ? Boolean(a?.enabled) : can && d.enabled
const says = switchesAlone ? (on ? 'On' : 'Off') : !can ? 'Add instructions first' : on ? 'On when saved' : 'Off when saved'
const state = fresh ? 'Not created yet' : dirty ? 'Unsaved changes' : ''
const says = switchesAlone ? (on ? t('On') : t('Off')) : !can ? t('Add instructions first') : on ? t('On when saved') : t('Off when saved')
const state = fresh ? t('Not created yet') : dirty ? t('Unsaved changes') : ''
const sayId = `${id}-switch`
return (
<YStack flex={1} minH={0}>
<YStack shrink={0} px="$6" pt="$4" pb="$3" borderBottomWidth={1} borderColor="$borderColor">
<XStack width="100%" maxW={760} mx="auto" items="center" gap="$3" flexWrap="wrap" rowGap="$2">
<Button size="sm" variant="ghost" onPress={back} aria-label="All scheduled tasks">
<ChevronLeft size={14} />
Scheduled tasks
<Button size="sm" variant="ghost" onPress={back} aria-label={t('All scheduled tasks')}>
<ChevronLeft size={14} {...{ 'data-i18n-mirror': '' }} />
{t('Scheduled tasks')}
</Button>
<SizableText size="$1" color="$soft" role="status">
{state}
@@ -403,13 +414,13 @@ export function Editor({ id }: { id: string }) {
checked={on}
disabled={busy === 'switch' || !can}
onCheckedChange={flip}
aria-label={switchesAlone ? `${a!.name} on` : 'On when saved'}
aria-label={switchesAlone ? t('{name} on', { name: a!.name }) : t('On when saved')}
aria-describedby={sayId}
/>
</XStack>
{a && !a.draft ? (
<Button size="sm" variant="outline" disabled={Boolean(busy) || dirty} onPress={() => void go()}>
{busy === 'run' ? 'Starting…' : 'Run now'}
{busy === 'run' ? t('Starting…') : t('Run now')}
</Button>
) : null}
{a && dirty ? (
@@ -423,11 +434,11 @@ export function Editor({ id }: { id: string }) {
setNote('')
}}
>
Discard changes
{t('Discard changes')}
</Button>
) : null}
<Button size="sm" disabled={Boolean(busy) || locked || !(fresh || drafty || dirty)} onPress={() => complete() && void keep()}>
{busy === 'save' ? 'Saving…' : fresh ? 'Create' : 'Save'}
{busy === 'save' ? t('Saving…') : fresh ? t('Create') : t('Save')}
</Button>
</XStack>
{note ? (
@@ -435,7 +446,7 @@ export function Editor({ id }: { id: string }) {
<Line>{note}</Line>
{upgrade ? (
<Button size="sm" variant="outline" onPress={() => away(path({ kind: 'screen', screen: 'plans' }))}>
See plans
{t('See plans')}
</Button>
) : null}
</XStack>
@@ -445,101 +456,107 @@ export function Editor({ id }: { id: string }) {
<YStack width="100%" maxW={760} mx="auto" gap="$5">
{locked ? (
<SizableText size="$1" color="$soft" role="note">
This automation posts to the organization’s accounts, so only an org admin changes it.
{t('This automation posts to the organization’s accounts, so only an org admin changes it.')}
</SizableText>
) : null}
{free ? (
<XStack items="center" gap="$3" px="$3" py="$2" rounded="$3" borderWidth={1} borderColor="$borderColor" flexWrap="wrap" rowGap="$2">
<SizableText flex={1} minW={200} size="$1" color="$soft">
Paid plans include automation runs. On the Free plan each run needs $1 of credit.
{t('Paid plans include automation runs. On the Free plan each run needs $1 of credit.')}
</SizableText>
<Button size="sm" variant="outline" onPress={() => away(path({ kind: 'screen', screen: 'plans' }))}>
See plans
{t('See plans')}
</Button>
</XStack>
) : null}
<YStack gap="$1">
<SizableText render="h1" size="$6" color="$ink" numberOfLines={1}>
{fresh ? 'New automation' : a?.name}
{fresh ? t('New automation') : a?.name}
</SizableText>
<SizableText size="$2" color="$soft">
{fresh
? 'Name it, say what it should do, and when. It runs as you, in this organization.'
? t('Name it, say what it should do, and when. It runs as you, in this organization.')
: a?.draft
? 'A draft: say what it should do and save it, and it runs.'
: [`From ${SOURCE[a!.source]}`, words(a!.schedule), a!.next && a!.enabled ? `next ${local(a!.next)}` : a!.schedule.kind === 'manual' ? '' : 'off'].filter(Boolean).join(' · ')}
? t('A draft: say what it should do and save it, and it runs.')
: [
t('From {product}', { product: t(SOURCE[a!.source]) }),
words(a!.schedule, t),
a!.next && a!.enabled ? t('next {when}', { when: local(a!.next) }) : a!.schedule.kind === 'manual' ? '' : t('off'),
]
.filter(Boolean)
.join(' · ')}
</SizableText>
</YStack>
{fresh && offered.value.length && !d.instructions.trim() ? (
<XStack gap="$2" flexWrap="wrap" rowGap="$2" items="center">
<SizableText size="$2" color="$soft">
Start from
{t('Start from')}
</SizableText>
{offered.value.map((x) => (
<Button key={x.key} size="sm" variant="outline" onPress={() => begin(x)} aria-label={`Start from ${x.name}`}>
<Button key={x.key} size="sm" variant="outline" onPress={() => begin(x)} aria-label={t('Start from {name}', { name: x.name })}>
{x.name}
</Button>
))}
</XStack>
) : null}
<Field label="Name">
<Field label={t('Name')}>
<Input
ref={nameRef}
value={d.name}
onChangeText={(v: string) => set({ name: v })}
onSubmitEditing={() => asksRef.current?.focus()}
placeholder="Morning briefing"
aria-label="Automation name"
placeholder={t('Morning briefing')}
aria-label={t('Automation name')}
autoFocus={fresh}
disabled={locked}
/>
</Field>
<Field label="Instructions (required)" hint="What the agent does each run. It works in a Dev sandbox with this organization's connectors, MCP servers and skills.">
<Field label={t('Instructions (required)')} hint={t("What the agent does each run. It works in a Dev sandbox with this organization's connectors, MCP servers and skills.")}>
<Textarea
ref={asksRef}
disabled={locked}
value={d.instructions}
onChangeText={(v: string) => set({ instructions: v })}
placeholder="What should it do each run? For example: summarize what needs my attention today across calendar, email and messages."
aria-label="Instructions"
placeholder={t('What should it do each run? For example: summarize what needs my attention today across calendar, email and messages.')}
aria-label={t('Instructions')}
rows={7}
/>
</Field>
<Field label="When it runs" hint={s.kind === 'cron' ? 'Five fields: minute, hour, day of month, month, day of week.' : s.kind === 'once' ? 'It runs at that time and then switches itself off.' : undefined}>
<Field label={t('When it runs')} hint={s.kind === 'cron' ? t('Five fields: minute, hour, day of month, month, day of week.') : s.kind === 'once' ? t('It runs at that time and then switches itself off.') : undefined}>
<When s={s} onChange={(next) => set({ schedule: next })} disabled={locked} />
</Field>
<Field label="Model">
<Field label={t('Model')}>
<XStack>
<ModelPicker
size="sm"
name="Model"
name={t('Model')}
models={catalog.value}
scope="chat"
limits={limits}
value={d.model ?? ENSO}
onChange={(id) => set({ model: id === ENSO ? null : id })}
loading={catalog.loading}
error={catalog.error?.message ?? null}
error={catalog.error ? told(catalog.error, t) : null}
/>
</XStack>
</Field>
<Field
label="Permissions"
hint={d.permissions === 'auto' ? 'Works and uses connectors without stopping.' : 'Reads and researches, changes nothing, and ends with the actions it would take for you to approve.'}
label={t('Permissions')}
hint={d.permissions === 'auto' ? t('Works and uses connectors without stopping.') : t('Reads and researches, changes nothing, and ends with the actions it would take for you to approve.')}
>
<Choice label="Permissions" value={d.permissions} options={PERMISSIONS} onChange={(p) => set({ permissions: p })} />
<Choice label={t('Permissions')} value={d.permissions} options={PERMISSIONS.map((p) => ({ ...p, label: t(p.label) }))} onChange={(p) => set({ permissions: p })} />
</Field>
<Field
label="Post to"
label={t('Post to')}
hint={
!host.admin
? 'An org admin chooses where automations post: the accounts are the organization’s.'
? t('An org admin chooses where automations post: the accounts are the organization’s.')
: !d.postTo.length
? 'Turn an account on and each run’s answer is a post for it.'
? t('Turn an account on and each run’s answer is a post for it.')
: d.permissions === 'auto'
? 'Each run posts what the agent writes, as written.'
: 'Each run holds what the agent writes for you to post or discard.'
? t('Each run posts what the agent writes, as written.')
: t('Each run holds what the agent writes for you to post or discard.')
}
>
<YStack borderWidth={1} borderColor="$borderColor" rounded="$3" overflow="hidden">
@@ -554,15 +571,17 @@ export function Editor({ id }: { id: string }) {
</SizableText>
<SizableText size="$1" color="$soft">
{social.error
? social.error.message
? told(social.error, t)
: !c
? 'Reading…'
? t('Reading…')
: c.connected
? `Connected${c.account ? ` as ${c.account}` : ''}`
: `${c.available ? 'Not connected: an org admin connects it in Settings → Integrations.' : p.needs} ${
? c.account
? t('Connected as {account}', { account: c.account })
: t('Connected')
: `${c.available ? t('Not connected: an org admin connects it in Settings → Integrations.') : t(p.needs)} ${
on || d.permissions === 'ask'
? 'Runs hold the post for you to read; posting it fails until it is connected.'
: 'Acting on its own, it posts only to a connected account.'
? t('Runs hold the post for you to read; posting it fails until it is connected.')
: t('Acting on its own, it posts only to a connected account.')
}`}
</SizableText>
</YStack>
@@ -570,7 +589,7 @@ export function Editor({ id }: { id: string }) {
checked={on}
disabled={!host.admin || (!on && !c?.connected && d.permissions === 'auto')}
onCheckedChange={(v: boolean) => set({ postTo: v ? [...d.postTo, p.id].sort() : d.postTo.filter((x) => x !== p.id) })}
aria-label={`Post to ${p.name}`}
aria-label={t('Post to {name}', { name: p.name })}
/>
</XStack>
)
@@ -578,27 +597,27 @@ export function Editor({ id }: { id: string }) {
</YStack>
<XStack>
<Button size="sm" variant="ghost" onPress={() => away(path({ kind: 'settings', section: 'integrations' }))}>
Connect accounts
{t('Connect accounts')}
</Button>
</XStack>
</Field>
<XStack items="center" gap="$3">
<YStack flex={1} minW={0}>
<SizableText size="$2" color="$ink">
Email me when a run ends
{t('Email me when a run ends')}
</SizableText>
<SizableText size="$1" color="$soft">
One line on how it went, with a link to the run.
{t('One line on how it went, with a link to the run.')}
</SizableText>
</YStack>
<Switch checked={d.notify} onCheckedChange={(v: boolean) => set({ notify: v })} aria-label="Email me when a run ends" />
<Switch checked={d.notify} onCheckedChange={(v: boolean) => set({ notify: v })} aria-label={t('Email me when a run ends')} />
</XStack>
{dirty && wrong && note !== wrong ? <Line>{wrong}</Line> : null}
{a ? (
<XStack>
<Button size="sm" variant="ghost" disabled={Boolean(busy)} onPress={() => setAsking(true)}>
Delete
{t('Delete')}
</Button>
</XStack>
) : null}
@@ -606,16 +625,16 @@ export function Editor({ id }: { id: string }) {
{a ? (
<YStack gap="$2">
<SizableText size="$3" color="$ink">
Runs
{t('Runs')}
</SizableText>
{history.error && !history.value.length ? (
<Line>{history.error.message}</Line>
<Line>{told(history.error, t)}</Line>
) : !history.value.length ? (
<SizableText size="$2" color="$soft">
{history.loading ? 'Reading its runs…' : a.draft ? 'It runs once its instructions are saved.' : 'No runs yet. Run now starts one.'}
{history.loading ? t('Reading its runs…') : a.draft ? t('It runs once its instructions are saved.') : t('No runs yet. Run now starts one.')}
</SizableText>
) : (
<YStack role="list" borderWidth={1} borderColor="$borderColor" rounded="$3" overflow="hidden" aria-label="Run history">
<YStack role="list" borderWidth={1} borderColor="$borderColor" rounded="$3" overflow="hidden" aria-label={t('Run history')}>
{history.value.map((r, i) => (
<XStack role="listitem" key={r.id} items="flex-start" gap="$3" px="$3" py="$2.5" borderTopWidth={i ? 1 : 0} borderColor="$borderColor">
<YStack pt="$1">
@@ -623,13 +642,13 @@ export function Editor({ id }: { id: string }) {
</YStack>
<YStack flex={1} minW={0} gap="$0.5">
<SizableText size="$2" color="$ink">
{`${WORD[r.status] ?? r.status} · ${local(r.at)}`}
{`${WORD[r.status] ? t(WORD[r.status]) : r.status} · ${local(r.at)}`}
</SizableText>
<SizableText size="$1" color="$soft" style={{ whiteSpace: 'pre-wrap' }}>
{r.summary || (live(r.status) ? 'Working…' : '')}
{r.summary || (live(r.status) ? t('Working…') : '')}
</SizableText>
{r.draft ? (
<YStack mt="$1.5" px="$3" py="$2" rounded="$2" bg="$raised" role="group" aria-label="The post">
<YStack mt="$1.5" px="$3" py="$2" rounded="$2" bg="$raised" role="group" aria-label={t('The post')}>
<SizableText size="$2" color="$ink" style={{ whiteSpace: 'pre-wrap' }}>
{r.draft}
</SizableText>
@@ -642,9 +661,9 @@ export function Editor({ id }: { id: string }) {
{NAME[x.to] ?? x.to}
</SizableText>
{x.url ? (
<Out href={x.url} label={`Open the ${NAME[x.to] ?? x.to} post`}>
<Out href={x.url} label={t('Open the {name} post', { name: NAME[x.to] ?? x.to })}>
<SizableText size="$1" color="$ink" textDecorationLine="underline">
Posted · open
{t('Posted · open')}
</SizableText>
</Out>
) : (
@@ -656,22 +675,22 @@ export function Editor({ id }: { id: string }) {
))}
{r.status === 'review' && host.admin && !answered.has(r.id) ? (
<XStack gap="$2" pt="$2">
<Button size="sm" disabled={Boolean(busy)} onPress={() => void answer(r, true)} aria-label={`Post the post of ${local(r.at)}`}>
Post it
<Button size="sm" disabled={Boolean(busy)} onPress={() => void answer(r, true)} aria-label={t('Post the post of {when}', { when: local(r.at) })}>
{t('Post it')}
</Button>
<Button size="sm" variant="ghost" disabled={Boolean(busy)} onPress={() => void answer(r, false)} aria-label={`Discard the post of ${local(r.at)}`}>
Discard
<Button size="sm" variant="ghost" disabled={Boolean(busy)} onPress={() => void answer(r, false)} aria-label={t('Discard the post of {when}', { when: local(r.at) })}>
{t('Discard')}
</Button>
</XStack>
) : r.status === 'review' && !host.admin ? (
<SizableText size="$1" color="$soft" pt="$1">
An org admin posts or discards it.
{t('An org admin posts or discards it.')}
</SizableText>
) : null}
</YStack>
{r.session ? (
<Button size="sm" variant="ghost" onPress={() => host.go(r.session!)} aria-label={`Open the Dev run of ${local(r.at)}`}>
Open run
<Button size="sm" variant="ghost" onPress={() => host.go(r.session!)} aria-label={t('Open the Dev run of {when}', { when: local(r.at) })}>
{t('Open run')}
</Button>
) : null}
</XStack>
@@ -684,25 +703,25 @@ export function Editor({ id }: { id: string }) {
{a ? (
<Confirm
what={a.name}
says="It stops running, and its schedule and its runs go with it. A run going now is stopped."
says={t('It stops running, and its schedule and its runs go with it. A run going now is stopped.')}
open={asking}
onOpenChange={setAsking}
onYes={async () => {
await remove(t, a.id)
await remove(target, a.id)
leave({ replace: true })
}}
/>
) : null}
<Sheet title="Leave without saving?" open={Boolean(leaving)} onOpenChange={(o) => !o && setLeaving('')} width={420}>
<Sheet title={t('Leave without saving?')} open={Boolean(leaving)} onOpenChange={(o) => !o && setLeaving('')} width={420}>
<SizableText size="$2" color="$soft">
{fresh ? 'This automation is not created yet.' : 'Your changes to this automation are not saved.'}
{fresh ? t('This automation is not created yet.') : t('Your changes to this automation are not saved.')}
</SizableText>
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="ghost" onPress={() => setLeaving('')}>
Keep editing
{t('Keep editing')}
</Button>
<Button size="sm" variant="destructive" onPress={() => host.go(leaving)}>
Leave
{t('Leave')}
</Button>
</XStack>
</Sheet>
@@ -711,14 +730,16 @@ export function Editor({ id }: { id: string }) {
)
}
/** A page with nothing to edit yet: its title and what it says come already said. */
function Page({ title, says, onBack }: { title: string; says: string; onBack: () => void }) {
const { t } = useI18n()
return (
<YStack flex={1} minH={0} overflow="scroll" px="$6" py="$6">
<YStack width="100%" maxW={760} mx="auto" gap="$4">
<XStack>
<Button size="sm" variant="ghost" onPress={onBack} aria-label="All scheduled tasks">
<ChevronLeft size={14} />
Scheduled tasks
<Button size="sm" variant="ghost" onPress={onBack} aria-label={t('All scheduled tasks')}>
<ChevronLeft size={14} {...{ 'data-i18n-mirror': '' }} />
{t('Scheduled tasks')}
</Button>
</XStack>
<SizableText render="h1" size="$6" color="$ink">
+12 -4
View File
@@ -12,6 +12,8 @@
* `useBack` reads that answer once, takes it off the address so a reload does
* not replay it, finishes a GitHub grant, and says what happened.
*/
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { useEffect } from 'react'
import type { Target } from './api/call.ts'
@@ -65,13 +67,14 @@ export function here(): string {
const NAMES: Record<string, string> = { github: 'GitHub', slack: 'Slack' }
/** A provider id as people write it. */
export const named = (id: string): string => NAMES[id] ?? (id ? id[0].toUpperCase() + id.slice(1) : 'The connection')
export const named = (id: string): string => NAMES[id] ?? (id ? id[0].toUpperCase() + id.slice(1) : phrase('The connection'))
/**
* Finish what a return carried, once a person is signed in: `say` gets the
* sentence, and `after` runs once something was connected.
*/
export function useBack(t: Target, signed: boolean, say: (note: string) => void, after: () => void): void {
const { t: words } = useI18n()
useEffect(() => {
if (!signed) return
const l = landed(window.location.search)
@@ -80,18 +83,23 @@ export function useBack(t: Target, signed: boolean, say: (note: string) => void,
const next = clean(window.location.href)
window.history.replaceState(window.history.state, '', next)
if (l.kind === 'error') {
say(`${named(l.provider)}: ${l.reason || 'not connected'}`)
// The provider's own reason is said as it came; with none, ours.
say(l.reason ? `${words(named(l.provider))}: ${l.reason}` : words('{provider}: not connected', { provider: words(named(l.provider)) }))
return
}
if (l.kind === 'connected') {
say(`${named(l.provider)} is connected${l.account ? ` as ${l.account}` : ''}`)
say(
l.account
? words('{provider} is connected as {account}', { provider: words(named(l.provider)), account: l.account })
: words('{provider} is connected', { provider: words(named(l.provider)) }),
)
after()
return
}
if (l.provider !== 'github') return
complete(t, l.grant).then(
(c) => {
say(c.connected ? `Connected as @${c.login}` : 'GitHub did not connect; connect again')
say(c.connected ? words('Connected as @{login}', { login: c.login }) : words('GitHub did not connect; connect again'))
after()
},
(e: unknown) => say((e as Error).message),
+13 -11
View File
@@ -27,6 +27,7 @@
* `DevSection`, section.tsx): one left column, never two.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import { Menu, Search } from '@hanzogui/lucide-icons-2'
import { Button } from '@hanzo/ui'
import { SessionRail, type RailSession } from '@hanzo/ui/chat'
@@ -57,6 +58,7 @@ import { Settings } from './settings/index.tsx'
type Order = 'newest' | 'running'
function Shell() {
const { t: say } = useI18n()
const host = useHost()
const t = useTarget()
const r = route(host.path)
@@ -74,10 +76,10 @@ function Shell() {
}
const rows: RailSession[] = useMemo(() => {
const list = recents.value.map((s) => ({ id: s.id, title: s.title || 'Untitled run', status: DOTS[s.status] ?? 'idle' }))
const list = recents.value.map((s) => ({ id: s.id, title: s.title || say('Untitled run'), status: DOTS[s.status] ?? 'idle' }))
if (order === 'running') list.sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running'))
return list
}, [recents.value, order])
}, [recents.value, order, say])
// The top left, as on claude.ai: the builder's name, which leads to New, and under
// it the search that finds a run. The organization is the account's, so it is
@@ -85,7 +87,7 @@ function Shell() {
// the drawer's button below it — one place at a time.
const name = <Name onPress={() => go('')} />
// Every place in view, none behind a More; the rail's own New starts a run.
const links = nav(host, go)
const links = nav(host, go, say)
.flatMap((g) => g.links)
.filter((l) => l.id !== NEW)
@@ -100,10 +102,10 @@ function Shell() {
active={r.kind === 'run' ? r.id : null}
onOpen={(id) => go(id)}
onSort={() => setOrder(order === 'newest' ? 'running' : 'newest')}
sortLabel={order === 'newest' ? 'Show running first' : 'Show newest first'}
sortLabel={order === 'newest' ? say('Show running first') : say('Show newest first')}
empty={
<SizableText size="$1" color="$soft" px="$2">
{!host.person ? 'Sign in to see your runs.' : recents.error ? recents.error.message : recents.loading ? 'Reading…' : 'No runs yet.'}
{!host.person ? say('Sign in to see your runs.') : recents.error ? say(recents.error.message) : recents.loading ? say('Reading…') : say('No runs yet.')}
</SizableText>
}
notice={
@@ -121,7 +123,7 @@ function Shell() {
who.toggle()
},
}
: { name: 'Sign in', onPress: () => host.signIn?.() }
: { name: say('Sign in'), onPress: () => host.signIn?.() }
}
collapsed={collapsed}
onCollapse={setCollapsed}
@@ -129,14 +131,14 @@ function Shell() {
pt={collapsed ? undefined : 92}
open={drawer}
onOpenChange={setDrawer}
label="Runs"
label={say('Runs')}
/>
{collapsed ? null : (
<YStack position="absolute" t={10} l={8} r={8} z={2} gap="$2.5" $max-md={{ display: 'none' }}>
<XStack px="$2.5">{name}</XStack>
<XStack
render="button"
aria-label="Search runs"
aria-label={say('Search runs')}
onPress={() => setFinding(true)}
items="center"
gap="$2"
@@ -150,7 +152,7 @@ function Shell() {
>
<Search size={15} color="$soft" />
<SizableText size="$2" color="$soft">
Search
{say('Search')}
</SizableText>
</XStack>
</YStack>
@@ -161,12 +163,12 @@ function Shell() {
{/* Below md the rail is a drawer; this is the one control that opens it. */}
{/* Its own row, in flow: laid over the pane it covered the heading's mark. */}
<XStack height={44} px="$2" gap="$2" items="center" shrink={0} $md={{ display: 'none' }}>
<Button variant="ghost" size="icon-sm" onPress={() => setDrawer(true)} aria-label="Open runs">
<Button variant="ghost" size="icon-sm" onPress={() => setDrawer(true)} aria-label={say('Open runs')}>
<Menu size={18} />
</Button>
{name}
<XStack flex={1} />
<Button variant="ghost" size="icon-sm" onPress={() => setFinding(true)} aria-label="Search runs">
<Button variant="ghost" size="icon-sm" onPress={() => setFinding(true)} aria-label={say('Search runs')}>
<Search size={18} />
</Button>
</XStack>
+24 -20
View File
@@ -16,6 +16,7 @@
* (Chat its agents, Dev its devices) beside these.
*/
import { SizableText, XStack } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import { BookOpen, ListChecks, Palette, ScrollText } from '@hanzogui/lucide-icons-2'
import { Button, Dialog, DialogContent, DialogDescription, DialogTitle } from '@hanzo/ui'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
@@ -105,20 +106,21 @@ export interface Asked {
/** Manual's pause: what is about to run, what it does, and the three answers. Closing it is a no. */
function Allow({ what, onAnswer }: { what: Asked; onAnswer: (v: Leave) => void }) {
const { t } = useI18n()
return (
<Dialog open onOpenChange={(o) => !o && onAnswer('deny')}>
<DialogContent maxW={440} width="100%" data-slot="allow">
<DialogTitle>{`Allow ${what.name}?`}</DialogTitle>
<DialogDescription>{what.note ? `${what.note}. Manual asks before each one runs.` : 'Manual asks before each one runs.'}</DialogDescription>
<DialogTitle>{t('Allow {name}?', { name: t(what.name) })}</DialogTitle>
<DialogDescription>{what.note ? `${t(what.note)}. ${t('Manual asks before each one runs.')}` : t('Manual asks before each one runs.')}</DialogDescription>
<XStack justify="flex-end" gap="$2" pt="$3" flexWrap="wrap">
<Button size="sm" variant="ghost" onPress={() => onAnswer('deny')}>
Deny
{t('Deny')}
</Button>
<Button size="sm" variant="outline" onPress={() => onAnswer('always')}>
<SizableText fontSize={14}>Always in this chat</SizableText>
<SizableText fontSize={14}>{t('Always in this chat')}</SizableText>
</Button>
<Button size="sm" onPress={() => onAnswer('once')}>
Allow once
{t('Allow once')}
</Button>
</XStack>
</DialogContent>
@@ -132,6 +134,7 @@ function Allow({ what, onAnswer }: { what: Asked; onAnswer: (v: Leave) => void }
* surface offers (a choice it does not offer reads as Auto).
*/
export function useCarry(t: Target, key: string, customize: (tab: 'skills') => void, lent: Lent, modes: readonly Mode[] = DEV_MODES): Carry {
const { t: say } = useI18n()
const gear = useGear(t, key)
const [dialog, show] = useState<ReactNode>(null)
// The composer a plugin acts on, read when it acts: the draft moves under it.
@@ -161,14 +164,14 @@ export function useCarry(t: Target, key: string, customize: (tab: 'skills') => v
rows: names.map((n) => ({ id: n, label: n, icon: <ScrollText size={16} />, on: skills.includes(n) })),
pick: (id) => gear.toggle('skills', id),
loading: list.loading,
error: list.error ? 'The skills could not be read.' : null,
search: names.length > 8 ? 'Search skills' : undefined,
empty: 'No skills are on for this organization',
manage: { label: 'Manage skills', onPress: () => customize('skills') },
browse: { label: 'Browse skills', onPress: () => customize('skills') },
error: list.error ? say('The skills could not be read.') : null,
search: names.length > 8 ? say('Search skills') : undefined,
empty: say('No skills are on for this organization'),
manage: { label: say('Manage skills'), onPress: () => customize('skills') },
browse: { label: say('Browse skills'), onPress: () => customize('skills') },
}
const plugins = pluginShelf(found, gear, composer)
const plugins = pluginShelf(found, gear, composer, say)
// The design systems: Studio's brands, the one store for them.
const kits = useRead(signed ? () => brands(t) : null, [], [t, signed, design])
@@ -179,13 +182,14 @@ export function useCarry(t: Target, key: string, customize: (tab: 'skills') => v
}),
pick: (id) => gear.set({ design: design === id ? '' : id }),
loading: kits.loading,
error: kits.error ? 'The design systems could not be read.' : null,
empty: 'No design systems yet',
error: kits.error ? say('The design systems could not be read.') : null,
empty: say('No design systems yet'),
create: {
label: 'Create design system',
label: say('Create design system'),
onPress: () => {
if (!gear.gear.plugins.includes('design')) gear.toggle('plugins', 'design')
lentNow.current.setDraft('Make me a design system for ')
// The person's own words to finish, so in their language.
lentNow.current.setDraft(say('Make me a design system for '))
},
},
}
@@ -193,12 +197,12 @@ export function useCarry(t: Target, key: string, customize: (tab: 'skills') => v
const on: Chip[] = [
...skills.map((n) => ({ id: `skill:${n}`, label: n, icon: <ScrollText size={14} />, onRemove: () => gear.toggle('skills', n) })),
...(research ? [{ id: 'research', label: 'Research', icon: <BookOpen size={14} />, onRemove: () => gear.set({ research: false }) }] : []),
...(mode === 'plan' ? [{ id: 'plan', label: 'Plan', icon: <ListChecks size={14} />, onRemove: () => gear.set({ mode: 'auto' }) }] : []),
...(design ? [{ id: 'design', label: named?.name ?? 'Design system', icon: <Palette size={14} />, onRemove: () => gear.set({ design: '' }) }] : []),
...(research ? [{ id: 'research', label: say('Research'), icon: <BookOpen size={14} />, onRemove: () => gear.set({ research: false }) }] : []),
...(mode === 'plan' ? [{ id: 'plan', label: say('Plan'), icon: <ListChecks size={14} />, onRemove: () => gear.set({ mode: 'auto' }) }] : []),
...(design ? [{ id: 'design', label: named?.name ?? say('Design system'), icon: <Palette size={14} />, onRemove: () => gear.set({ design: '' }) }] : []),
...lit
.filter((p) => p.id !== 'research')
.map((p) => ({ id: `plugin:${p.id}`, label: p.name, icon: p.icon, onRemove: () => gear.toggle('plugins', p.id) })),
.map((p) => ({ id: `plugin:${p.id}`, label: say(p.name), icon: p.icon, onRemove: () => gear.toggle('plugins', p.id) })),
]
const signedIn = Boolean(t.token())
@@ -254,7 +258,7 @@ export function useCarry(t: Target, key: string, customize: (tab: 'skills') => v
// One plugin at a time, so each pause is its own question.
const denied: string[] = []
for (const x of lit) if (!signal.aborted && !(await allow({ id: x.id, name: x.name, note: x.note }))) denied.push(x.id)
const p = await prep(found.plugins, gear.gear, { ...turn, design }, composer, signal, denied)
const p = await prep(found.plugins, gear.gear, { ...turn, design }, composer, signal, denied, say)
if (p.design) gear.set({ design: p.design, plugins: gear.gear.plugins.filter((x) => x !== 'design') })
return p
},
+151 -102
View File
@@ -14,8 +14,14 @@
* so the choices are the tools that are on; "every tool" is whatever the
* fleet's MCP server serves when it runs. A budget is required: what it may
* spend each period, and in one run.
*
* Its words are the reader's language (@hanzo/i18n `t`); an agent's name,
* description, instructions, model and runs, and the categories the host and
* the platform give their templates, are drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n, type Say } from '@hanzo/i18n/react'
import { ArrowLeft, Upload } from '@hanzogui/lucide-icons-2'
import { Button, Input, Switch, Textarea } from '@hanzo/ui'
import { ModelPicker } from '@hanzo/ui/models'
@@ -32,7 +38,7 @@ import { Add, Choice, Confirm, Empty, Failed, Field, Grid, Line, Mark, matches,
/** How many cards Browse draws at a time. */
const PAGE = 60
const ANY = 'All'
const ANY = phrase('All')
/** Categories as Browse offers them: the default agents' and presets' first, as listed, then the rest by how many each holds. */
export function kinds(first: readonly Cast[], rest: readonly Cast[]): string[] {
@@ -44,13 +50,42 @@ export function kinds(first: readonly Cast[], rest: readonly Cast[]): string[] {
const money = (m: number): string => `$${(m / 1_000_000).toFixed(m % 10_000 ? 4 : 2).replace(/\.?0+$/, '')}`
/** A category as Browse names it: All, and the platform's presets and people, in the reader's words; the rest are the host's, as given. */
const category = (c: string, t: Say): string => (c === ANY || c === 'Preset' || c === 'Person' ? t(c) : c)
/**
* A budget's sentences by its period ('' where the agent names none): whole
* sentences, so a language says the period its own way.
*/
const SPENT: Record<Period | '', string> = {
day: phrase('{spent} of {cap} this day'),
week: phrase('{spent} of {cap} this week'),
month: phrase('{spent} of {cap} this month'),
'': phrase('{spent} of {cap} this period'),
}
const BUDGET: Record<Period | '', string> = {
day: phrase('{spent} of {cap} spent this day; at most {task} a run.'),
week: phrase('{spent} of {cap} spent this week; at most {task} a run.'),
month: phrase('{spent} of {cap} spent this month; at most {task} a run.'),
'': phrase('{spent} of {cap} spent this period; at most {task} a run.'),
}
const SO_FAR: Record<Period | '', string> = {
day: phrase('Spent {spent} this day.'),
week: phrase('Spent {spent} this week.'),
month: phrase('Spent {spent} this month.'),
'': phrase('Spent {spent} this period.'),
}
const EACH: Record<Period, string> = { day: phrase('Each day, USD'), week: phrase('Each week, USD'), month: phrase('Each month, USD') }
const PERIOD: Record<Period, string> = { day: phrase('Day'), week: phrase('Week'), month: phrase('Month') }
export function Agents({ view, q, adding, onAdding, onView, onCount }: Pane) {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const signed = Boolean(host.person)
const browsing = view === 'discover'
const mine = useLoad(signed ? () => agents(t) : null, [] as Agent[], [t, signed])
const offered = useLoad(browsing ? () => presets(t) : null, [] as Preset[], [t, view])
const mine = useLoad(signed ? () => agents(target) : null, [] as Agent[], [target, signed])
const offered = useLoad(browsing ? () => presets(target) : null, [] as Preset[], [target, view])
const figures = useLoad(browsing && host.personas ? host.personas : null, [] as readonly Cast[], [view])
useCount(mine.value.length, onCount)
const [note, setNote] = useState('')
@@ -65,9 +100,9 @@ export function Agents({ view, q, adding, onAdding, onView, onCount }: Pane) {
setUsing(c)
setNote('')
try {
opened(await use(t, c, window.location.href))
opened(await use(target, c, window.location.href))
} catch (e) {
setNote(say(e))
setNote(say(e, t))
} finally {
setUsing(null)
}
@@ -80,7 +115,7 @@ export function Agents({ view, q, adding, onAdding, onView, onCount }: Pane) {
onAdding(false)
onView('yours')
mine.reload()
setNote(`${a.name} is saved`)
setNote(t('{name} is saved', { name: a.name }))
}}
/>
) : null
@@ -95,44 +130,44 @@ export function Agents({ view, q, adding, onAdding, onView, onCount }: Pane) {
{signed ? (
<XStack gap="$2" flexWrap="wrap">
<Button size="sm" variant="outline" onPress={() => onAdding(true)}>
Start from scratch
{t('Start from scratch')}
</Button>
<Button size="sm" variant="outline" onPress={() => setMaking('self')}>
Clone yourself
{t('Clone yourself')}
</Button>
<Button size="sm" variant="outline" onPress={() => setMaking('person')}>
Create from a person
{t('Create from a person')}
</Button>
</XStack>
) : null}
<XStack overflowX="auto" overflowY="hidden" pb="$1">
<Choice label="Category" value={kind} options={kinds(first, figures.value).map((id) => ({ id, label: id }))} onChange={setKind} />
<Choice label={t('Category')} value={kind} options={kinds(first, figures.value).map((id) => ({ id, label: category(id, t) }))} onChange={setKind} />
</XStack>
<Line>{note}</Line>
{shown.length ? (
<Grid label="Agents to start from">
<Grid label={t('Agents to start from')}>
{shown.slice(0, more).map((c) => (
<Tile
key={`${c.category}/${c.name}/${c.title}`}
title={c.title}
detail={c.description === c.title || c.description === c.category ? undefined : c.description}
meta={c.category}
meta={category(c.category, t)}
mark={<Mark name={c.title} size={40} logo={c.avatar} />}
action={signed ? <Add name={c.title} label="Use" added={false} busy={using !== null} onPress={() => void take(c)} /> : undefined}
action={signed ? <Add name={c.title} use added={false} busy={using !== null} onPress={() => void take(c)} /> : undefined}
/>
))}
</Grid>
) : figures.loading || offered.loading ? null : (
<Soft>{`No agent matches “${q.trim()}”.`}</Soft>
<Soft>{t('No agent matches “{q}”.', { q: q.trim() })}</Soft>
)}
{shown.length > more ? (
<XStack>
<Button size="sm" variant="ghost" onPress={() => setMore(more + PAGE)}>
More
{t('More')}
</Button>
</XStack>
) : null}
{failed ? <Failed error={failed} onRetry={figures.error ? figures.reload : offered.reload} /> : figures.loading || offered.loading ? <Soft>Loading…</Soft> : null}
{failed ? <Failed error={failed} onRetry={figures.error ? figures.reload : offered.reload} /> : figures.loading || offered.loading ? <Soft>{t('Loading…')}</Soft> : null}
{editor}
{making ? <Person self={making === 'self'} onClose={() => setMaking(null)} onMade={opened} /> : null}
</YStack>
@@ -140,7 +175,7 @@ export function Agents({ view, q, adding, onAdding, onView, onCount }: Pane) {
}
if (!signed) {
return <Visitor>Sign in to see your agents.</Visitor>
return <Visitor>{t('Sign in to see your agents.')}</Visitor>
}
const shown = mine.value.filter((a) => matches(q, a.name, a.description, a.model))
return (
@@ -149,26 +184,33 @@ export function Agents({ view, q, adding, onAdding, onView, onCount }: Pane) {
{mine.error && !mine.value.length ? (
<Failed error={mine.error} onRetry={mine.reload} />
) : mine.loading && !mine.value.length ? (
<Soft>Loading your agents…</Soft>
<Soft>{t('Loading your agents…')}</Soft>
) : !mine.value.length ? (
<Empty title="No agents yet" detail="">
<Empty title={t('No agents yet')} detail="">
<Button size="sm" variant="outline" onPress={() => onView('discover')}>
Browse agents
{t('Browse agents')}
</Button>
<Button size="sm" onPress={() => onAdding(true)}>
New agent
{t('New agent')}
</Button>
</Empty>
) : !shown.length ? (
<Soft>{`No agent of yours matches “${q.trim()}”.`}</Soft>
<Soft>{t('No agent of yours matches “{q}”.', { q: q.trim() })}</Soft>
) : (
<Grid label="Your agents">
<Grid label={t('Your agents')}>
{shown.map((a) => (
<Tile
key={a.id || a.name}
title={a.name}
detail={a.description || 'No description.'}
meta={[a.model, `${a.runs} ${a.runs === 1 ? 'run' : 'runs'}`, reach(a), a.cap ? `${money(a.spent)} of ${money(a.cap)} this ${a.period || 'period'}` : ''].filter(Boolean).join(' · ')}
detail={a.description || t('No description.')}
meta={[
a.model,
a.runs === 1 ? t('1 run') : t('{n} runs', { n: a.runs }),
reach(a, t),
a.cap ? t(SPENT[a.period], { spent: money(a.spent), cap: money(a.cap) }) : '',
]
.filter(Boolean)
.join(' · ')}
mark={<Face agent={a} cast={host.cast ?? []} />}
onOpen={() => opened(a)}
/>
@@ -182,8 +224,9 @@ export function Agents({ view, q, adding, onAdding, onView, onCount }: Pane) {
/** Clone yourself, or anyone named: a name, who they are, and writing of theirs, pasted or uploaded. */
function Person({ self, onClose, onMade }: { self: boolean; onClose: () => void; onMade: (a: Agent) => void }) {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const me = self ? host.person : null
const [name, setName] = useState(me?.name ?? '')
const [about, setAbout] = useState('')
@@ -199,26 +242,26 @@ function Person({ self, onClose, onMade }: { self: boolean; onClose: () => void;
setWorking(true)
setNote('')
try {
onMade(await use(t, person(name, about, writing, me?.avatar), window.location.href))
onMade(await use(target, person(name, about, writing, me?.avatar), window.location.href))
} catch (e) {
setNote(say(e))
setNote(say(e, t))
setWorking(false)
}
}
return (
<Sheet title={self ? 'Clone yourself' : 'Create from a person'} open onOpenChange={(o) => !o && onClose()}>
<Field label="Name">
<Input value={name} onChangeText={setName} aria-label="Name" />
<Sheet title={self ? t('Clone yourself') : t('Create from a person')} open onOpenChange={(o) => !o && onClose()}>
<Field label={t('Name')}>
<Input value={name} onChangeText={setName} aria-label={t('Name')} />
</Field>
<Field label="Description">
<Textarea value={about} onChangeText={setAbout} aria-label="Description" rows={3} />
<Field label={t('Description')}>
<Textarea value={about} onChangeText={setAbout} aria-label={t('Description')} rows={3} />
</Field>
<Field label="Writing">
<Textarea value={writing} onChangeText={setWriting} aria-label="Writing" rows={6} />
<Field label={t('Writing')}>
<Textarea value={writing} onChangeText={setWriting} aria-label={t('Writing')} rows={6} />
</Field>
<XStack>
<Button size="sm" variant="ghost" onPress={() => file.current?.click()}>
<Upload size={14} /> Upload
<Upload size={14} /> {t('Upload')}
</Button>
<input
ref={file}
@@ -226,7 +269,7 @@ function Person({ self, onClose, onMade }: { self: boolean; onClose: () => void;
multiple
accept=".txt,.md,.markdown,.csv,.json,.html,text/*"
hidden
aria-label="Upload writing"
aria-label={t('Upload writing')}
onChange={(e) => {
void read(e.currentTarget.files)
e.currentTarget.value = ''
@@ -237,19 +280,19 @@ function Person({ self, onClose, onMade }: { self: boolean; onClose: () => void;
<XStack gap="$2" items="center">
<XStack flex={1} />
<Button size="sm" variant="ghost" onPress={onClose}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" disabled={working || !name.trim()} onPress={() => void save()}>
Create
{t('Create')}
</Button>
</XStack>
</Sheet>
)
}
function reach(a: Agent): string {
if (a.tools.includes(ALL)) return 'every tool'
return a.tools.length === 1 ? '1 tool' : `${a.tools.length} tools`
function reach(a: Agent, t: Say): string {
if (a.tools.includes(ALL)) return t('every tool')
return a.tools.length === 1 ? t('1 tool') : t('{n} tools', { n: a.tools.length })
}
/** An agent's face: its picture, the default agent's of its name, its emoji, or its initial. */
@@ -260,27 +303,28 @@ function Face({ agent: a, cast, size = 32 }: { agent: Agent; cast: readonly Cast
/** One agent's page: who it is, what it runs on and reads, what it may call and spend, and what it has done. */
export function Profile({ name }: { name: string }) {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const got = useLoad(host.person ? () => profile(t, name) : null, null as { agent: Agent; runs: Run[] } | null, [t, name])
const target = useTarget()
const got = useLoad(host.person ? () => profile(target, name) : null, null as { agent: Agent; runs: Run[] } | null, [target, name])
const [editing, setEditing] = useState(false)
const back = () => host.go(path({ kind: 'customize', tab: 'agents' }))
const a = got.value?.agent
return (
<YStack flex={1} minH={0} overflow="scroll" px="$6" py="$6" $max-md={{ px: '$4', py: '$4' }}>
<YStack width="100%" maxW={880} mx="auto" gap="$5">
<XStack render="button" onPress={back} items="center" gap="$1.5" self="flex-start" cursor="pointer" aria-label="Back to agents">
<ArrowLeft size={14} color="$soft" />
<XStack render="button" onPress={back} items="center" gap="$1.5" self="flex-start" cursor="pointer" aria-label={t('Back to agents')}>
<ArrowLeft size={14} color="$soft" {...{ 'data-i18n-mirror': '' }} />
<SizableText size="$2" color="$soft">
Agents
{t('Agents')}
</SizableText>
</XStack>
{!host.person ? (
<Visitor>Sign in to see this agent.</Visitor>
<Visitor>{t('Sign in to see this agent.')}</Visitor>
) : got.error && !a ? (
<Failed error={got.error} onRetry={got.reload} />
) : !a ? (
<Soft>Loading the agent…</Soft>
<Soft>{t('Loading the agent…')}</Soft>
) : (
<>
<XStack items="center" gap="$4">
@@ -290,15 +334,15 @@ export function Profile({ name }: { name: string }) {
{a.name}
</SizableText>
<SizableText size="$3" color="$soft">
{a.description || 'No role written yet.'}
{a.description || t('No role written yet.')}
</SizableText>
</YStack>
<Button size="sm" variant="outline" onPress={() => setEditing(true)}>
Edit
{t('Edit')}
</Button>
</XStack>
<Part title="Model" detail={a.model || 'The deployment’s default'} />
<Part title="Instructions" detail={a.instructions ? '' : 'None written: it runs on its model alone.'}>
<Part title={t('Model')} detail={a.model || t('The deployment’s default')} />
<Part title={t('Instructions')} detail={a.instructions ? '' : t('None written: it runs on its model alone.')}>
{a.instructions ? (
<YStack p="$3" rounded="$3" borderWidth={1} borderColor="$borderColor" maxH={320} overflow="scroll">
<SizableText size="$2" color="$ink" style={{ whiteSpace: 'pre-wrap' }}>
@@ -307,20 +351,20 @@ export function Profile({ name }: { name: string }) {
</YStack>
) : null}
</Part>
<Part title="Tools" detail={a.tools.includes(ALL) ? 'Every tool the fleet’s MCP server serves.' : a.tools.length ? a.tools.join(', ') : 'None: it calls no tool.'} />
<Part title={t('Tools')} detail={a.tools.includes(ALL) ? t('Every tool the fleet’s MCP server serves.') : a.tools.length ? a.tools.join(', ') : t('None: it calls no tool.')} />
<Part title={t('Budget')} detail={a.cap ? t(BUDGET[a.period], { spent: money(a.spent), cap: money(a.cap), task: money(a.task) }) : t('No budget set.')} />
<Part
title="Budget"
detail={a.cap ? `${money(a.spent)} of ${money(a.cap)} spent this ${a.period || 'period'}; at most ${money(a.task)} a run.` : 'No budget set.'}
/>
<Part title="Runs" detail={a.runs ? `${a.runs} in all; the latest ${Math.min(a.runs, got.value!.runs.length)} below.` : 'It has not run yet.'}>
title={t('Runs')}
detail={a.runs ? t('{runs} in all; the latest {n} below.', { runs: a.runs, n: Math.min(a.runs, got.value!.runs.length) }) : t('It has not run yet.')}
>
{got.value!.runs.map((r) => (
<XStack key={r.id} gap="$3" py="$2" borderTopWidth={1} borderColor="$borderColor" items="flex-start">
<SizableText size="$1" color={r.status === 'ok' ? '$soft' : '$bad'} width={44} shrink={0}>
{r.status === 'ok' ? 'ok' : 'failed'}
{r.status === 'ok' ? t('ok') : t('failed')}
</SizableText>
<YStack flex={1} minW={0}>
<SizableText size="$2" color="$ink" numberOfLines={1}>
{r.input || '(no input)'}
{r.input || t('(no input)')}
</SizableText>
<SizableText size="$1" color="$soft" numberOfLines={2}>
{r.status === 'ok' ? r.output : r.error}
@@ -362,10 +406,11 @@ function Editor({
onSaved: (a: Agent) => void
onDeleted?: () => void
}) {
const t = useTarget()
const full = useLoad(agent ? () => one(t, agent.id || agent.name) : null, null as Agent | null, [t, agent?.id])
const catalog = useLoad(() => models(t), [] as Model[], [t])
const on = useLoad(() => tools(t, { activated: true }), [] as Tool[], [t])
const { t } = useI18n()
const target = useTarget()
const full = useLoad(agent ? () => one(target, agent.id || agent.name) : null, null as Agent | null, [target, agent?.id])
const catalog = useLoad(() => models(target), [] as Model[], [target])
const on = useLoad(() => tools(target, { activated: true }), [] as Tool[], [target])
const [d, setD] = useState<Draft>(EMPTY)
const [ready, setReady] = useState(!agent)
const [working, setWorking] = useState(false)
@@ -386,19 +431,19 @@ function Editor({
const choices = useMemo(() => {
const listed = on.value.map((x) => ({ name: x.name, source: x.source, description: x.description }))
const seen = new Set(listed.map((x) => x.name))
const kept = d.tools.filter((n) => n !== ALL && !seen.has(n)).map((name) => ({ name, source: 'not on', description: '' }))
const kept = d.tools.filter((n) => n !== ALL && !seen.has(n)).map((name) => ({ name, source: t('not on'), description: '' }))
return [...kept, ...listed]
}, [on.value, d.tools])
}, [on.value, d.tools, t])
const shownTools = choices.filter((x) => matches(find, x.name, x.source, x.description))
const save = async () => {
setWorking(true)
setNote('')
try {
const was = full.value
const saved = agent && was ? await update(t, was, d) : await create(t, d)
const saved = agent && was ? await update(target, was, d) : await create(target, d)
onSaved(saved)
} catch (e) {
setNote(say(e))
setNote(say(e, t))
} finally {
setWorking(false)
}
@@ -407,67 +452,71 @@ function Editor({
const pick = (name: string, yes: boolean) => set({ tools: yes ? [...d.tools, name] : d.tools.filter((n) => n !== name) })
return (
<Sheet title={agent ? agent.name : 'New agent'} open onOpenChange={(o) => !o && onClose()} width={680}>
<Sheet title={agent ? agent.name : t('New agent')} open onOpenChange={(o) => !o && onClose()} width={680}>
{agent && full.error && !full.value ? (
<Failed error={full.error} onRetry={full.reload} />
) : !ready ? (
<Soft>Loading the agent…</Soft>
<Soft>{t('Loading the agent…')}</Soft>
) : (
<>
<Field label="Name" hint={agent ? 'An agent keeps its name.' : 'Letters, digits, . _ or -. Other agents call it as agent_<name>.'}>
<Input value={d.name} onChangeText={(v: string) => set({ name: v })} aria-label="Name" disabled={Boolean(agent)} autoCapitalize="none" />
<Field label={t('Name')} hint={agent ? t('An agent keeps its name.') : t('Letters, digits, . _ or -. Other agents call it as agent_<name>.')}>
<Input value={d.name} onChangeText={(v: string) => set({ name: v })} aria-label={t('Name')} disabled={Boolean(agent)} autoCapitalize="none" />
</Field>
<Field label="Description" hint="The line another agent reads to decide whether to call this one.">
<Input value={d.description} onChangeText={(v: string) => set({ description: v })} placeholder="Answers questions about our codebase" aria-label="Description" />
<Field label={t('Description')} hint={t('The line another agent reads to decide whether to call this one.')}>
<Input value={d.description} onChangeText={(v: string) => set({ description: v })} placeholder={t('Answers questions about our codebase')} aria-label={t('Description')} />
</Field>
<Field label="Model">
<Field label={t('Model')}>
<XStack items="center" gap="$2" flexWrap="wrap">
<ModelPicker
size="sm"
name="Model"
placeholder="The deployment’s default"
name={t('Model')}
placeholder={t('The deployment’s default')}
models={catalog.value}
scope="chat"
value={d.model || undefined}
onChange={(id) => set({ model: id })}
loading={catalog.loading}
error={catalog.error ? say(catalog.error) : null}
error={catalog.error ? say(catalog.error, t) : null}
/>
{d.model && !agent ? (
<Button size="sm" variant="ghost" onPress={() => set({ model: '' })}>
Use the default
{t('Use the default')}
</Button>
) : null}
</XStack>
</Field>
<Field label="Instructions" hint="The system prompt: what the model reads before every run.">
<Textarea value={d.instructions} onChangeText={(v: string) => set({ instructions: v })} placeholder="Be terse, and cite the file you read." aria-label="Instructions" rows={8} />
<Field label={t('Instructions')} hint={t('The system prompt: what the model reads before every run.')}>
<Textarea value={d.instructions} onChangeText={(v: string) => set({ instructions: v })} placeholder={t('Be terse, and cite the file you read.')} aria-label={t('Instructions')} rows={8} />
</Field>
<YStack gap="$2">
<XStack items="center" gap="$3">
<YStack flex={1} minW={0}>
<SizableText size="$2" color="$ink">
Tools
{t('Tools')}
</SizableText>
<SizableText size="$1" color="$soft">
{every ? 'Every tool the fleet’s MCP server serves when it runs.' : d.tools.length ? `${d.tools.length} chosen. It can call only these.` : 'None chosen: it can call no tool.'}
{every
? t('Every tool the fleet’s MCP server serves when it runs.')
: d.tools.length
? t('{n} chosen. It can call only these.', { n: d.tools.length })
: t('None chosen: it can call no tool.')}
</SizableText>
</YStack>
<SizableText size="$1" color="$soft">
Every tool
{t('Every tool')}
</SizableText>
<Switch checked={every} onCheckedChange={(v: boolean) => set({ tools: v ? [ALL] : [] })} aria-label="Every tool" />
<Switch checked={every} onCheckedChange={(v: boolean) => set({ tools: v ? [ALL] : [] })} aria-label={t('Every tool')} />
</XStack>
{every ? null : (
<>
<Input value={find} onChangeText={setFind} placeholder="Find a tool…" aria-label="Find a tool" />
<Input value={find} onChangeText={setFind} placeholder={t('Find a tool…')} aria-label={t('Find a tool')} />
<YStack borderWidth={1} borderColor="$borderColor" rounded="$3" maxH={240} overflow="scroll">
{on.loading && !on.value.length ? (
<Soft>Loading the tools that are on…</Soft>
<Soft>{t('Loading the tools that are on…')}</Soft>
) : on.error && !on.value.length ? (
<Failed error={on.error} onRetry={on.reload} />
) : !shownTools.length ? (
<Soft>{choices.length ? 'No tool matches.' : 'No tool is on for your organization yet. Add skills or connectors first.'}</Soft>
<Soft>{choices.length ? t('No tool matches.') : t('No tool is on for your organization yet. Add skills or connectors first.')}</Soft>
) : (
shownTools.map((x, i) => (
<XStack key={x.name} items="center" gap="$3" px="$3" py="$2" borderTopWidth={i ? 1 : 0} borderColor="$borderColor">
@@ -479,7 +528,7 @@ function Editor({
{[x.source, x.description].filter(Boolean).join(' · ')}
</SizableText>
</YStack>
<Switch checked={d.tools.includes(x.name)} onCheckedChange={(v: boolean) => pick(x.name, v)} aria-label={`Use ${x.name}`} />
<Switch checked={d.tools.includes(x.name)} onCheckedChange={(v: boolean) => pick(x.name, v)} aria-label={t('Use {name}', { name: x.name })} />
</XStack>
))
)}
@@ -489,50 +538,50 @@ function Editor({
</YStack>
<YStack gap="$2">
<SizableText size="$2" color="$ink">
Budget
{t('Budget')}
</SizableText>
<XStack gap="$3" flexWrap="wrap" rowGap="$2" items="flex-end">
<YStack gap="$1" width={140}>
<SizableText size="$1" color="$soft">
Each {d.period}, USD
{t(EACH[d.period])}
</SizableText>
<Input value={d.cap} onChangeText={(v: string) => set({ cap: v })} placeholder="10" aria-label="Budget each period" inputMode="decimal" />
<Input value={d.cap} onChangeText={(v: string) => set({ cap: v })} placeholder="10" aria-label={t('Budget each period')} inputMode="decimal" />
</YStack>
<YStack gap="$1" width={140}>
<SizableText size="$1" color="$soft">
One run, USD
{t('One run, USD')}
</SizableText>
<Input value={d.task} onChangeText={(v: string) => set({ task: v })} placeholder="1" aria-label="Budget for one run" inputMode="decimal" />
<Input value={d.task} onChangeText={(v: string) => set({ task: v })} placeholder="1" aria-label={t('Budget for one run')} inputMode="decimal" />
</YStack>
<Choice label="Period" value={d.period} options={PERIODS.map((p) => ({ id: p, label: p[0]!.toUpperCase() + p.slice(1) }))} onChange={(p: Period) => set({ period: p })} />
<Choice label={t('Period')} value={d.period} options={PERIODS.map((p) => ({ id: p, label: t(PERIOD[p]) }))} onChange={(p: Period) => set({ period: p })} />
</XStack>
<SizableText size="$1" color="$soft">
{full.value && full.value.cap ? `Spent ${money(full.value.spent)} this ${full.value.period}. ` : ''}A run stops at either limit.
{[full.value && full.value.cap ? t(SO_FAR[full.value.period], { spent: money(full.value.spent) }) : '', t('A run stops at either limit.')].filter(Boolean).join(' ')}
</SizableText>
</YStack>
<Line>{note}</Line>
<XStack gap="$2" items="center">
{agent && onDeleted ? (
<Button size="sm" variant="ghost" onPress={() => setAsking(true)}>
Delete
{t('Delete')}
</Button>
) : null}
<XStack flex={1} />
<Button size="sm" variant="ghost" onPress={onClose}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" disabled={working} onPress={() => void save()}>
{agent ? 'Save' : 'Create'}
{agent ? t('Save') : t('Create')}
</Button>
</XStack>
{agent && onDeleted ? (
<Confirm
what={agent.name}
says="The agent and every run recorded against it are removed."
says={t('The agent and every run recorded against it are removed.')}
open={asking}
onOpenChange={setAsking}
onYes={async () => {
await remove(t, agent.id || agent.name)
await remove(target, agent.id || agent.name)
onDeleted()
}}
/>
+103 -89
View File
@@ -10,8 +10,12 @@
* all its tools on its card and one per tool inside; a tool is called only
* while it is on. Adding a server switches its tools on. A server's secret is
* sealed in KMS and never shown again.
*
* Its words are the reader's language (@hanzo/i18n `t`); a server's name,
* address, description and tools are the registry's or the org's, drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { useI18n, type Say } from '@hanzo/i18n/react'
import { ChevronDown, ExternalLink, KeyRound, Server as ServerIcon } from '@hanzogui/lucide-icons-2'
import { Button, Input, Switch } from '@hanzo/ui'
import { useEffect, useMemo, useState } from 'react'
@@ -24,7 +28,7 @@ import { Out } from '../out.tsx'
import { route } from '../route.ts'
import { useLoad } from './load.ts'
import { say } from './say.ts'
import { Add, Confirm, Empty, Failed, Featured, Field, Grid, left, Line, Mark, matches, mono, Part, Sheet, Soft, Tile, useCount, Visitor, type Pane } from './ui.tsx'
import { Add, Confirm, Empty, Failed, Featured, Field, Grid, Line, Mark, matches, mono, Part, Sheet, Soft, Tile, useCount, Visitor, type Pane } from './ui.tsx'
const PAGE = 48
@@ -32,14 +36,15 @@ const PAGE = 48
const where = (url: string): string => url.replace(/^https?:\/\//, '').replace(/\/$/, '')
export function Connectors({ view, q, adding, onAdding, onView, onCount }: Pane) {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const signed = Boolean(host.person)
// What every run in the org connects to is an org admin's to change; a member reads it.
const may = signed && host.admin
const mine = useLoad(signed ? () => servers(t) : null, [] as Server[], [t, signed])
const mine = useLoad(signed ? () => servers(target) : null, [] as Server[], [target, signed])
// Every server's tools, for the switch on each card; read once for all of them.
const plane = useLoad(signed && view === 'yours' && mine.value.length ? () => tools(t, { source: 'mcp' }) : null, [] as Tool[], [t, signed, view, mine.value])
const plane = useLoad(signed && view === 'yours' && mine.value.length ? () => tools(target, { source: 'mcp' }) : null, [] as Tool[], [target, signed, view, mine.value])
const [opened, setOpened] = useState<Server | null>(null)
const [listing, setListing] = useState<Listing | null>(null)
const [connecting, setConnecting] = useState<Listing | null>(null)
@@ -54,11 +59,11 @@ export function Connectors({ view, q, adding, onAdding, onView, onCount }: Pane)
setBusy(s.id)
setNote('')
try {
await toggle(t, next ? its : [], next ? [] : its)
await toggle(target, next ? its : [], next ? [] : its)
plane.reload()
setNote(next ? `${s.name} is on` : `${s.name} is off`)
setNote(next ? t('{name} is on', { name: s.name }) : t('{name} is off', { name: s.name }))
} catch (e) {
setNote(say(e))
setNote(say(e, t))
} finally {
setBusy('')
}
@@ -114,7 +119,7 @@ export function Connectors({ view, q, adding, onAdding, onView, onCount }: Pane)
}
if (!signed) {
return <Visitor>Sign in to see your connectors.</Visitor>
return <Visitor>{t('Sign in to see your connectors.')}</Visitor>
}
const shown = mine.value.filter((s) => matches(q, s.name, s.url, s.listing))
return (
@@ -123,22 +128,22 @@ export function Connectors({ view, q, adding, onAdding, onView, onCount }: Pane)
{mine.error && !mine.value.length ? (
<Failed error={mine.error} onRetry={mine.reload} />
) : mine.loading && !mine.value.length ? (
<Soft>Loading your connectors…</Soft>
<Soft>{t('Loading your connectors…')}</Soft>
) : !mine.value.length ? (
<Empty title="No connectors yet" detail={may ? 'Connect an app from Browse, or add any MCP server by its URL.' : 'An org admin adds connectors. Browse what there is.'}>
<Empty title={t('No connectors yet')} detail={may ? t('Connect an app from Browse, or add any MCP server by its URL.') : t('An org admin adds connectors. Browse what there is.')}>
<Button size="sm" variant="outline" onPress={() => onView('discover')}>
Browse connectors
{t('Browse connectors')}
</Button>
{may ? (
<Button size="sm" onPress={() => onAdding(true)}>
Add by URL
{t('Add by URL')}
</Button>
) : null}
</Empty>
) : !shown.length ? (
<Soft>{`No connector of yours matches “${q.trim()}”.`}</Soft>
<Soft>{t('No connector of yours matches “{q}”.', { q: q.trim() })}</Soft>
) : (
<Grid label="Your connectors">
<Grid label={t('Your connectors')}>
{shown.map((s) => {
const its = plane.value.filter((x) => owns(s, x.name))
const lit = its.filter((x) => x.activated).length
@@ -148,10 +153,10 @@ export function Connectors({ view, q, adding, onAdding, onView, onCount }: Pane)
title={s.name}
detail={where(s.url)}
meta={[
its.length ? `${lit} of ${its.length} tools on` : plane.loading ? '' : 'No tools listed yet',
s.listing ? 'From Browse' : 'Added by URL',
s.secret ? 'Secret in KMS' : '',
s.admitted ? '' : 'Not in runs until an admin adds it again',
its.length ? t('{on} of {n} tools on', { on: lit, n: its.length }) : plane.loading ? '' : t('No tools listed yet'),
s.listing ? t('From Browse') : t('Added by URL'),
s.secret ? t('Secret in KMS') : '',
s.admitted ? '' : t('Not in runs until an admin adds it again'),
]
.filter(Boolean)
.join(' · ')}
@@ -159,10 +164,10 @@ export function Connectors({ view, q, adding, onAdding, onView, onCount }: Pane)
onOpen={() => setOpened(s)}
action={
may && its.length ? (
<Switch checked={lit > 0} disabled={busy === s.id} onCheckedChange={(v: boolean) => void flip(s, v)} aria-label={`${s.name} on`} />
<Switch checked={lit > 0} disabled={busy === s.id} onCheckedChange={(v: boolean) => void flip(s, v)} aria-label={t('{name} on', { name: s.name })} />
) : its.length ? (
<SizableText size="$1" color="$soft">
{lit ? 'On' : 'Off'}
{lit ? t('On') : t('Off')}
</SizableText>
) : undefined
}
@@ -171,7 +176,7 @@ export function Connectors({ view, q, adding, onAdding, onView, onCount }: Pane)
})}
</Grid>
)}
{plane.error && mine.value.length ? <Line>{`Tools: ${say(plane.error)}`}</Line> : null}
{plane.error && mine.value.length ? <Line>{t('Tools: {reason}', { reason: say(plane.error, t) })}</Line> : null}
{opened ? (
<Detail
server={opened}
@@ -181,7 +186,7 @@ export function Connectors({ view, q, adding, onAdding, onView, onCount }: Pane)
plane.reload()
}}
onDeleted={() => {
setNote(`${opened.name} is removed`)
setNote(t('{name} is removed', { name: opened.name }))
setOpened(null)
mine.reload()
}}
@@ -194,9 +199,10 @@ export function Connectors({ view, q, adding, onAdding, onView, onCount }: Pane)
/** The shelf: featured first, then by name, a page at a time. The platform searches it. */
function Shelf({ q, signed, may, added, onOpen, onAdd }: { q: string; signed: boolean; may: boolean; added: Set<string>; onOpen: (l: Listing) => void; onAdd: (l: Listing) => void }) {
const t = useTarget()
const { t } = useI18n()
const target = useTarget()
const text = useSettled(q.trim(), 300)
const first = useLoad(signed ? () => shelf(t, { text, limit: PAGE }) : null, null as Page | null, [t, signed, text])
const first = useLoad(signed ? () => shelf(target, { text, limit: PAGE }) : null, null as Page | null, [target, signed, text])
const [more, setMore] = useState<Listing[]>([])
const [paging, setPaging] = useState(false)
const [note, setNote] = useState('')
@@ -204,8 +210,8 @@ function Shelf({ q, signed, may, added, onOpen, onAdd }: { q: string; signed: bo
if (!signed) {
return (
<Part title="Connect an app" detail="Give runs the tools you already use.">
<Visitor>Sign in to browse connectors.</Visitor>
<Part title={t('Connect an app')} detail={t('Give runs the tools you already use.')}>
<Visitor>{t('Sign in to browse connectors.')}</Visitor>
</Part>
)
}
@@ -220,48 +226,48 @@ function Shelf({ q, signed, may, added, onOpen, onAdd }: { q: string; signed: bo
setPaging(true)
setNote('')
try {
const got = await shelf(t, { text, limit: PAGE, offset: list.length })
const got = await shelf(target, { text, limit: PAGE, offset: list.length })
setMore((m) => [...m, ...got.listings])
} catch (e) {
setNote(say(e))
setNote(say(e, t))
} finally {
setPaging(false)
}
}
return (
<Part title="Connect an app" detail="Give runs the tools you already use. Adding one turns its tools on for your organization.">
<Part title={t('Connect an app')} detail={t('Give runs the tools you already use. Adding one turns its tools on for your organization.')}>
{first.error && !page ? (
<Failed error={first.error} onRetry={first.reload} />
) : !page ? (
<Soft>Loading connectors…</Soft>
<Soft>{t('Loading connectors…')}</Soft>
) : !list.length ? (
<Soft>{text ? `No connector matches “${text}”.` : 'There are no connectors to add yet.'}</Soft>
<Soft>{text ? t('No connector matches “{q}”.', { q: text }) : t('There are no connectors to add yet.')}</Soft>
) : (
<YStack gap="$3">
{hero ? (
<Featured
title={titleOf(hero)}
detail={hero.description}
meta={facts(hero)}
meta={facts(hero, t)}
mark={<Mark name={titleOf(hero)} logo={hero.logo} size={48} />}
onOpen={() => onOpen(hero)}
action={action(hero)}
/>
) : null}
<SizableText size="$1" color="$soft">
{list.length < page.total ? `${list.length} of ${page.total} connectors` : `${page.total} connectors`}
{list.length < page.total ? t('{shown} of {n} connectors', { shown: list.length, n: page.total }) : t('{n} connectors', { n: page.total })}
</SizableText>
<Grid label="Servers on the shelf">
<Grid label={t('Servers on the shelf')}>
{rest.map((l) => (
<Tile key={l.id} title={titleOf(l)} detail={l.description} meta={facts(l)} mark={<Mark name={titleOf(l)} logo={l.logo} />} onOpen={() => onOpen(l)} action={action(l)} />
<Tile key={l.id} title={titleOf(l)} detail={l.description} meta={facts(l, t)} mark={<Mark name={titleOf(l)} logo={l.logo} />} onOpen={() => onOpen(l)} action={action(l)} />
))}
</Grid>
<Line>{note}</Line>
{list.length < page.total ? (
<XStack justify="center">
<Button size="sm" variant="outline" disabled={paging} onPress={() => void next()}>
Show more
{t('Show more')}
</Button>
</XStack>
) : null}
@@ -271,8 +277,8 @@ function Shelf({ q, signed, may, added, onOpen, onAdd }: { q: string; signed: bo
)
}
/** The facts a card carries under its description. */
const facts = (l: Listing): string => [l.vendor, l.official ? 'Official' : '', ready(l) ? 'Hosted' : 'Package only: needs a place to run'].filter(Boolean).join(' · ')
/** The facts a card carries under its description, said in the reader's language. */
const facts = (l: Listing, t: Say): string => [l.vendor, l.official ? t('Official') : '', ready(l) ? t('Hosted') : t('Package only: needs a place to run')].filter(Boolean).join(' · ')
/** `value` once it has held still for `ms`. */
function useSettled<T>(value: T, ms: number): T {
@@ -290,42 +296,43 @@ function useSettled<T>(value: T, ms: number): T {
* at once on the old MCP address, which exists to show them.
*/
function Natives({ q }: { q: string }) {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const r = route(host.path)
const [asked, setAsked] = useState(r.kind === 'screen' && r.screen === 'mcp')
const list = useLoad(asked ? () => natives(t) : null, [] as Native[], [t, asked])
const list = useLoad(asked ? () => natives(target) : null, [] as Native[], [target, asked])
const [open, setOpen] = useState<string | null>(null)
const shown = list.value.filter((s) => matches(q, s.name, s.description, ...s.ops))
const ops = shown.reduce((n, s) => n + s.ops.length, 0)
return (
<Part
title="Built in"
detail="Hanzo’s own servers. Always on: every run can call them, and nothing needs adding."
title={t('Built in')}
detail={t('Hanzo’s own servers. Always on: every run can call them, and nothing needs adding.')}
action={
asked ? undefined : (
<Button size="sm" variant="outline" onPress={() => setAsked(true)}>
Show built-in servers
{t('Show built-in servers')}
</Button>
)
}
>
{!asked ? null : list.loading && !list.value.length ? (
<Soft>Loading built-in servers…</Soft>
<Soft>{t('Loading built-in servers…')}</Soft>
) : list.error && !list.value.length ? (
<Failed error={list.error} onRetry={list.reload} />
) : !shown.length ? (
<Soft>{list.value.length ? `No built-in server matches “${q.trim()}”.` : 'No built-in servers are listed.'}</Soft>
<Soft>{list.value.length ? t('No built-in server matches “{q}”.', { q: q.trim() }) : t('No built-in servers are listed.')}</Soft>
) : (
<YStack gap="$1">
<SizableText size="$1" color="$soft">
{shown.length} servers · {ops} operations
{t('{servers} servers · {ops} operations', { servers: shown.length, ops })}
</SizableText>
{shown.map((s) => {
const on = open === s.name
return (
<YStack key={s.name} borderWidth={1} borderColor="$borderColor" rounded="$3" overflow="hidden">
<XStack render="button" aria-expanded={on} aria-label={s.name} items="center" gap="$3" px="$3" py="$2.5" hoverStyle={{ bg: '$hover' }} style={left} onPress={() => setOpen(on ? null : s.name)}>
<XStack render="button" aria-expanded={on} aria-label={s.name} items="center" gap="$3" px="$3" py="$2.5" hoverStyle={{ bg: '$hover' }} $platform-web={{ textAlign: 'start' }} onPress={() => setOpen(on ? null : s.name)}>
<YStack flex={1} minW={0} gap="$1">
<SizableText size="$3" color="$ink">
{s.name}
@@ -351,7 +358,7 @@ function Natives({ q }: { q: string }) {
))
) : (
<SizableText size="$1" color="$soft">
This server lists no operations.
{t('This server lists no operations.')}
</SizableText>
)}
</YStack>
@@ -367,8 +374,9 @@ function Natives({ q }: { q: string }) {
/** One listing in full: what it is, where it is served, and adding it. */
function About({ listing, added, signed, onAdd, onClose }: { listing: Listing; added: boolean; signed: boolean; onAdd: () => void; onClose: () => void }) {
const t = useTarget()
const full = useLoad(() => one(t, listing.id), listing, [t, listing.id])
const { t } = useI18n()
const target = useTarget()
const full = useLoad(() => one(target, listing.id), listing, [target, listing.id])
const l = full.value
const links = [l.site, l.repo].filter((u) => u.startsWith('https://'))
return (
@@ -380,7 +388,7 @@ function About({ listing, added, signed, onAdd, onClose }: { listing: Listing; a
{l.name}
</SizableText>
<SizableText size="$1" color="$soft" numberOfLines={1}>
{[l.vendor, l.version ? `v${l.version}` : '', l.official ? 'Official' : ''].filter(Boolean).join(' · ')}
{[l.vendor, l.version ? `v${l.version}` : '', l.official ? t('Official') : ''].filter(Boolean).join(' · ')}
</SizableText>
</YStack>
</XStack>
@@ -390,7 +398,7 @@ function About({ listing, added, signed, onAdd, onClose }: { listing: Listing; a
</SizableText>
) : null}
{l.remotes.length ? (
<Field label="Served at">
<Field label={t('Served at')}>
{l.remotes.map((r) => (
<SizableText key={r.url} size="$1" color="$ink" style={mono} numberOfLines={1}>
{r.url} · {r.transport}
@@ -399,7 +407,7 @@ function About({ listing, added, signed, onAdd, onClose }: { listing: Listing; a
</Field>
) : null}
{l.packages.length ? (
<Field label="Packages" hint={ready(l) ? undefined : 'It ships only as a package, so it needs a place to run before it can be added here.'}>
<Field label={t('Packages')} hint={ready(l) ? undefined : t('It ships only as a package, so it needs a place to run before it can be added here.')}>
{l.packages.map((p) => (
<SizableText key={`${p.registry}:${p.identifier}`} size="$1" color="$ink" style={mono} numberOfLines={1}>
{[p.registry, p.identifier, p.version, p.runtime].filter(Boolean).join(' · ')}
@@ -419,11 +427,11 @@ function About({ listing, added, signed, onAdd, onClose }: { listing: Listing; a
))}
</XStack>
) : null}
{full.error ? <Line>{`Showing what Browse listed. ${say(full.error)}`}</Line> : null}
{full.error ? <Line>{t('Showing what Browse listed. {reason}', { reason: say(full.error, t) })}</Line> : null}
{signed && ready(l) ? (
<XStack justify="flex-end">
<Button size="sm" disabled={added} onPress={onAdd}>
{added ? 'Added' : 'Add to your connectors'}
{added ? t('Added') : t('Add to your connectors')}
</Button>
</XStack>
) : null}
@@ -436,7 +444,8 @@ function About({ listing, added, signed, onAdd, onClose }: { listing: Listing; a
* needs one, is sealed in KMS as it is added; then its tools are switched on.
*/
function Connect({ listing, onClose, onAdded }: { listing: Listing | null; onClose: () => void; onAdded: (said: string) => void }) {
const t = useTarget()
const { t } = useI18n()
const target = useTarget()
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const [header, setHeader] = useState('Authorization')
@@ -449,57 +458,57 @@ function Connect({ listing, onClose, onAdded }: { listing: Listing | null; onClo
setNote('')
const a: Adding = listing ? { listing: listing.id, name, header, secret } : { name, url, header, secret }
try {
const s = await add(t, a)
let said = `${s.name} is added.`
const s = await add(target, a)
let said = t('{name} is added.', { name: s.name })
try {
const its = (await tools(t, { source: 'mcp' })).filter((x) => owns(s, x.name)).map((x) => x.name)
if (its.length) await toggle(t, its)
said = its.length ? `${s.name} is added, and its ${its.length} tools are on.` : `${s.name} is added. It listed no tools yet.`
const its = (await tools(target, { source: 'mcp' })).filter((x) => owns(s, x.name)).map((x) => x.name)
if (its.length) await toggle(target, its)
said = its.length ? t('{name} is added, and its {n} tools are on.', { name: s.name, n: its.length }) : t('{name} is added. It listed no tools yet.', { name: s.name })
} catch (e) {
said = `${s.name} is added, but its tools could not be switched on. ${say(e)}`
said = t('{name} is added, but its tools could not be switched on. {reason}', { name: s.name, reason: say(e, t) })
}
onAdded(said)
} catch (e) {
setNote(say(e))
setNote(say(e, t))
} finally {
setWorking(false)
}
}
return (
<Sheet title={listing ? `Add ${titleOf(listing)}` : 'Add by URL'} open onOpenChange={(o) => !o && onClose()} width={500}>
<Sheet title={listing ? t('Add {name}', { name: titleOf(listing) }) : t('Add by URL')} open onOpenChange={(o) => !o && onClose()} width={500}>
{listing ? (
<SizableText size="$2" color="$soft">
{listing.description || listing.name}
</SizableText>
) : (
<SizableText size="$2" color="$soft">
Any MCP server that speaks HTTP. Its tools are on once it is added.
{t('Any MCP server that speaks HTTP. Its tools are on once it is added.')}
</SizableText>
)}
<Field label="Name" hint={listing ? 'Leave it empty to use the listing’s own.' : undefined}>
<Input value={name} onChangeText={setName} placeholder={listing ? titleOf(listing) : 'Docs'} aria-label="Name" />
<Field label={t('Name')} hint={listing ? t('Leave it empty to use the listing’s own.') : undefined}>
<Input value={name} onChangeText={setName} placeholder={listing ? titleOf(listing) : t('Docs')} aria-label={t('Name')} />
</Field>
{listing ? null : (
<Field label="URL" hint="A public http(s) address. Private and loopback addresses are refused.">
<Field label="URL" hint={t('A public http(s) address. Private and loopback addresses are refused.')}>
<Input value={url} onChangeText={setUrl} placeholder="https://mcp.example.com/mcp" aria-label="URL" autoCapitalize="none" />
</Field>
)}
<Field label="Secret" hint="Optional. Kept sealed in KMS, sent on every call, and never shown again.">
<Input value={secret} onChangeText={setSecret} placeholder="Bearer …" aria-label="Secret" secureTextEntry />
<Field label={t('Secret')} hint={t('Optional. Kept sealed in KMS, sent on every call, and never shown again.')}>
<Input value={secret} onChangeText={setSecret} placeholder="Bearer …" aria-label={t('Secret')} secureTextEntry />
</Field>
{secret ? (
<Field label="Header">
<Input value={header} onChangeText={setHeader} aria-label="Header" autoCapitalize="none" />
<Field label={t('Header')}>
<Input value={header} onChangeText={setHeader} aria-label={t('Header')} autoCapitalize="none" />
</Field>
) : null}
<Line>{note}</Line>
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="ghost" onPress={onClose}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" disabled={working} onPress={() => void save()}>
Add
{t('Add')}
</Button>
</XStack>
</Sheet>
@@ -508,8 +517,9 @@ function Connect({ listing, onClose, onAdded }: { listing: Listing | null; onClo
/** One of the org's servers: where it is, its secret, its tools and their switches, and removing it. */
function Detail({ server, may, onClose, onDeleted }: { server: Server; may: boolean; onClose: () => void; onDeleted: () => void }) {
const t = useTarget()
const list = useLoad(() => tools(t, { source: 'mcp' }), [] as Tool[], [t, server.id])
const { t } = useI18n()
const target = useTarget()
const list = useLoad(() => tools(target, { source: 'mcp' }), [] as Tool[], [target, server.id])
const its = useMemo(() => list.value.filter((x) => owns(server, x.name)), [list.value, server])
const [busy, setBusy] = useState(false)
const [note, setNote] = useState('')
@@ -520,10 +530,10 @@ function Detail({ server, may, onClose, onDeleted }: { server: Server; may: bool
setBusy(true)
setNote('')
try {
await toggle(t, on, offNames)
await toggle(target, on, offNames)
list.reload()
} catch (e) {
setNote(say(e))
setNote(say(e, t))
} finally {
setBusy(false)
}
@@ -538,26 +548,30 @@ function Detail({ server, may, onClose, onDeleted }: { server: Server; may: bool
<XStack items="center" gap="$1.5">
{server.secret ? <KeyRound size={12} opacity={0.7} /> : <ServerIcon size={12} opacity={0.7} />}
<SizableText size="$1" color="$soft">
{server.secret ? `A secret is sealed in KMS and sent in ${server.header || 'its header'}.` : 'No secret: it is called as it is.'}
{server.secret
? server.header
? t('A secret is sealed in KMS and sent in {header}.', { header: server.header })
: t('A secret is sealed in KMS and sent in its header.')
: t('No secret: it is called as it is.')}
</SizableText>
</XStack>
</YStack>
<XStack items="center" gap="$2">
<SizableText size="$3" color="$ink" flex={1}>
Tools
{t('Tools')}
</SizableText>
{its.length && may ? (
<Button size="sm" variant="outline" disabled={busy} onPress={() => void (off.length ? flip(off, []) : flip([], its.map((x) => x.name)))}>
{off.length ? 'Turn all on' : 'Turn all off'}
{off.length ? t('Turn all on') : t('Turn all off')}
</Button>
) : null}
</XStack>
{list.loading && !list.value.length ? (
<Soft>Loading its tools…</Soft>
<Soft>{t('Loading its tools…')}</Soft>
) : list.error && !list.value.length ? (
<Failed error={list.error} onRetry={list.reload} />
) : !its.length ? (
<Soft>No tools listed yet. A server that does not answer shows its tools once it does.</Soft>
<Soft>{t('No tools listed yet. A server that does not answer shows its tools once it does.')}</Soft>
) : (
<YStack borderWidth={1} borderColor="$borderColor" rounded="$3" overflow="hidden">
{its.map((x, i) => {
@@ -575,10 +589,10 @@ function Detail({ server, may, onClose, onDeleted }: { server: Server; may: bool
) : null}
</YStack>
{may ? (
<Switch checked={x.activated} disabled={busy} onCheckedChange={(v: boolean) => void flip(v ? [x.name] : [], v ? [] : [x.name])} aria-label={`${short} on`} />
<Switch checked={x.activated} disabled={busy} onCheckedChange={(v: boolean) => void flip(v ? [x.name] : [], v ? [] : [x.name])} aria-label={t('{name} on', { name: short })} />
) : (
<SizableText size="$1" color="$soft">
{x.activated ? 'On' : 'Off'}
{x.activated ? t('On') : t('Off')}
</SizableText>
)}
</XStack>
@@ -586,21 +600,21 @@ function Detail({ server, may, onClose, onDeleted }: { server: Server; may: bool
})}
</YStack>
)}
<Line>{note || (may ? '' : 'Only an org admin can switch or remove connectors.')}</Line>
<Line>{note || (may ? '' : t('Only an org admin can switch or remove connectors.'))}</Line>
{may ? (
<XStack justify="flex-start">
<Button size="sm" variant="ghost" onPress={() => setAsking(true)}>
Remove connector
{t('Remove connector')}
</Button>
</XStack>
) : null}
<Confirm
what={server.name}
says="Its tools leave your organization and every agent, and its sealed secret is destroyed."
says={t('Its tools leave your organization and every agent, and its sealed secret is destroyed.')}
open={asking}
onOpenChange={setAsking}
onYes={async () => {
await remove(t, server.id)
await remove(target, server.id)
onDeleted()
}}
/>
+50 -17
View File
@@ -8,8 +8,12 @@
* Skills and connectors ride into every run in the org, so adding, switching
* and removing them is an org admin's and a member reads them; plugins and
* agents are any member's.
*
* Its words are the reader's language (@hanzo/i18n `t`).
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { BookOpen, Bot, Plug, Plus, Puzzle } from '@hanzogui/lucide-icons-2'
import { Button, Input } from '@hanzo/ui'
import { useCallback, useState, type ReactNode } from 'react'
@@ -25,14 +29,43 @@ import { Choice, type Pane, type View } from './ui.tsx'
/** What every run in the org loads: an org admin's to add to, a member's to read. */
export const ORG_KIT: Tab[] = ['skills', 'connectors']
const LABEL: Record<Tab, { title: string; says: string; add: string; find: string; icon: ReactNode }> = {
skills: { title: 'Skills', says: 'Know-how an agent reads before it works.', add: 'New skill', find: 'Search skills', icon: <BookOpen size={15} /> },
connectors: { title: 'Connectors', says: 'Apps and tools an agent can call.', add: 'Add by URL', find: 'Search connectors', icon: <Plug size={15} /> },
plugins: { title: 'Plugins', says: 'Connectors you build in TypeScript.', add: 'Build plugin', find: 'Search plugins', icon: <Puzzle size={15} /> },
agents: { title: 'Agents', says: 'Agents with their own model, instructions and budget.', add: 'New agent', find: 'Search agents', icon: <Bot size={15} /> },
/**
* Each tab's words, English and marked (`phrase`), drawn through `t`: its name,
* its line, its add control, its search and the search's hint, and, for a tab
* an org admin keeps (ORG_KIT), what a member is told.
*/
const LABEL: Record<Tab, { title: string; says: string; add: string; find: string; typing: string; admin?: string; icon: ReactNode }> = {
skills: {
title: phrase('Skills'),
says: phrase('Know-how an agent reads before it works.'),
add: phrase('New skill'),
find: phrase('Search skills'),
typing: phrase('Search skills…'),
admin: phrase('Only an org admin can add or change skills. You can see what is on.'),
icon: <BookOpen size={15} />,
},
connectors: {
title: phrase('Connectors'),
says: phrase('Apps and tools an agent can call.'),
add: phrase('Add by URL'),
find: phrase('Search connectors'),
typing: phrase('Search connectors…'),
admin: phrase('Only an org admin can add or change connectors. You can see what is on.'),
icon: <Plug size={15} />,
},
plugins: { title: phrase('Plugins'), says: phrase('Connectors you build in TypeScript.'), add: phrase('Build plugin'), find: phrase('Search plugins'), typing: phrase('Search plugins…'), icon: <Puzzle size={15} /> },
agents: {
title: phrase('Agents'),
says: phrase('Agents with their own model, instructions and budget.'),
add: phrase('New agent'),
find: phrase('Search agents'),
typing: phrase('Search agents…'),
icon: <Bot size={15} />,
},
}
export function Customize({ tab, view: first }: { tab: Tab; view?: View }) {
const { t } = useI18n()
const host = useHost()
const [view, setView] = useState<View>(first ?? 'discover')
const [q, setQ] = useState('')
@@ -56,15 +89,15 @@ export function Customize({ tab, view: first }: { tab: Tab; view?: View }) {
{/* A host whose sidebar lists the tabs heads the page with the tab itself. */}
<YStack gap="$1">
<SizableText render="h1" size="$6" color="$ink">
{host.sections ? LABEL[tab].title : 'Customize'}
{host.sections ? t(LABEL[tab].title) : t('Customize')}
</SizableText>
<SizableText size="$2" color="$soft">
{host.sections ? LABEL[tab].says : 'What your agent can use in every run.'}
{host.sections ? t(LABEL[tab].says) : t('What your agent can use in every run.')}
</SizableText>
</YStack>
{host.sections ? null : (
<XStack role="tablist" aria-label="Customize" gap="$4" borderBottomWidth={1} borderColor="$borderColor" $max-md={{ gap: '$3' }}>
<XStack role="tablist" aria-label={t('Customize')} gap="$4" borderBottomWidth={1} borderColor="$borderColor" $max-md={{ gap: '$3' }}>
{TABS.map((id) => {
const on = id === tab
return (
@@ -86,7 +119,7 @@ export function Customize({ tab, view: first }: { tab: Tab; view?: View }) {
{LABEL[id].icon}
</XStack>
<SizableText size="$3" color={on ? '$ink' : '$soft'}>
{LABEL[id].title}
{t(LABEL[id].title)}
</SizableText>
</XStack>
)
@@ -96,31 +129,31 @@ export function Customize({ tab, view: first }: { tab: Tab; view?: View }) {
{host.sections ? null : (
<SizableText size="$2" color="$soft">
{LABEL[tab].says}
{t(LABEL[tab].says)}
</SizableText>
)}
<XStack items="center" gap="$2">
<Choice
label="Show"
label={t('Show')}
value={view}
options={[
{ id: 'discover', label: 'Browse' },
{ id: 'yours', label: 'Yours', count },
{ id: 'discover', label: t('Browse') },
{ id: 'yours', label: t('Yours'), count },
]}
onChange={setView}
/>
<XStack flex={1} />
{may ? (
<Button size="sm" onPress={() => setAdding(true)}>
<Plus size={14} /> {LABEL[tab].add}
<Plus size={14} /> {t(LABEL[tab].add)}
</Button>
) : null}
</XStack>
<Input value={q} onChangeText={setQ} placeholder={`${LABEL[tab].find}…`} aria-label={LABEL[tab].find} />
{host.person && kit && !host.admin ? (
<Input value={q} onChangeText={setQ} placeholder={t(LABEL[tab].typing)} aria-label={t(LABEL[tab].find)} />
{host.person && kit && !host.admin && LABEL[tab].admin ? (
<SizableText size="$1" color="$soft">
Only an org admin can add or change {LABEL[tab].title.toLowerCase()}. You can see what is on.
{t(LABEL[tab].admin)}
</SizableText>
) : null}
+64 -49
View File
@@ -8,8 +8,12 @@
* Describe an API instead of writing the TypeScript and a model writes it; the
* result opens to be read. A plugin carries no credential: it names the
* connector whose credential it reads when it runs.
*
* Its words are the reader's language (@hanzo/i18n `t`); a plugin's name, its
* source and the bundler's reasons are drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import { Puzzle } from '@hanzogui/lucide-icons-2'
import { Button, Input, Textarea } from '@hanzo/ui'
import { Code } from '@hanzo/ui/chat'
@@ -24,11 +28,12 @@ import { Choice, Confirm, day, Empty, Failed, Field, Grid, Line, Mark, matches,
const kb = (n: number): string => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`)
export function Plugins({ view, q, adding, onAdding, onView, onCount }: Pane) {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const signed = Boolean(host.person)
const mine = useLoad(signed ? () => authored(t) : null, [] as Plugin[], [t, signed])
const mounts = useLoad(signed && view === 'discover' ? () => mounted(t) : null, [] as Mount[], [t, signed, view])
const mine = useLoad(signed ? () => authored(target) : null, [] as Plugin[], [target, signed])
const mounts = useLoad(signed && view === 'discover' ? () => mounted(target) : null, [] as Mount[], [target, signed, view])
const [opened, setOpened] = useState<Plugin | null>(null)
const [note, setNote] = useState('')
useCount(mine.value.length, onCount)
@@ -52,7 +57,7 @@ export function Plugins({ view, q, adding, onAdding, onView, onCount }: Pane) {
plugin={opened}
onClose={() => setOpened(null)}
onDeleted={() => {
setNote(`${opened.name} is deleted`)
setNote(t('{name} is deleted', { name: opened.name }))
setOpened(null)
mine.reload()
}}
@@ -62,7 +67,7 @@ export function Plugins({ view, q, adding, onAdding, onView, onCount }: Pane) {
)
if (!signed) {
return <Visitor>{view === 'discover' ? 'Sign in to browse plugins.' : 'Sign in to see your plugins.'}</Visitor>
return <Visitor>{view === 'discover' ? t('Sign in to browse plugins.') : t('Sign in to see your plugins.')}</Visitor>
}
if (view === 'discover') {
@@ -70,28 +75,28 @@ export function Plugins({ view, q, adding, onAdding, onView, onCount }: Pane) {
return (
<YStack gap="$6">
<Line>{note}</Line>
<Part title="Build your own" detail="Write a connector in TypeScript, or describe an API and Hanzo writes it.">
<Part title={t('Build your own')} detail={t('Write a connector in TypeScript, or describe an API and Hanzo writes it.')}>
<XStack>
<Button size="sm" onPress={() => onAdding(true)}>
Build a plugin
{t('Build a plugin')}
</Button>
</XStack>
</Part>
<Part title="Built in" detail="Part of Hanzo. Always on, nothing to add.">
<Part title={t('Built in')} detail={t('Part of Hanzo. Always on, nothing to add.')}>
{mounts.error && !mounts.value.length ? (
<Failed error={mounts.error} onRetry={mounts.reload} />
) : mounts.loading && !mounts.value.length ? (
<Soft>Loading built-in plugins…</Soft>
<Soft>{t('Loading built-in plugins…')}</Soft>
) : !shown.length ? (
<Soft>{mounts.value.length ? `Nothing built in matches “${q.trim()}”.` : 'Nothing built in is listed.'}</Soft>
<Soft>{mounts.value.length ? t('Nothing built in matches “{q}”.', { q: q.trim() }) : t('Nothing built in is listed.')}</Soft>
) : (
<>
<SizableText size="$1" color="$soft">
{shown.length} built in
{t('{n} built in', { n: shown.length })}
</SizableText>
<Grid label="Mounted subsystems">
<Grid label={t('Mounted subsystems')}>
{shown.map((m) => (
<Tile key={m.name} title={m.name} detail={m.prefixes.join(' ')} meta={m.enabled ? 'On' : 'Off'} mark={<Mark name={m.name} icon={<Puzzle size={15} />} />} />
<Tile key={m.name} title={m.name} detail={m.prefixes.join(' ')} meta={m.enabled ? t('On') : t('Off')} mark={<Mark name={m.name} icon={<Puzzle size={15} />} />} />
))}
</Grid>
</>
@@ -109,23 +114,23 @@ export function Plugins({ view, q, adding, onAdding, onView, onCount }: Pane) {
{mine.error && !mine.value.length ? (
<Failed error={mine.error} onRetry={mine.reload} />
) : mine.loading && !mine.value.length ? (
<Soft>Loading your plugins…</Soft>
<Soft>{t('Loading your plugins…')}</Soft>
) : !mine.value.length ? (
<Empty title="No plugins yet" detail="Write a connector in TypeScript, or describe an API and Hanzo writes it.">
<Empty title={t('No plugins yet')} detail={t('Write a connector in TypeScript, or describe an API and Hanzo writes it.')}>
<Button size="sm" onPress={() => onAdding(true)}>
Build a plugin
{t('Build a plugin')}
</Button>
</Empty>
) : !shown.length ? (
<Soft>{`No plugin of yours matches “${q.trim()}”.`}</Soft>
<Soft>{t('No plugin of yours matches “{q}”.', { q: q.trim() })}</Soft>
) : (
<Grid label="Your plugins">
<Grid label={t('Your plugins')}>
{shown.map((p) => (
<Tile
key={p.id}
title={p.name}
detail={p.provider ? `Reads the ${p.provider} connector’s credential when it runs.` : 'Needs no credential.'}
meta={day(p.built) ? `Built ${day(p.built)}` : undefined}
detail={p.provider ? t('Reads the {provider} connector’s credential when it runs.', { provider: p.provider }) : t('Needs no credential.')}
meta={day(p.built) ? t('Built {date}', { date: day(p.built) }) : undefined}
mark={<Mark name={p.name} />}
onOpen={() => setOpened(p)}
/>
@@ -137,14 +142,10 @@ export function Plugins({ view, q, adding, onAdding, onView, onCount }: Pane) {
)
}
const WAYS = [
{ id: 'source', label: 'TypeScript' },
{ id: 'spec', label: 'Describe an API' },
] as const
/** Build a plugin from its TypeScript, or from a description of the API it calls. */
function Build({ onClose, onBuilt }: { onClose: () => void; onBuilt: (p: Plugin, said: string) => void }) {
const t = useTarget()
const { t } = useI18n()
const target = useTarget()
const [name, setName] = useState('')
const [provider, setProvider] = useState('')
const [way, setWay] = useState<'source' | 'spec'>('source')
@@ -156,39 +157,48 @@ function Build({ onClose, onBuilt }: { onClose: () => void; onBuilt: (p: Plugin,
const go = async () => {
setWorking(true)
setNote(way === 'spec' ? 'Writing and building it…' : 'Building it…')
setNote(way === 'spec' ? t('Writing and building it…') : t('Building it…'))
try {
const out = await build(t, { name: name.trim(), provider, source: way === 'source' ? source : '', spec: way === 'spec' ? spec : '' })
onBuilt(out.plugin, `${out.plugin.name} built: ${kb(out.bytes)}${out.generated ? ', written from your description — read it below' : ''}.`)
const out = await build(target, { name: name.trim(), provider, source: way === 'source' ? source : '', spec: way === 'spec' ? spec : '' })
const built = { name: out.plugin.name, size: kb(out.bytes) }
onBuilt(out.plugin, out.generated ? t('{name} built: {size}, written from your description — read it below.', built) : t('{name} built: {size}.', built))
} catch (e) {
setNote(say(e))
setNote(say(e, t))
} finally {
setWorking(false)
}
}
return (
<Sheet title="Build a plugin" open onOpenChange={(o) => !o && onClose()} width={680}>
<Field label="Name" hint="One lowercase word: letters, digits, _ or -.">
<Input value={name} onChangeText={setName} aria-label="Name" autoCapitalize="none" />
<Sheet title={t('Build a plugin')} open onOpenChange={(o) => !o && onClose()} width={680}>
<Field label={t('Name')} hint={t('One lowercase word: letters, digits, _ or -.')}>
<Input value={name} onChangeText={setName} aria-label={t('Name')} autoCapitalize="none" />
</Field>
<Field label="Connector" hint="Optional. The connector whose key it uses when it runs; a plugin never holds a key itself.">
<Input value={provider} onChangeText={setProvider} aria-label="Connector" autoCapitalize="none" />
<Field label={t('Connector')} hint={t('Optional. The connector whose key it uses when it runs; a plugin never holds a key itself.')}>
<Input value={provider} onChangeText={setProvider} aria-label={t('Connector')} autoCapitalize="none" />
</Field>
<Choice label="Build from" value={way} options={WAYS} onChange={setWay} />
<Choice
label={t('Build from')}
value={way}
options={[
{ id: 'source', label: 'TypeScript' },
{ id: 'spec', label: t('Describe an API') },
]}
onChange={setWay}
/>
<Field
label={way === 'source' ? 'Source' : 'The API'}
label={way === 'source' ? t('Source') : t('The API')}
hint={
way === 'source'
? 'Kept only if it builds. A failed build says why.'
: 'An OpenAPI document, or the endpoints in plain words. You read the TypeScript before it runs.'
? t('Kept only if it builds. A failed build says why.')
: t('An OpenAPI document, or the endpoints in plain words. You read the TypeScript before it runs.')
}
>
<Textarea
value={way === 'source' ? source : spec}
onChangeText={way === 'source' ? setSource : setSpec}
aria-label={way === 'source' ? 'Source' : 'The API'}
placeholder={way === 'source' ? 'export const acme = createProvider({ … })' : 'POST /v1/things creates a thing…'}
aria-label={way === 'source' ? t('Source') : t('The API')}
placeholder={way === 'source' ? 'export const acme = createProvider({ … })' : t('POST /v1/things creates a thing…')}
rows={14}
style={mono}
/>
@@ -196,10 +206,10 @@ function Build({ onClose, onBuilt }: { onClose: () => void; onBuilt: (p: Plugin,
<Line>{note}</Line>
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="ghost" onPress={onClose}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" disabled={working} onPress={() => void go()}>
Build
{t('Build')}
</Button>
</XStack>
</Sheet>
@@ -208,13 +218,18 @@ function Build({ onClose, onBuilt }: { onClose: () => void; onBuilt: (p: Plugin,
/** A built plugin's source, as the runtime will run it, and deleting it. */
function Source({ plugin, onClose, onDeleted }: { plugin: Plugin; onClose: () => void; onDeleted: () => void }) {
const t = useTarget()
const { t } = useI18n()
const target = useTarget()
const [asking, setAsking] = useState(false)
return (
<Sheet title={plugin.name} open onOpenChange={(o) => !o && onClose()} width={760}>
<SizableText size="$2" color="$soft">
{plugin.provider ? `Reads the ${plugin.provider} connector’s credential when it runs.` : 'Needs no credential.'}
{day(plugin.built) ? ` Built ${day(plugin.built)}.` : ''}
{[
plugin.provider ? t('Reads the {provider} connector’s credential when it runs.', { provider: plugin.provider }) : t('Needs no credential.'),
day(plugin.built) ? t('Built {date}.', { date: day(plugin.built) }) : '',
]
.filter(Boolean)
.join(' ')}
</SizableText>
<Code language="TypeScript" value={plugin.source}>
<SizableText size="$1" color="$ink" style={{ ...mono, whiteSpace: 'pre' }}>
@@ -223,16 +238,16 @@ function Source({ plugin, onClose, onDeleted }: { plugin: Plugin; onClose: () =>
</Code>
<YStack items="flex-start">
<Button size="sm" variant="ghost" onPress={() => setAsking(true)}>
Delete plugin
{t('Delete plugin')}
</Button>
</YStack>
<Confirm
what={plugin.name}
says="The runtime can no longer load it."
says={t('The runtime can no longer load it.')}
open={asking}
onOpenChange={setAsking}
onYes={async () => {
await remove(t, plugin.id)
await remove(target, plugin.id)
onDeleted()
}}
/>
+21 -14
View File
@@ -4,27 +4,34 @@
* 422 carries the platform's own sentence, because those name what was wrong
* with what was sent (a name taken, a build that failed and why). A sentence
* this client wrote itself (a name that does not fit) is said as written.
*
* Said in the reader's language: the screen hands its `t` (English without
* one). A sentence a module wrote is English and marked, so it goes through
* `t`; the platform's own words are no key and come back from it as given.
*/
import { Refusal } from '../api/call.ts'
import type { Say } from '@hanzo/i18n/react'
import { Refusal, told } from '../api/call.ts'
import { Slow } from '../data.ts'
import { english } from '../say.ts'
/** The line call.ts writes when the platform gave no reason: a method, a path and a status. */
const BARE = /^(GET|POST|PUT|PATCH|DELETE) \S* answered \d+$/
export function say(e: unknown): string {
if (e instanceof Slow) return 'Hanzo is taking too long to answer. Try again.'
export function say(e: unknown, t: Say = english): string {
if (e instanceof Slow) return t('Hanzo is taking too long to answer. Try again.')
if (e instanceof Refusal) {
if (e.status === 401) return 'Your sign-in has expired. Sign in again.'
if (e.status === 403) return 'Your account can’t do this here. Ask an org admin.'
if (e.status === 404) return 'Not found. It may have been removed.'
if (e.status === 429) return 'Too many requests. Wait a moment and try again.'
if (e.status >= 500) return 'Hanzo could not answer just now. Try again.'
if ((e.status === 400 || e.status === 409 || e.status === 422) && e.message && !BARE.test(e.message)) return e.message
return 'Hanzo could not do that. Try again.'
if (e.status === 401) return t('Your sign-in has expired. Sign in again.')
if (e.status === 403) return t('Your account can’t do this here. Ask an org admin.')
if (e.status === 404) return t('Not found. It may have been removed.')
if (e.status === 429) return t('Too many requests. Wait a moment and try again.')
if (e.status >= 500) return t('Hanzo could not answer just now. Try again.')
if ((e.status === 400 || e.status === 409 || e.status === 422) && e.message && !BARE.test(e.message)) return t(e.message)
return t('Hanzo could not do that. Try again.')
}
// fetch rejects with a TypeError when the network does; a body that is not JSON is a SyntaxError.
if (e instanceof TypeError) return 'Could not reach Hanzo. Check your connection and try again.'
if (e instanceof SyntaxError) return 'Hanzo answered with something unreadable. Try again.'
if (e instanceof Error && e.message) return e.message
return 'Something went wrong. Try again.'
if (e instanceof TypeError) return t('Could not reach Hanzo. Check your connection and try again.')
if (e instanceof SyntaxError) return t('Hanzo answered with something unreadable. Try again.')
if (e instanceof Error && e.message) return told(e, t)
return t('Something went wrong. Try again.')
}
+64 -53
View File
@@ -6,8 +6,12 @@
* all adding is: the platform keeps no copy. Yours is the org's own skills —
* written here, each with its switch — and the catalogue's skills it has added.
* A skill written here is switched on when it is saved.
*
* Its words are the reader's language (@hanzo/i18n `t`); a skill's name,
* description and SKILL.md are the platform's or the org's, drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import { BookOpen } from '@hanzogui/lucide-icons-2'
import { Button, Input, Switch, Textarea } from '@hanzo/ui'
import { Code } from '@hanzo/ui/chat'
@@ -30,14 +34,15 @@ interface Reading {
}
export function Skills({ view, q, adding, onAdding, onView, onCount }: Pane) {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const signed = Boolean(host.person)
// What every run in the org loads is an org admin's to change; a member reads it.
const may = signed && host.admin
const own = useLoad(signed ? () => authored(t) : null, [] as Skill[], [t, signed])
const on = useLoad(signed ? () => active(t) : null, [] as Tool[], [t, signed])
const shelf = useLoad(view === 'discover' ? () => brand(t) : null, null as Catalogue | null, [t.api, view])
const own = useLoad(signed ? () => authored(target) : null, [] as Skill[], [target, signed])
const on = useLoad(signed ? () => active(target) : null, [] as Tool[], [target, signed])
const shelf = useLoad(view === 'discover' ? () => brand(target) : null, null as Catalogue | null, [target.api, view])
const [editing, setEditing] = useState<Skill | null>(null)
const [reading, setReading] = useState<Reading | null>(null)
const [busy, setBusy] = useState('')
@@ -54,11 +59,11 @@ export function Skills({ view, q, adding, onAdding, onView, onCount }: Pane) {
setBusy(name)
setNote('')
try {
await toggle(t, next ? [tool(name)] : [], next ? [] : [tool(name)])
await toggle(target, next ? [tool(name)] : [], next ? [] : [tool(name)])
on.reload()
setNote(next ? `${name} is on` : `${name} is off`)
setNote(next ? t('{name} is on', { name }) : t('{name} is off', { name }))
} catch (e) {
setNote(say(e))
setNote(say(e, t))
} finally {
setBusy('')
}
@@ -89,13 +94,13 @@ export function Skills({ view, q, adding, onAdding, onView, onCount }: Pane) {
own.reload()
on.reload()
onView('yours')
setNote(fresh ? `${s.name} is saved and on` : `${s.name} is saved`)
setNote(fresh ? t('{name} is saved and on', { name: s.name }) : t('{name} is saved', { name: s.name }))
}}
onDeleted={(name) => {
setEditing(null)
own.reload()
on.reload()
setNote(`${name} is deleted`)
setNote(t('{name} is deleted', { name }))
}}
/>
) : null
@@ -106,19 +111,19 @@ export function Skills({ view, q, adding, onAdding, onView, onCount }: Pane) {
return (
<YStack gap="$3">
<Line>{note}</Line>
<Part title="From Hanzo" detail="Add one and every run in your organization can use it.">
<Part title={t('From Hanzo')} detail={t('Add one and every run in your organization can use it.')}>
{shelf.error && !shelf.value ? (
<Failed error={shelf.error} onRetry={shelf.reload} />
) : !shelf.value ? (
<Soft>Loading skills…</Soft>
<Soft>{t('Loading skills…')}</Soft>
) : !found.length ? (
<Soft>{all.length ? `No skill matches “${q.trim()}”.` : 'There are no skills to add yet.'}</Soft>
<Soft>{all.length ? t('No skill matches “{q}”.', { q: q.trim() }) : t('There are no skills to add yet.')}</Soft>
) : (
<>
<SizableText size="$1" color="$soft">
{found.length === all.length ? `${all.length} skills` : `${found.length} of ${all.length} skills`}
{found.length === all.length ? t('{n} skills', { n: all.length }) : t('{found} of {n} skills', { found: found.length, n: all.length })}
</SizableText>
<Grid label="Skills to add">
<Grid label={t('Skills to add')}>
{found.slice(0, shown).map((e: Entry) => (
<Tile
key={e.name}
@@ -134,7 +139,7 @@ export function Skills({ view, q, adding, onAdding, onView, onCount }: Pane) {
{found.length > shown ? (
<XStack justify="center">
<Button size="sm" variant="outline" onPress={() => setShown(shown + PAGE)}>
Show more
{t('Show more')}
</Button>
</XStack>
) : null}
@@ -148,7 +153,7 @@ export function Skills({ view, q, adding, onAdding, onView, onCount }: Pane) {
}
if (!signed) {
return <Visitor>Sign in to see your skills.</Visitor>
return <Visitor>{t('Sign in to see your skills.')}</Visitor>
}
const ownShown = own.value.filter((s) => matches(q, s.name, s.description))
const addedShown = added.filter((x) => matches(q, nameOf(x.name), x.description))
@@ -165,39 +170,43 @@ export function Skills({ view, q, adding, onAdding, onView, onCount }: Pane) {
}}
/>
) : (own.loading || on.loading) && !own.value.length && !on.value.length ? (
<Soft>Loading your skills…</Soft>
<Soft>{t('Loading your skills…')}</Soft>
) : !own.value.length && !added.length ? (
<Empty title="No skills yet" detail={may ? 'Add one from Hanzo, or write your own.' : 'An org admin adds skills. Browse what there is.'}>
<Empty title={t('No skills yet')} detail={may ? t('Add one from Hanzo, or write your own.') : t('An org admin adds skills. Browse what there is.')}>
<Button size="sm" variant="outline" onPress={() => onView('discover')}>
Browse skills
{t('Browse skills')}
</Button>
{may ? (
<Button size="sm" onPress={() => onAdding(true)}>
Write a skill
{t('Write a skill')}
</Button>
) : null}
</Empty>
) : !ownShown.length && !addedShown.length ? (
<Soft>{`No skill of yours matches “${q.trim()}”.`}</Soft>
<Soft>{t('No skill of yours matches “{q}”.', { q: q.trim() })}</Soft>
) : (
<>
{ownShown.length ? (
<Part title="Made by your team" detail="Off keeps a skill but leaves it out of every run.">
<Grid label="Your skills">
<Part title={t('Made by your team')} detail={t('Off keeps a skill but leaves it out of every run.')}>
<Grid label={t('Your skills')}>
{ownShown.map((s) => (
<Tile
key={s.id}
title={s.name}
detail={s.description || firstLine(s.content)}
meta={[s.source ? `From ${s.source}` : day(s.created) ? `Saved ${day(s.created)}` : '', s.admitted ? '' : 'Not in runs until an admin saves it'].filter(Boolean).join(' · ') || undefined}
meta={
[s.source ? t('From {source}', { source: s.source }) : day(s.created) ? t('Saved {date}', { date: day(s.created) }) : '', s.admitted ? '' : t('Not in runs until an admin saves it')]
.filter(Boolean)
.join(' · ') || undefined
}
mark={<Mark name={s.name} />}
onOpen={() => setEditing(s)}
action={
may ? (
<Switch checked={lit.has(tool(s.name))} disabled={busy === s.name} onCheckedChange={(v: boolean) => void flip(s.name, v)} aria-label={`${s.name} on`} />
<Switch checked={lit.has(tool(s.name))} disabled={busy === s.name} onCheckedChange={(v: boolean) => void flip(s.name, v)} aria-label={t('{name} on', { name: s.name })} />
) : (
<SizableText size="$1" color="$soft">
{lit.has(tool(s.name)) ? 'On' : 'Off'}
{lit.has(tool(s.name)) ? t('On') : t('Off')}
</SizableText>
)
}
@@ -207,8 +216,8 @@ export function Skills({ view, q, adding, onAdding, onView, onCount }: Pane) {
</Part>
) : null}
{addedShown.length ? (
<Part title="Added from Hanzo" detail="On for every run in your organization.">
<Grid label="Added skills">
<Part title={t('Added from Hanzo')} detail={t('On for every run in your organization.')}>
<Grid label={t('Added skills')}>
{addedShown.map((x) => {
const name = nameOf(x.name)
return (
@@ -220,8 +229,8 @@ export function Skills({ view, q, adding, onAdding, onView, onCount }: Pane) {
onOpen={() => setReading({ name, description: x.description, product: '' })}
action={
may ? (
<Button size="sm" variant="ghost" disabled={busy === name} onPress={() => void flip(name, false)} aria-label={`Remove ${name}`}>
Remove
<Button size="sm" variant="ghost" disabled={busy === name} onPress={() => void flip(name, false)} aria-label={t('Remove {name}', { name })}>
{t('Remove')}
</Button>
) : undefined
}
@@ -261,8 +270,9 @@ function Reader({
onFlip: (next: boolean) => void
onClose: () => void
}) {
const t = useTarget()
const doc = useLoad(() => document(t, skill.name), '', [t.api, skill.name])
const { t } = useI18n()
const target = useTarget()
const doc = useLoad(() => document(target, skill.name), '', [target.api, skill.name])
return (
<Sheet title={skill.name} open onOpenChange={(o) => !o && onClose()} width={720}>
{skill.description ? (
@@ -273,17 +283,17 @@ function Reader({
{signed ? (
<XStack gap="$2" items="center">
<Button size="sm" variant={added ? 'outline' : 'default'} disabled={busy} onPress={() => onFlip(!added)}>
{added ? 'Remove' : 'Add to your skills'}
{added ? t('Remove') : t('Add to your skills')}
</Button>
<SizableText size="$1" color="$soft" flex={1}>
{added ? 'On for your organization.' : 'Every run in your organization can use it once added.'}
{added ? t('On for your organization.') : t('Every run in your organization can use it once added.')}
</SizableText>
</XStack>
) : null}
{doc.error ? (
<Failed error={doc.error} onRetry={doc.reload} />
) : doc.loading && !doc.value ? (
<Soft>Loading SKILL.md…</Soft>
<Soft>{t('Loading SKILL.md…')}</Soft>
) : (
<Code language="SKILL.md" value={doc.value}>
<SizableText size="$1" color="$ink" style={{ ...mono, whiteSpace: 'pre-wrap' }}>
@@ -310,7 +320,8 @@ function Editor({
onSaved: (s: Skill, fresh: boolean) => void
onDeleted: (name: string) => void
}) {
const t = useTarget()
const { t } = useI18n()
const target = useTarget()
const [name, setName] = useState(skill?.name ?? '')
const [description, setDescription] = useState(skill?.description ?? '')
const [content, setContent] = useState(skill?.content ?? '')
@@ -322,58 +333,58 @@ function Editor({
setWorking(true)
setNote('')
try {
const saved = await write(t, { name: name.trim(), description: description.trim(), content })
const saved = await write(target, { name: name.trim(), description: description.trim(), content })
// A new skill is switched on as it is saved; a revised one keeps its switch.
if (!skill) await toggle(t, [tool(saved.name)])
if (!skill) await toggle(target, [tool(saved.name)])
onSaved(saved, !skill)
} catch (e) {
setNote(say(e))
setNote(say(e, t))
} finally {
setWorking(false)
}
}
return (
<Sheet title={skill ? skill.name : 'New skill'} open onOpenChange={(o) => !o && onClose()} width={680}>
<Sheet title={skill ? skill.name : t('New skill')} open onOpenChange={(o) => !o && onClose()} width={680}>
{skill?.source ? (
<SizableText size="$2" color="$soft">
Read from {skill.source}. The next push of that repository replaces what is saved here.
{t('Read from {source}. The next push of that repository replaces what is saved here.', { source: skill.source })}
</SizableText>
) : null}
<Field label="Name" hint={skill ? 'A skill keeps its name. Save under another name to make a new one.' : 'One lowercase word: letters, digits, _ or -.'}>
<Input value={name} onChangeText={setName} aria-label="Name" disabled={Boolean(skill)} autoCapitalize="none" />
<Field label={t('Name')} hint={skill ? t('A skill keeps its name. Save under another name to make a new one.') : t('One lowercase word: letters, digits, _ or -.')}>
<Input value={name} onChangeText={setName} aria-label={t('Name')} disabled={Boolean(skill)} autoCapitalize="none" />
</Field>
<Field label="Description" hint="One line. An agent reads it to decide whether it needs the skill.">
<Input value={description} onChangeText={setDescription} placeholder="How we triage an incoming issue" aria-label="Description" />
<Field label={t('Description')} hint={t('One line. An agent reads it to decide whether it needs the skill.')}>
<Input value={description} onChangeText={setDescription} placeholder={t('How we triage an incoming issue')} aria-label={t('Description')} />
</Field>
<Field label="SKILL.md" hint="Markdown: what it is for, when to use it, and the steps.">
<Textarea value={content} onChangeText={setContent} placeholder={'# Triage\n\n1. Read the issue…'} aria-label="SKILL.md" rows={14} style={mono} />
<Field label="SKILL.md" hint={t('Markdown: what it is for, when to use it, and the steps.')}>
<Textarea value={content} onChangeText={setContent} placeholder={t('# Triage\n\n1. Read the issue…')} aria-label="SKILL.md" rows={14} fontFamily="$mono" />
</Field>
<Line>{note || (may ? '' : 'Only an org admin can write or delete skills.')}</Line>
<Line>{note || (may ? '' : t('Only an org admin can write or delete skills.'))}</Line>
<XStack gap="$2" items="center">
{skill && may ? (
<Button size="sm" variant="ghost" onPress={() => setAsking(true)}>
Delete
{t('Delete')}
</Button>
) : null}
<XStack flex={1} />
<Button size="sm" variant="ghost" onPress={onClose}>
{may ? 'Cancel' : 'Close'}
{may ? t('Cancel') : t('Close')}
</Button>
{may ? (
<Button size="sm" disabled={working} onPress={() => void save()}>
Save
{t('Save')}
</Button>
) : null}
</XStack>
{skill ? (
<Confirm
what={skill.name}
says="The skill leaves your organization and every agent that names it."
says={t('The skill leaves your organization and every agent that names it.')}
open={asking}
onOpenChange={setAsking}
onYes={async () => {
await remove(t, skill.id)
await remove(target, skill.id)
onDeleted(skill.name)
}}
/>
+33 -23
View File
@@ -4,8 +4,13 @@
* featured card, a dialog with a title and a scrolling body, a delete that asks
* first, the empty state that names the next step, a failure with its retry,
* and the quiet line for loading and signed-out.
*
* Their own words are the reader's language (@hanzo/i18n `t`); a title, a
* line or a label a tab hands them comes already said, since it may be a
* name or a description that is drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import { Check, Plus, X } from '@hanzogui/lucide-icons-2'
import { Button, Dialog, DialogContent, DialogTitle } from '@hanzo/ui'
import { useEffect, useState, type ReactNode } from 'react'
@@ -14,9 +19,6 @@ import { useHost } from '../host.tsx'
import { say } from './say.ts'
export const mono = { fontFamily: 'var(--f-mono, ui-monospace, monospace)' }
/** A button that holds a card's text reads from the left, not from the middle a button centres it in. */
export const left = { textAlign: 'left' } as const
/** Which half of a tab: what there is to browse and add (first), or what the org already has. */
export type View = 'discover' | 'yours'
@@ -121,7 +123,8 @@ export function Tile({
gap="$3"
items="flex-start"
cursor="pointer"
style={left}
// A button centres its words unless told: a card reads from the line's start.
$platform-web={{ textAlign: 'start' }}
focusVisibleStyle={{ outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }}
>
{body}
@@ -138,13 +141,14 @@ export function Tile({
/** The one card the shelf puts first, drawn larger. */
export function Featured({ title, detail, meta, mark, action, onOpen }: { title: string; detail: string; meta: string; mark: ReactNode; action?: ReactNode; onOpen: () => void }) {
const { t } = useI18n()
return (
<XStack items="flex-start" gap="$4" p="$4" rounded="$4" borderWidth={1} borderColor="$borderColor" bg="$panel" flexWrap="wrap" rowGap="$3">
<XStack render="button" aria-label={title} onPress={onOpen} flex={1} minW={220} gap="$4" items="flex-start" cursor="pointer" style={left}>
<XStack render="button" aria-label={title} onPress={onOpen} flex={1} minW={220} gap="$4" items="flex-start" cursor="pointer" $platform-web={{ textAlign: 'start' }}>
{mark}
<YStack flex={1} minW={0} gap="$1.5">
<SizableText size="$1" color="$soft" textTransform="uppercase" letterSpacing={1}>
Featured
{t('Featured')}
</SizableText>
<SizableText size="$6" color="$ink" numberOfLines={1}>
{title}
@@ -162,27 +166,29 @@ export function Featured({ title, detail, meta, mark, action, onOpen }: { title:
)
}
/** A card's add control: Add while it is not yours, Added once it is. */
export function Add({ name, added, busy, onPress, label = 'Add' }: { name: string; added: boolean; busy?: boolean; onPress: () => void; label?: string }) {
/** A card's add control: Add (Use, for a template one is made from) while it is not yours, Added once it is. */
export function Add({ name, added, busy, onPress, use = false }: { name: string; added: boolean; busy?: boolean; onPress: () => void; use?: boolean }) {
const { t } = useI18n()
if (added) {
return (
<XStack aria-label={`${name} is added`} gap="$1" height={32} items="center" shrink={0}>
<XStack aria-label={t('{name} is added', { name })} gap="$1" height={32} items="center" shrink={0}>
<Check size={14} />
<SizableText size="$1" color="$soft">
Added
{t('Added')}
</SizableText>
</XStack>
)
}
return (
<Button size="sm" variant="outline" aria-label={`${label} ${name}`} disabled={busy} onPress={onPress}>
<Plus size={14} /> {label}
<Button size="sm" variant="outline" aria-label={use ? t('Use {name}', { name }) : t('Add {name}', { name })} disabled={busy} onPress={onPress}>
<Plus size={14} /> {use ? t('Use') : t('Add')}
</Button>
)
}
/** A dialog with a title, a close control and a body that scrolls inside the window. */
export function Sheet({ title, open, onOpenChange, width = 560, children }: { title: string; open: boolean; onOpenChange: (o: boolean) => void; width?: number; children: ReactNode }) {
const { t } = useI18n()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent maxW={width} showCloseButton={false} maxH="88vh" gap="$3">
@@ -190,7 +196,7 @@ export function Sheet({ title, open, onOpenChange, width = 560, children }: { ti
<DialogTitle numberOfLines={1} flex={1}>
{title}
</DialogTitle>
<XStack render="button" aria-label="Close" p="$1" onPress={() => onOpenChange(false)}>
<XStack render="button" aria-label={t('Close')} p="$1" onPress={() => onOpenChange(false)}>
<X size={16} />
</XStack>
</XStack>
@@ -202,8 +208,9 @@ export function Sheet({ title, open, onOpenChange, width = 560, children }: { ti
)
}
/** Asks before a delete; says what goes with it. */
/** Asks before a delete; says what goes with it. `what` is the thing's name, `says` comes already said. */
export function Confirm({ what, says, open, onOpenChange, onYes }: { what: string; says: string; open: boolean; onOpenChange: (o: boolean) => void; onYes: () => Promise<void> }) {
const { t } = useI18n()
const [busy, setBusy] = useState(false)
const [note, setNote] = useState('')
const yes = async () => {
@@ -213,23 +220,23 @@ export function Confirm({ what, says, open, onOpenChange, onYes }: { what: strin
await onYes()
onOpenChange(false)
} catch (e) {
setNote(say(e))
setNote(say(e, t))
} finally {
setBusy(false)
}
}
return (
<Sheet title={`Delete ${what}?`} open={open} onOpenChange={onOpenChange} width={420}>
<Sheet title={t('Delete {name}?', { name: what })} open={open} onOpenChange={onOpenChange} width={420}>
<SizableText size="$2" color="$soft">
{says}
</SizableText>
<Line>{note}</Line>
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="ghost" onPress={() => onOpenChange(false)}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" variant="destructive" disabled={busy} onPress={() => void yes()}>
Delete
{t('Delete')}
</Button>
</XStack>
</Sheet>
@@ -250,13 +257,14 @@ export function Soft({ children, action }: { children: string; action?: ReactNod
/** A read that failed: what went wrong in plain words, and the one control that asks again. */
export function Failed({ error, onRetry }: { error: unknown; onRetry: () => void }) {
const { t } = useI18n()
return (
<YStack py="$6" items="center" gap="$3" role="alert">
<SizableText size="$2" color="$soft" text="center">
{say(error)}
{say(error, t)}
</SizableText>
<Button size="sm" variant="outline" onPress={onRetry}>
Try again
{t('Try again')}
</Button>
</YStack>
)
@@ -285,12 +293,13 @@ export function Empty({ title, detail, children }: { title: string; detail: stri
/** What a signed-out visitor is told, with the one control that signs them in. */
export function Visitor({ children }: { children: string }) {
const { t } = useI18n()
const host = useHost()
return (
<Soft
action={
<Button size="sm" onPress={() => host.signIn?.()}>
Sign in
{t('Sign in')}
</Button>
}
>
@@ -346,7 +355,7 @@ export function Field({ label, hint, children }: { label: string; hint?: string;
)
}
/** Two or three choices side by side; the chosen one is filled. A count, when one is given, follows its label. */
/** Two or three choices side by side; the chosen one is filled. A count, when one is given, follows its label. Labels come already said. */
export function Choice<T extends string>({
value,
options,
@@ -358,6 +367,7 @@ export function Choice<T extends string>({
onChange: (v: T) => void
label: string
}) {
const { t } = useI18n()
return (
<XStack role="group" aria-label={label} p={2} gap={2} rounded="$3" borderWidth={1} borderColor="$borderColor" self="flex-start" shrink={0}>
{options.map((o) => (
@@ -365,7 +375,7 @@ export function Choice<T extends string>({
key={o.id}
render="button"
aria-pressed={o.id === value}
aria-label={o.count ? `${o.label}, ${o.count}` : o.label}
aria-label={o.count ? t('{label}, {count}', { label: o.label, count: o.count }) : o.label}
onPress={() => onChange(o.id)}
items="center"
gap="$1.5"
+3 -2
View File
@@ -6,6 +6,7 @@
* signed in is not attempted — the platform would refuse it and the refusal
* would read as an outage.
*/
import { phrase } from '@hanzo/i18n'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Target } from './api/call.ts'
@@ -56,10 +57,10 @@ export function useRead<T>(load: (() => Promise<T>) | null, initial: T, key: unk
/** How long a read is waited on before the screen says it is taking too long. */
export const WAIT = 15_000
/** A read that did not answer in time. */
/** A read that did not answer in time. Its message is ours, marked, so a screen drawing it through `t` says it in the reader's language. */
export class Slow extends Error {
constructor() {
super('Hanzo is taking too long to answer.')
super(phrase('Hanzo is taking too long to answer.'))
this.name = 'Slow'
}
}
+71 -59
View File
@@ -7,8 +7,12 @@
* The sandbox outlives the run: kept when the run ends, parked while nobody
* watches, retired a day after it parks. The bar under the tabs says which and
* suspends or resumes it; a tab that needs it running resumes it (sandbox.ts).
*
* Its words are the reader's language (@hanzo/i18n `t`); a repository, branch,
* port, address, command and the sandbox's own error are drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { useI18n, type Say } from '@hanzo/i18n/react'
import { ExternalLink, Info, MoreHorizontal, RefreshCcw } from '@hanzogui/lucide-icons-2'
import { Button, DropdownMenu, type DropdownMenuProps } from '@hanzo/ui'
import { PreviewFrame, type PreviewHandle } from '@hanzo/ui/agents'
@@ -92,46 +96,47 @@ export function Desk({
sheet: boolean
onHide: () => void
}) {
const { t: say } = useI18n()
const name = repo.split('/').filter(Boolean).pop() || repo
const lines = shell(events)
const failed = failure(events)
const failed = failure(events, say)
const box = useBox(sandbox)
// Nothing is said of the sandbox until it has been read.
const held = box.value?.state ?? ''
const environ = () => side.open({ kind: 'environment', title: 'Environment' })
const environ = () => side.open({ kind: 'environment', title: say('Environment') })
const kinds: Kind[] = [
{ id: 'preview', label: 'Preview', bleed: true, render: () => <Browser key={published} sandbox={sandbox} held={held} site={site} live={live} /> },
{ id: 'preview', label: say('Preview'), bleed: true, render: () => <Browser key={published} sandbox={sandbox} held={held} site={site} live={live} /> },
{
id: 'artifacts',
label: 'Artifacts',
label: say('Artifacts'),
render: () => <Artifacts session={id} sandbox={sandbox} repo={repo} branch={branch} mode={mode} project={project} site={site} pr={pr} onEnvironment={environ} />,
},
{ id: 'files', label: 'Files', render: () => <Files session={id} repo={repo} branch={branch} sandbox={sandbox} live={held === 'running'} /> },
{ id: 'diff', label: 'Diff', render: () => <Git session={id} title={title} live={live} /> },
{ id: 'files', label: say('Files'), render: () => <Files session={id} repo={repo} branch={branch} sandbox={sandbox} live={held === 'running'} /> },
{ id: 'diff', label: say('Diff'), render: () => <Git session={id} title={title} live={live} /> },
{
id: 'terminal',
label: 'Terminal',
label: say('Terminal'),
bleed: true,
render: () => <Terminal id={id} sandbox={sandbox} held={held} lines={lines} live={live} failed={failed} refused={refused} retry={retry} onRetry={onRetry} />,
},
{ id: 'desktop', label: 'Desktop', bleed: true, render: () => <Door which="screen" sandbox={sandbox} held={held} session={id} /> },
{ id: 'desktop', label: say('Desktop'), bleed: true, render: () => <Door which="screen" sandbox={sandbox} held={held} session={id} /> },
{
id: 'environment',
label: 'Environment',
label: say('Environment'),
render: () => (
<YStack gap="$2">
<Environment repo={name} busy={live && mode === 'setup'} />
<SizableText size="$2" color="$ink" pt="$4">
This run
{say('This run')}
</SizableText>
<Fact label="Run" value={id} />
<Fact label="Repository" value={name || '—'} />
<Fact label="Branch" value={branch || '—'} />
<Fact label="Base" value={base || '—'} />
<Fact label="Runs on" value={environment || 'sandbox'} />
<Fact label="Sandbox" value={sandbox || '—'} />
<Fact label="Mode" value={mode || 'build'} />
<Fact label={say('Run')} value={id} />
<Fact label={say('Repository')} value={name || '—'} />
<Fact label={say('Branch')} value={branch || '—'} />
<Fact label={say('Base')} value={base || '—'} />
<Fact label={say('Runs on')} value={environment || 'sandbox'} />
<Fact label={say('Sandbox')} value={sandbox || '—'} />
<Fact label={say('Mode')} value={mode || 'build'} />
</YStack>
),
},
@@ -143,18 +148,18 @@ export function Desk({
kinds={kinds}
sheet={sheet}
onHide={onHide}
label="Run details"
label={say('Run details')}
// An empty tab offers what this run made: its site and its pull request.
suggested={[...(site ? [{ title: name || site, url: site }] : []), ...(pr.href ? [{ title: `Pull request ${pr.label}`, url: pr.href }] : [])]}
suggested={[...(site ? [{ title: name || site, url: site }] : []), ...(pr.href ? [{ title: say('Pull request {pr}', { pr: pr.label }), url: pr.href }] : [])]}
bar={sandbox ? <Held box={box} live={live} /> : null}
menu={
<DropdownMenu
trigger={
<XStack render="button" aria-label="Run actions" p="$1.5" rounded="$2" hoverStyle={{ bg: '$hover' }}>
<XStack render="button" aria-label={say('Run actions')} p="$1.5" rounded="$2" hoverStyle={{ bg: '$hover' }}>
<MoreHorizontal size={16} />
</XStack>
}
items={[...menu, { key: 'details', label: 'Details', icon: <Info size={16} />, onSelect: environ }]}
items={[...menu, { key: 'details', label: say('Details'), icon: <Info size={16} />, onSelect: environ }]}
/>
}
/>
@@ -219,33 +224,36 @@ const until = (ms: number): string => new Date(ms).toLocaleString(undefined, { w
* A sandbox a run is working in is not suspended from here.
*/
function Held({ box, live }: { box: Watch; live: boolean }) {
const { t } = useI18n()
const b = box.value
const state = b?.state ?? 'pending'
const says =
state === 'running'
? 'Sandbox running'
? t('Sandbox running')
: state === 'parked'
? b?.ends
? `Sandbox suspended · its files are kept until ${until(b.ends)}`
: 'Sandbox suspended · its files are kept'
? t('Sandbox suspended · its files are kept until {time}', { time: until(b.ends) })
: t('Sandbox suspended · its files are kept')
: state === 'pending'
? 'Sandbox starting…'
? t('Sandbox starting…')
: state === 'gone'
? 'Sandbox ended'
: `Sandbox unavailable${b?.error ? ` · ${b.error}` : ''}`
? t('Sandbox ended')
: b?.error
? t('Sandbox unavailable · {error}', { error: b.error })
: t('Sandbox unavailable')
return (
<XStack px="$3" py="$1.5" gap="$2" items="center" borderBottomWidth={1} borderColor="$borderColor" aria-label="Sandbox">
<XStack px="$3" py="$1.5" gap="$2" items="center" borderBottomWidth={1} borderColor="$borderColor" aria-label={t('Sandbox')}>
<YStack width={7} height={7} rounded={99} bg={state === 'running' ? '$green10' : state === 'parked' ? '$soft' : '$borderColor'} />
<SizableText flex={1} minW={0} size="$1" color="$soft" numberOfLines={1}>
{box.note || says}
</SizableText>
{state === 'running' && !live ? (
<Button size="sm" variant="ghost" disabled={box.busy} onPress={() => box.act('park')}>
Suspend
{t('Suspend')}
</Button>
) : state === 'parked' ? (
<Button size="sm" variant="outline" disabled={box.busy} onPress={() => box.act('wake')}>
{box.busy ? 'Resuming…' : 'Resume'}
{box.busy ? t('Resuming…') : t('Resume')}
</Button>
) : null}
</XStack>
@@ -259,10 +267,10 @@ const PREVIEW = 'hanzo-preview'
const REMINTS = 2
const REMINT = 30_000
/** What a preview page's status means to the person looking at it, when it is not reopened. */
const SAYS: Record<number, (port: number) => string> = {
401: () => 'This browser keeps the preview’s cookie out of a frame. Open it in a new tab.',
502: (port) => `Nothing answered on port ${port}. Start the server, then Retry.`,
/** What a preview page's status means to the person looking at it, when it is not reopened, in the language of `say`. */
const SAYS: Record<number, (port: number, say: Say) => string> = {
401: (_, say) => `${say('This browser keeps the preview’s cookie out of a frame.')} ${say('Open it in a new tab.')}`,
502: (port, say) => `${say('Nothing answered on port {port}.', { port })} ${say('Start the server, then Retry.')}`,
}
/**
@@ -273,6 +281,7 @@ const SAYS: Record<number, (port: number) => string> = {
* in a while; any other status is said, and Retry is the person's.
*/
function Browser({ sandbox, held, site, live }: { sandbox: string; held: Box['state'] | ''; site: string; live: boolean }) {
const { t: say } = useI18n()
const t = useTarget()
const [open, setOpen] = useState<Port[]>([])
const [port, setPort] = useState(0)
@@ -338,12 +347,12 @@ function Browser({ sandbox, held, site, live }: { sandbox: string; held: Box['st
setAttempt((a) => a + 1)
return
}
const say = SAYS[m.status]
if (say || m.status === 409) setWhy(say ? say(port) : 'The sandbox stopped serving. Retry to open it again.')
const words = SAYS[m.status]
if (words || m.status === 409) setWhy(words ? words(port, say) : `${say('The sandbox stopped serving.')} ${say('Retry to open it again.')}`)
}
window.addEventListener('message', hear)
return () => window.removeEventListener('message', hear)
}, [src, port])
}, [src, port, say])
const tab = () => away(preview(t, sandbox, port)).catch((e: Error) => setWhy(e.message))
@@ -353,13 +362,13 @@ function Browser({ sandbox, held, site, live }: { sandbox: string; held: Box['st
return (
<YStack flex={1} minH={0}>
<XStack px="$2" py="$1.5" gap="$1.5" items="center" borderBottomWidth={1} borderColor="$borderColor">
<XStack render="button" aria-label="Reload the page" onPress={() => setAttempt((a) => a + 1)} p="$1.5" rounded="$2" hoverStyle={{ bg: '$hover' }}>
<XStack render="button" aria-label={say('Reload the page')} onPress={() => setAttempt((a) => a + 1)} p="$1.5" rounded="$2" hoverStyle={{ bg: '$hover' }}>
<RefreshCcw size={14} />
</XStack>
{open.length > 1 ? (
<XStack gap="$1">
{open.map((p) => (
<XStack key={p.port} render="button" aria-label={`Port ${p.port}`} onPress={() => setPort(p.port)} px="$2" py="$1" rounded="$2" bg={p.port === port ? '$hover' : 'transparent'}>
<XStack key={p.port} render="button" aria-label={say('Port {port}', { port: p.port })} onPress={() => setPort(p.port)} px="$2" py="$1" rounded="$2" bg={p.port === port ? '$hover' : 'transparent'}>
<SizableText size="$1" color={p.port === port ? '$ink' : '$soft'}>
{String(p.port)}
</SizableText>
@@ -368,9 +377,9 @@ function Browser({ sandbox, held, site, live }: { sandbox: string; held: Box['st
</XStack>
) : null}
<SizableText flex={1} minW={0} size="$1" color="$soft" numberOfLines={1} style={mono}>
{host || `port ${port}`}
{host || say('port {port}', { port })}
</SizableText>
<XStack render="button" aria-label="Open in a new tab" onPress={() => void tab()} p="$1.5" rounded="$2" hoverStyle={{ bg: '$hover' }}>
<XStack render="button" aria-label={say('Open in a new tab')} onPress={() => void tab()} p="$1.5" rounded="$2" hoverStyle={{ bg: '$hover' }}>
<ExternalLink size={14} />
</XStack>
</XStack>
@@ -381,10 +390,10 @@ function Browser({ sandbox, held, site, live }: { sandbox: string; held: Box['st
</SizableText>
<XStack gap="$2">
<Button size="sm" variant="outline" onPress={() => setAttempt((a) => a + 1)}>
Retry
{say('Retry')}
</Button>
<Button size="sm" variant="outline" onPress={() => void tab()}>
Open in a new tab
{say('Open in a new tab')}
</Button>
</XStack>
</YStack>
@@ -392,14 +401,14 @@ function Browser({ sandbox, held, site, live }: { sandbox: string; held: Box['st
<iframe
ref={frame}
src={src}
title={`What the run serves on port ${port}`}
title={say('What the run serves on port {port}', { port })}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
style={{ border: 0, width: '100%', height: '100%', display: 'block', background: '#fff' }}
/>
) : (
<YStack flex={1} items="center" justify="center">
<SizableText size="$2" color="$soft">
{`Opening port ${port}…`}
{say('Opening port {port}…', { port })}
</SizableText>
</YStack>
)}
@@ -412,18 +421,19 @@ function Browser({ sandbox, held, site, live }: { sandbox: string; held: Box['st
* page in a tab of its own; or what keeps its sandbox from serving.
*/
function Site({ site, live, held }: { site: string; live: boolean; held: Box['state'] | '' }) {
const { t } = useI18n()
const frame = useRef<PreviewHandle | null>(null)
return (
<YStack flex={1} minH={0}>
<XStack px="$2" py="$1.5" gap="$1.5" items="center" borderBottomWidth={1} borderColor="$borderColor">
<XStack render="button" aria-label="Reload the page" onPress={() => frame.current?.reload()} p="$1.5" rounded="$2" hoverStyle={{ bg: '$hover' }}>
<XStack render="button" aria-label={t('Reload the page')} onPress={() => frame.current?.reload()} p="$1.5" rounded="$2" hoverStyle={{ bg: '$hover' }}>
<RefreshCcw size={14} />
</XStack>
<SizableText flex={1} minW={0} size="$1" color="$soft" numberOfLines={1} style={mono}>
{site || 'Nothing is serving yet'}
{site || t('Nothing is serving yet')}
</SizableText>
{site ? (
<Out href={site} label="Open in a new tab">
<Out href={site} label={t('Open in a new tab')}>
<XStack p="$1.5" rounded="$2" hoverStyle={{ bg: '$hover' }}>
<ExternalLink size={14} />
</XStack>
@@ -433,19 +443,19 @@ function Site({ site, live, held }: { site: string; live: boolean; held: Box['st
<PreviewFrame
ref={frame}
src={site}
title="Where this run is published"
title={t('Where this run is published')}
device="desktop"
empty={
<YStack items="center" gap="$2" p="$6">
<SizableText size="$3" color="$ink">
Nothing is serving yet
{t('Nothing is serving yet')}
</SizableText>
<SizableText size="$2" color="$soft" style={{ textAlign: 'center' }}>
{held === 'parked'
? 'The run’s sandbox is suspended. Resume it to see what it serves.'
? `${t('The run’s sandbox is suspended.')} ${t('Resume it to see what it serves.')}`
: live
? 'When the run starts a server in its sandbox, the page opens here.'
: 'Nothing listens in this run’s sandbox, and it published no site. Its work is on its branch.'}
? t('When the run starts a server in its sandbox, the page opens here.')
: `${t('Nothing listens in this run’s sandbox, and it published no site.')} ${t('Its work is on its branch.')}`}
</SizableText>
</YStack>
}
@@ -497,6 +507,7 @@ function Terminal({
retry: string
onRetry: () => void
}) {
const { t } = useI18n()
const reachable = Boolean(sandbox) && held === 'running'
// The shell while there is one, unless the log was asked for.
const [log, setLog] = useState(false)
@@ -505,15 +516,15 @@ function Terminal({
<YStack flex={1} minH={0} bg="$background">
<XStack px="$2" py="$1.5" gap="$1" borderBottomWidth={1} borderColor="$borderColor" items="center">
{reachable ? (
<XStack render="button" aria-label="Shell" onPress={() => setLog(false)} px="$2" py="$1" rounded="$2" bg={showShell ? '$hover' : 'transparent'}>
<XStack render="button" aria-label={t('Shell')} onPress={() => setLog(false)} px="$2" py="$1" rounded="$2" bg={showShell ? '$hover' : 'transparent'}>
<SizableText size="$1" color={showShell ? '$ink' : '$soft'}>
Shell
{t('Shell')}
</SizableText>
</XStack>
) : null}
<XStack render="button" aria-label="Agent log" onPress={() => setLog(true)} px="$2" py="$1" rounded="$2" bg={showShell ? 'transparent' : '$hover'}>
<XStack render="button" aria-label={t('Agent log')} onPress={() => setLog(true)} px="$2" py="$1" rounded="$2" bg={showShell ? 'transparent' : '$hover'}>
<SizableText size="$1" color={showShell ? '$soft' : '$ink'}>
Agent log
{t('Agent log')}
</SizableText>
</XStack>
</XStack>
@@ -537,6 +548,7 @@ function Log({
retry: string
onRetry: () => void
}) {
const { t } = useI18n()
const prompt = 'workspace'
const idle = lines.length === 0 && !live
return (
@@ -545,7 +557,7 @@ function Log({
{idle ? (
<YStack flex={1} items="center" justify="center" gap="$3" py="$8">
<SizableText size="$2" color="$soft" style={{ textAlign: 'center' }}>
{refused || (failed ? `This run stopped before its first command: ${failed}` : 'This run finished without writing a command.')}
{refused || (failed ? t('This run stopped before its first command: {reason}', { reason: failed }) : t('This run finished without writing a command.'))}
</SizableText>
{refused ? (
<Button size="sm" variant="outline" onPress={onRetry}>
@@ -582,7 +594,7 @@ function Log({
</YStack>
<XStack px="$3" py="$2" borderTopWidth={1} borderColor="$borderColor">
<SizableText size="$1" color="$soft">
Commands appear here as the run writes them.
{t('Commands appear here as the run writes them.')}
</SizableText>
</XStack>
</YStack>
+38 -12
View File
@@ -7,8 +7,13 @@
* past the deadline, is a failed connection, and Retry mints a new ticket: a
* ticket is spent the moment its socket opens, so reloading the old address can
* only fail again.
*
* What a door says is the reader's language (@hanzo/i18n `t`), one sentence
* per door; the page's own reason for a failed connection is drawn as given.
*/
import { SizableText, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { Button } from '@hanzo/ui'
import { useEffect, useRef, useState } from 'react'
@@ -17,9 +22,28 @@ import { useTarget } from './host.tsx'
/** What each page signs its messages to the window that frames it with. */
const SOURCE = { screen: 'hanzo-screen', terminal: 'hanzo-term' } as const
const WHAT = { screen: 'desktop', terminal: 'terminal' } as const
const DEADLINE = 20_000
/** What each door says of itself, one sentence per door, marked (`phrase`) and drawn through `t`. */
const SAYS = {
screen: {
starting: phrase('The desktop opens once the run’s sandbox is up.'),
parked: phrase('This run’s sandbox is suspended with its files kept. Resume it from the bar above to open its desktop.'),
error: phrase('This run’s sandbox could not start, so it has no desktop.'),
unreachable: phrase('Unable to reach the desktop. The run may have stopped.'),
title: phrase('The run’s desktop'),
connecting: phrase('Connecting to the desktop…'),
},
terminal: {
starting: phrase('The terminal opens once the run’s sandbox is up.'),
parked: phrase('This run’s sandbox is suspended with its files kept. Resume it from the bar above to open its terminal.'),
error: phrase('This run’s sandbox could not start, so it has no terminal.'),
unreachable: phrase('Unable to reach the terminal. The run may have stopped.'),
title: phrase('The run’s terminal'),
connecting: phrase('Connecting to the terminal…'),
},
} as const
type State = 'opening' | 'open' | 'failed'
/**
@@ -27,7 +51,9 @@ type State = 'opening' | 'open' | 'failed'
* a running sandbox has an open door.
*/
export function Door({ which, sandbox, held, session }: { which: Which; sandbox: string; held: Held | ''; session: string }) {
const t = useTarget()
const target = useTarget()
const { t } = useI18n()
const says = SAYS[which]
const frame = useRef<HTMLIFrameElement>(null)
const [src, setSrc] = useState('')
const [state, setState] = useState<State>('opening')
@@ -41,7 +67,7 @@ export function Door({ which, sandbox, held, session }: { which: Which; sandbox:
setSrc('')
setState('opening')
setWhy('')
door(t, sandbox, which, session).then(
door(target, sandbox, which, session).then(
(u) => {
if (!gone) setSrc(u)
},
@@ -54,7 +80,7 @@ export function Door({ which, sandbox, held, session }: { which: Which; sandbox:
return () => {
gone = true
}
}, [t, sandbox, which, session, up, attempt])
}, [target, sandbox, which, session, up, attempt])
useEffect(() => {
if (!src) return
@@ -81,22 +107,22 @@ export function Door({ which, sandbox, held, session }: { which: Which; sandbox:
}, [src, which])
if (!sandbox || held === '' || held === 'pending') {
return <Shut title="Starting" body={`The ${WHAT[which]} opens once the run’s sandbox is up.`} />
return <Shut title={t('Starting')} body={t(says.starting)} />
}
if (held === 'parked') {
return <Shut title="Suspended" body={`This run’s sandbox is suspended with its files kept. Resume it from the bar above to open its ${WHAT[which]}.`} />
return <Shut title={t('Suspended')} body={t(says.parked)} />
}
if (held === 'gone') {
return <Shut title="Ended" body="This run’s sandbox has ended. Its work is on its branch and in its artifacts." />
return <Shut title={t('Ended')} body={t('This run’s sandbox has ended. Its work is on its branch and in its artifacts.')} />
}
if (held === 'error') {
return <Shut title="Unavailable" body={`This run’s sandbox could not start, so it has no ${WHAT[which]}.`} />
return <Shut title={t('Unavailable')} body={t(says.error)} />
}
if (state === 'failed') {
return (
<Shut title="Failed to connect" body={why || `Unable to reach the ${WHAT[which]}. The run may have stopped.`}>
<Shut title={t('Failed to connect')} body={why || t(says.unreachable)}>
<Button size="sm" variant="outline" onPress={() => setAttempt((a) => a + 1)}>
Retry
{t('Retry')}
</Button>
</Shut>
)
@@ -107,7 +133,7 @@ export function Door({ which, sandbox, held, session }: { which: Which; sandbox:
<iframe
ref={frame}
src={src}
title={which === 'screen' ? 'The run’s desktop' : 'The run’s terminal'}
title={t(says.title)}
allow="clipboard-read; clipboard-write"
style={{ border: 0, width: '100%', height: '100%', display: 'block', background: '#000' }}
/>
@@ -115,7 +141,7 @@ export function Door({ which, sandbox, held, session }: { which: Which; sandbox:
{state === 'opening' ? (
<YStack position="absolute" t={0} l={0} r={0} b={0} items="center" justify="center" pointerEvents="none">
<SizableText size="$2" color="$soft">
{`Connecting to the ${WHAT[which]}…`}
{t(says.connecting)}
</SizableText>
</YStack>
) : null}
+154
View File
@@ -0,0 +1,154 @@
/**
* Projects in the reader's language (language-projects.tsx), as a host that
* mounts @hanzo/i18n draws it: the heading and its line, the two ways in, the
* GitHub line, the list's columns, rows and pages, and the empty page's two
* cards — each word the catalog's, a repository's name, org and branch its
* own. Right to left, the page mirrors: a row's name at its right, when it
* moved at its left, a card's arrow turned, nothing past the edge.
*/
import type { Page } from '@playwright/test'
import { expect, test } from './fixture.ts'
import type { Lang } from './language.tsx'
const AGO = new Date(Date.now() - 10 * 60_000).toISOString()
/** Two repositories, the newest first, as the forge lists them. */
const REPOS = [
{ name: 'universe', org: 'acme', description: 'The cluster, declared', defaultBranch: 'main', updatedAt: AGO },
{ name: 'site', org: 'acme', description: '', defaultBranch: 'next', updatedAt: AGO },
]
/** A French catalog this spec writes, so what is drawn is checked against what was written. */
const FR: Record<string, string> = {
Projects: 'Projets',
'{org}’s repositories on the forge. Open one to work on it.': 'Les dépôts de {org} sur la forge. Ouvrez-en un pour y travailler.',
'Create from template': 'Créer depuis un modèle',
'Link a GitHub repo': 'Lier un dépôt GitHub',
'Connect GitHub to bring your repositories here': 'Connectez GitHub pour amener vos dépôts ici',
'One step on GitHub, then pick the repositories to sync.': 'Une étape sur GitHub, puis choisissez les dépôts à synchroniser.',
'Connect GitHub': 'Connecter GitHub',
'Find a project…': 'Trouver un projet…',
'Find a project': 'Trouver un projet',
Name: 'Nom',
Branch: 'Branche',
Updated: 'Mis à jour',
'Sort by name': 'Trier par nom',
'Open {repo}': 'Ouvrir {repo}',
'Showing {from}–{to} of {total}': '{from}–{to} sur {total}',
'Previous page': 'Page précédente',
Prev: 'Préc.',
'Next page': 'Page suivante',
'No projects yet. A project is a repository: link one from GitHub, or start one from a template.':
'Aucun projet pour l’instant. Un projet est un dépôt : liez-en un depuis GitHub, ou partez d’un modèle.',
'Start from a finished product — a site, an app, a store — and make it yours.': 'Partez d’un produit fini — un site, une app, une boutique — et faites-le vôtre.',
}
const AR: Record<string, string> = {
Projects: 'المشاريع',
Name: 'الاسم',
Updated: 'آخر تحديث',
'Open {repo}': 'افتح {repo}',
'Link a GitHub repo': 'اربط مستودع GitHub',
'Create from template': 'أنشئ من قالب',
}
async function open(p: Page, lang: Lang, repos: object[]): Promise<void> {
await p.route(
(u) => u.pathname.startsWith('/v1/'),
(r) => {
const path = new URL(r.request().url()).pathname
if (path === '/v1/git/repos') return r.fulfill({ json: { data: repos } })
if (path === '/v1/provider/github/user') return r.fulfill({ json: { configured: true, connected: false } })
return r.fulfill({ status: 404, json: { detail: 'not here' } })
},
)
await p.addInitScript((l) => {
;(window as unknown as { lang: Lang }).lang = l
}, lang)
await p.route('**/-/language-projects', 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-projects.tsx')
await r.fulfill({ response: res, body: html })
})
await p.goto('/-/language-projects')
await expect(p.locator('html')).toHaveAttribute('lang', lang.locale, { timeout: 45_000 })
}
const shot = (p: Page, name: string) => p.screenshot({ path: test.info().outputPath(name) })
test('in French, Projects is the catalog’s words around the forge’s own names', async ({ page }) => {
await open(page, { locale: 'fr-FR', catalog: FR }, REPOS)
await expect(page.getByRole('heading', { name: FR.Projects })).toBeVisible({ timeout: 45_000 })
await expect(page.getByText('Les dépôts de acme sur la forge. Ouvrez-en un pour y travailler.')).toBeVisible()
await expect(page.getByRole('button', { name: FR['Create from template'] })).toBeVisible()
await expect(page.getByRole('button', { name: FR['Link a GitHub repo'] })).toBeVisible()
await expect(page.getByText(FR['Connect GitHub to bring your repositories here']!)).toBeVisible()
await expect(page.getByText(FR['One step on GitHub, then pick the repositories to sync.']!)).toBeVisible()
await expect(page.getByRole('button', { name: FR['Connect GitHub'] })).toBeVisible()
// The list: the columns in French, each row by its own name, org and branch.
await expect(page.getByLabel(FR['Find a project']!)).toHaveAttribute('placeholder', FR['Find a project…']!)
await expect(page.getByRole('button', { name: FR['Sort by name'] })).toHaveText(FR.Name!)
await expect(page.getByText(FR.Branch!, { exact: true })).toBeVisible()
// A word the catalog lacks is its English, never a key.
await expect(page.getByRole('button', { name: 'Sort by last updated' })).toHaveText(FR.Updated!)
const universe = page.getByRole('button', { name: 'Ouvrir acme/universe' })
await expect(universe).toContainText('acme/universe')
await expect(universe).toContainText('The cluster, declared')
await expect(universe).toContainText('main')
await expect(page.getByRole('button', { name: 'Ouvrir acme/site' })).toContainText('next')
await expect(page.getByText('1–2 sur 2')).toBeVisible()
await expect(page.getByRole('button', { name: FR['Previous page'] })).toHaveText(FR.Prev!)
await expect(page.getByRole('button', { name: FR['Next page'] })).toHaveText('Next')
await shot(page, 'language-projects-fr.png')
})
test('in French, the empty page offers its two ways in, in the catalog’s words', async ({ page }) => {
await open(page, { locale: 'fr-FR', catalog: FR }, [])
await expect(page.getByText(FR['No projects yet. A project is a repository: link one from GitHub, or start one from a template.']!)).toBeVisible({ timeout: 45_000 })
const card = page.getByRole('button', { name: FR['Create from template'] }).last()
await expect(card).toContainText(FR['Start from a finished product — a site, an app, a store — and make it yours.']!)
// The other card's line the catalog lacks: English.
await expect(page.getByRole('button', { name: FR['Link a GitHub repo'] }).last()).toContainText('Mirror repositories you already have.')
await shot(page, 'language-projects-fr-empty.png')
})
test('in Arabic, Projects reads right to left: a row’s name at its right, when it moved at its left, nothing past the edge', async ({ page }) => {
await open(page, { locale: 'ar', catalog: AR }, REPOS)
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl')
await expect(page.getByRole('heading', { name: AR.Projects })).toBeVisible({ timeout: 45_000 })
// A value in right-to-left words is isolated, so the name is matched around it.
const row = page.getByRole('button', { name: /^افتح .*acme\/universe/ })
const box = (await row.boundingBox())!
const middle = box.x + box.width / 2
const org = (await row.getByText('acme/', { exact: true }).boundingBox())!
const name = (await row.getByText('universe', { exact: true }).boundingBox())!
const when = (await row.getByText(/ago$/).boundingBox())!
expect(name.x).toBeGreaterThan(middle)
expect(when.x + when.width).toBeLessThan(middle)
// The address keeps its own order: acme/ before universe, read left to right.
expect(org.x + org.width).toBeLessThanOrEqual(name.x + 1)
// The header's columns stand the same way: Name at the right, Updated at the left.
const head = (await page.getByText(AR.Name!, { exact: true }).boundingBox())!
const updated = (await page.getByText(AR.Updated!, { exact: true }).boundingBox())!
expect(head.x).toBeGreaterThan(updated.x)
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-projects-ar-${width}.png`)
}
})
test('in Arabic, the empty page’s cards point along the line: their arrows turn', async ({ page }) => {
await open(page, { locale: 'ar', catalog: AR }, [])
const card = page.getByRole('button', { name: AR['Link a GitHub repo'] }).last()
await expect(card).toBeVisible({ timeout: 45_000 })
const arrow = card.locator('[data-i18n-mirror]')
await expect(arrow).toHaveCount(1)
expect(await arrow.evaluate((e) => getComputedStyle(e).transform)).toBe('matrix(-1, 0, 0, 1, 0, 0)')
await shot(page, 'language-projects-ar-empty.png')
})
+50
View File
@@ -0,0 +1,50 @@
/**
* The builder's Projects in a language, for language-projects.spec.ts: the
* builder as a host mounts it (no rail of its own), inside @hanzo/i18n's
* provider, signed in as acme's admin and opened at `-/projects`.
*
* `window.lang` before the page loads (language.tsx `Lang`): 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, YStack } from '@hanzo/ui'
import { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { Builder } from '../builder.tsx'
import type { Host } from '../host.tsx'
/** A token whose claims say acme's admin; the routed platform reads nothing else of it. */
const TOKEN = `x.${btoa(JSON.stringify({ sub: 'acme/dave', orgs: [{ org: 'acme', role: 'admin' }] })).replace(/=+$/, '')}.x`
function Mount() {
const [path, setPath] = useState('-/projects')
const host: Host = {
name: 'Hanzo',
api: window.location.origin,
token: () => TOKEN,
org: 'acme',
person: { name: 'Dave', email: '[email protected]', avatar: '' },
admin: true,
path,
go: setPath,
links: { github: '', customize: '', settings: '', home: '' },
open: () => {},
}
return <Builder host={host} rail={false} />
}
createRoot(document.getElementById('root')!).render(
<I18nProvider userId="acme/dave" initialLocale={window.lang.locale} loadCatalog={async () => window.lang.catalog}>
<Hanzo theme="dark">
<YStack flex={1} minW={0} bg="$background" style={{ height: '100dvh', overflow: 'hidden' }}>
<Mount />
</YStack>
</Hanzo>
</I18nProvider>,
)
+144
View File
@@ -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`)
}
})
+88
View File
@@ -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>,
)
+121
View File
@@ -0,0 +1,121 @@
/**
* The builder's own screens in the reader's language (translated.tsx), as a
* host that mounts @hanzo/i18n draws them: Customize's Skills in French — the
* tabs, the choice of half, the search, a shelf's heading, count and Add, and a
* skill added, each word the catalog's, a skill's name its own and what is
* sent unchanged — and the Library right to left in Arabic: its heading at the
* line's start on the right, the kinds and the day in Arabic, an item's name
* its own, nothing past the edge.
*/
import type { Page } from '@playwright/test'
import { expect, test } from './fixture.ts'
import type { Lang } from './language.tsx'
import { catalogue, customize, library } from './stubs.ts'
/** A French catalog this spec writes, so what is drawn is checked against what was written. */
const FR: Record<string, string> = {
Customize: 'Personnaliser',
Skills: 'Compétences',
Connectors: 'Connecteurs',
'Know-how an agent reads before it works.': 'Le savoir-faire qu’un agent lit avant de travailler.',
Show: 'Afficher',
Browse: 'Parcourir',
Yours: 'Les vôtres',
'Search skills': 'Rechercher des compétences',
'Search skills…': 'Rechercher des compétences…',
'New skill': 'Nouvelle compétence',
'From Hanzo': 'De Hanzo',
'Add one and every run in your organization can use it.': 'Ajoutez-en une et chaque exécution de votre organisation peut l’utiliser.',
'{n} skills': '{n} compétences',
'Add {name}': 'Ajouter {name}',
Add: 'Ajouter',
'{name} is added': '{name} est ajoutée',
Added: 'Ajoutée',
}
const AR: Record<string, string> = {
Library: 'المكتبة',
'Search your library': 'ابحث في مكتبتك',
Kind: 'النوع',
All: 'الكل',
Images: 'صور',
Videos: 'فيديوهات',
Documents: 'مستندات',
Today: 'اليوم',
Yesterday: 'أمس',
'Open {name}': 'افتح {name}',
}
/** Opens `path` in the builder's app inside the provider, in `lang`. */
async function open(p: Page, path: string, lang: Lang): Promise<void> {
await p.addInitScript((l) => {
;(window as unknown as { lang: Lang }).lang = l
}, lang)
await p.route(`**${path}`, async (r) => {
if (r.request().resourceType() !== 'document') return r.fallback()
const res = await r.fetch({ url: new URL('/', r.request().url()).href })
const html = (await res.text()).replace('/src/app/main.tsx', '/src/e2e/translated.tsx')
await r.fulfill({ response: res, body: html })
})
await p.goto(path)
await expect(p.locator('html')).toHaveAttribute('lang', lang.locale, { timeout: 45_000 })
}
const shot = (p: Page, name: string) => p.screenshot({ path: test.info().outputPath(name) })
test('in French, Customize’s Skills is the catalog’s words, a skill’s name its own, and Add sends what it always sent', async ({ page }) => {
await catalogue(page)
const sent = await customize(page)
await open(page, '/-/customize/skills', { locale: 'fr-FR', catalog: FR })
await expect(page.getByRole('tablist', { name: FR.Customize })).toBeVisible({ timeout: 45_000 })
await expect(page.getByRole('tab', { name: FR.Skills })).toHaveAttribute('aria-selected', 'true')
await expect(page.getByRole('tab', { name: FR.Connectors })).toBeVisible()
// The choice of half: its words, and the count beside Yours as a figure.
const half = page.getByRole('group', { name: FR.Show })
await expect.poll(() => half.getByRole('button').evaluateAll((els) => els.map((e) => e.getAttribute('aria-label')))).toEqual([FR.Browse, `${FR.Yours}, 2`])
await expect(page.getByRole('textbox', { name: FR['Search skills'] })).toHaveAttribute('placeholder', FR['Search skills…']!)
// The shelf: its heading and line, its count, and a word the catalog lacks in English, never a key.
await expect(page.getByRole('heading', { name: FR['From Hanzo'] })).toBeVisible()
await expect(page.getByText(FR['Add one and every run in your organization can use it.']!)).toBeVisible()
await expect(page.getByText('3 compétences', { exact: true })).toBeVisible()
await expect(page.getByRole('list', { name: 'Skills to add' })).toBeVisible()
await expect(page.getByLabel('git_branches est ajoutée')).toBeVisible()
await shot(page, 'translated-fr-skills.png')
// Add, named in French around the skill's own name; the request is the one it always was.
await page.getByRole('button', { name: 'Ajouter git_repos' }).click()
await expect(page.getByLabel('git_repos est ajoutée')).toBeVisible()
expect(sent.filter((s) => s.method === 'PUT' && s.path === '/v1/tool/activation').at(-1)?.body).toEqual({ activate: ['skill_git_repos'], deactivate: [] })
await expect(half.getByRole('button', { name: `${FR.Yours}, 3` })).toBeVisible()
})
test('in Arabic, the Library reads right to left: its heading on the right, the kinds and the day in Arabic, nothing past the edge', async ({ page }) => {
await library(page)
await open(page, '/-/artifacts', { locale: 'ar', catalog: AR })
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl')
const heading = page.getByRole('heading', { name: AR.Library, exact: true })
await expect(heading).toBeVisible({ timeout: 45_000 })
await expect(page.getByRole('textbox', { name: AR['Search your library'] })).toHaveAttribute('placeholder', AR['Search your library']!)
const kinds = page.getByRole('radiogroup', { name: AR.Kind })
for (const word of [AR.All!, AR.Images!, AR.Videos!, AR.Documents!]) await expect(kinds.getByRole('radio', { name: word })).toBeVisible()
await expect(page.getByRole('group', { name: AR.Today })).toBeVisible()
await expect(page.getByRole('group', { name: AR.Yesterday })).toContainText('Monday summary.md')
// An item's name is its own, set apart inside the Arabic around it.
await expect(page.getByRole('button', { name: /^افتح .*Lighthouse at dawn\.png/ })).toBeVisible()
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 844 })
await expect(heading).toBeVisible()
const box = (await heading.boundingBox())!
const bar = (await kinds.boundingBox())!
// The heading and the kinds start at the right of the column they share.
expect(box.x + box.width).toBeGreaterThan(width / 2)
expect(Math.abs(box.x + box.width - (bar.x + bar.width))).toBeLessThanOrEqual(2)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width)
await shot(page, `translated-ar-library-${width}.png`)
}
})
+50
View File
@@ -0,0 +1,50 @@
/**
* The builder's own app in a language, for translated.spec.ts: the same
* router, identity and theme as src/app/main.tsx, inside @hanzo/i18n's
* provider as a host mounts it.
*
* `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, Toaster, YStack } from '@hanzo/ui'
import { createRoot } from 'react-dom/client'
import { createBrowserRouter } from 'react-router'
import { RouterProvider } from 'react-router/dom'
import { Callback } from '../app/callback.tsx'
import { Identity, Mount } from '../app/root.tsx'
import type { Lang } from './language.tsx'
declare global {
interface Window {
lang: Lang
}
}
;(globalThis as { __HANZO_TELEMETRY__?: { enabled?: boolean } }).__HANZO_TELEMETRY__ = { enabled: false }
const router = createBrowserRouter([
{
element: <Identity />,
children: [
{ path: '/auth/callback', element: <Callback /> },
{ path: '*', element: <Mount /> },
],
},
])
createRoot(document.getElementById('root')!).render(
<I18nProvider userId="acme/dave" initialLocale={window.lang.locale} loadCatalog={async () => window.lang.catalog}>
<Hanzo theme="dark">
<YStack flex={1} minW={0} bg="$background" height="100dvh" overflow="hidden">
<RouterProvider router={router} />
</YStack>
<Toaster />
</Hanzo>
</I18nProvider>,
)
+60 -55
View File
@@ -6,9 +6,13 @@
* the review. While a setup run on the codebase is still working the scripts are
* its to find, so they cannot be edited. A member reads; an org admin saves and
* sets secrets, which is the platform's rule and not a choice made here.
*
* Its words are the reader's language (@hanzo/i18n `t`); a codebase's name, a
* secret's, the scripts and what the setup agent wrote are drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { Plus, X } from '@hanzogui/lucide-icons-2'
import { useI18n } from '@hanzo/i18n/react'
import { Button, Dialog, DialogContent, DialogTitle, Input, Textarea } from '@hanzo/ui'
import { useEffect, useState } from 'react'
@@ -22,11 +26,12 @@ import { useHost, useTarget } from './host.tsx'
const mono = { fontFamily: 'var(--f-mono, ui-monospace, monospace)' }
export function Environment({ repo, busy }: { repo: string; busy: boolean }) {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const signed = Boolean(host.person)
// Read again when a setup run on it stops, which is when its proposal lands.
const env = useRead(signed && repo ? () => read(t, repo) : null, null as Env | null, [t, repo, signed, busy])
const env = useRead(signed && repo ? () => read(target, repo) : null, null as Env | null, [target, repo, signed, busy])
const [install, setInstall] = useState('')
const [startCmd, setStart] = useState('')
const [adding, setAdding] = useState(false)
@@ -43,21 +48,21 @@ export function Environment({ repo, busy }: { repo: string; busy: boolean }) {
}, [shown])
if (!signed) {
return <Soft>Sign in to see this codebase’s environment.</Soft>
return <Soft>{t('Sign in to see this codebase’s environment.')}</Soft>
}
if (!repo) {
return <Soft>This run has no codebase, so it has no environment.</Soft>
return <Soft>{t('This run has no codebase, so it has no environment.')}</Soft>
}
if (env.error) {
return <Soft>{`${repo}’s environment could not be read right now.`}</Soft>
return <Soft>{t('{repo}’s environment could not be read right now.', { repo })}</Soft>
}
if (!shown) {
return <Soft>Reading the environment…</Soft>
return <Soft>{t('Reading the environment…')}</Soft>
}
const changed = install !== shown.install || startCmd !== shown.start || shown.proposal !== null
const needed = (shown.proposal?.secrets ?? []).filter((n) => !shown.secrets.includes(n))
const status = busy ? 'Setting up' : shown.state === 'proposed' ? 'Proposed' : shown.state === 'ready' ? 'Ready' : 'Not set up'
const status = busy ? t('Setting up') : shown.state === 'proposed' ? t('Proposed') : shown.state === 'ready' ? t('Ready') : t('Not set up')
/** One write, then the record read again. Answers whether it took. */
const act = async (what: () => Promise<Env>, done = ''): Promise<boolean> => {
@@ -69,7 +74,7 @@ export function Environment({ repo, busy }: { repo: string; busy: boolean }) {
setNote(done)
return true
} catch (e) {
setNote(plain(e, repo, 'save'))
setNote(plain(e, repo, 'save', t))
return false
} finally {
setWorking(false)
@@ -80,22 +85,22 @@ export function Environment({ repo, busy }: { repo: string; busy: boolean }) {
setWorking(true)
setNote('')
try {
const run = await start(t, { prompt: SETUP, repo, mode: 'setup' })
const run = await start(target, { prompt: SETUP, repo, mode: 'setup' })
host.go(run.session)
} catch (e) {
setNote(plain(e, repo, 'start'))
setNote(plain(e, repo, 'start', t))
setWorking(false)
}
}
const add = () => {
const n = name.trim()
const why = refuse(n) || (value ? '' : 'A secret needs a value')
const why = refuse(n, t) || (value ? '' : t('A secret needs a value'))
if (why) {
setNote(why)
return
}
void act(() => setSecret(t, repo, n, value), `${n} is set`).then((took) => {
void act(() => setSecret(target, repo, n, value), t('{name} is set', { name: n })).then((took) => {
if (!took) return
setAdding(false)
setName('')
@@ -116,8 +121,8 @@ export function Environment({ repo, busy }: { repo: string; busy: boolean }) {
</SizableText>
</XStack>
{host.admin ? (
<Button size="sm" variant="outline" disabled={busy || working || !changed} onPress={() => void act(() => save(t, repo, { install, start: startCmd }), 'Saved')}>
Save
<Button size="sm" variant="outline" disabled={busy || working || !changed} onPress={() => void act(() => save(target, repo, { install, start: startCmd }), t('Saved'))}>
{t('Save')}
</Button>
) : null}
</XStack>
@@ -125,7 +130,7 @@ export function Environment({ repo, busy }: { repo: string; busy: boolean }) {
{shown.proposal?.note ? (
<YStack gap="$1" px="$3" py="$2" rounded="$3" borderWidth={1} borderColor="$borderColor">
<SizableText size="$1" color="$soft">
What the setup agent found
{t('What the setup agent found')}
</SizableText>
<SizableText size="$2" color="$ink" numberOfLines={8}>
{shown.proposal.note}
@@ -134,29 +139,29 @@ export function Environment({ repo, busy }: { repo: string; busy: boolean }) {
) : null}
<Script
label="Install Script"
label={t('Install Script')}
value={install}
onChange={setInstall}
locked={busy || !host.admin}
hint={busy ? 'Install script editing will be available when the agent stops running' : 'pnpm install --frozen-lockfile'}
hint={busy ? t('Install script editing will be available when the agent stops running') : 'pnpm install --frozen-lockfile'}
/>
<Script
label="Start Script"
label={t('Start Script')}
value={startCmd}
onChange={setStart}
locked={busy || !host.admin}
hint={busy ? 'Start script editing will be available when the agent stops running' : 'pnpm dev'}
hint={busy ? t('Start script editing will be available when the agent stops running') : 'pnpm dev'}
/>
<YStack gap="$2">
<XStack items="center" gap="$2">
<SizableText size="$2" color="$ink" flex={1}>
Secrets
{t('Secrets')}
</SizableText>
{host.admin ? (
<Button size="sm" variant="outline" disabled={working} onPress={() => setAdding(true)}>
<Plus size={14} />
New Secret
{t('New Secret')}
</Button>
) : null}
</XStack>
@@ -164,7 +169,7 @@ export function Environment({ repo, busy }: { repo: string; busy: boolean }) {
{shown.secrets.length === 0 && needed.length === 0 && !adding ? (
<YStack py="$5" items="center">
<SizableText size="$2" color="$soft">
No secrets yet
{t('No secrets yet')}
</SizableText>
</YStack>
) : null}
@@ -174,10 +179,10 @@ export function Environment({ repo, busy }: { repo: string; busy: boolean }) {
{s}
</SizableText>
<SizableText size="$1" color="$soft">
set
{t('set')}
</SizableText>
{host.admin ? (
<XStack render="button" aria-label={`Remove ${s}`} px="$1" onPress={() => void act(() => removeSecret(t, repo, s), `${s} is removed`)}>
<XStack render="button" aria-label={t('Remove {name}', { name: s })} px="$1" onPress={() => void act(() => removeSecret(target, repo, s), t('{name} is removed', { name: s }))}>
<X size={14} />
</XStack>
) : null}
@@ -189,10 +194,10 @@ export function Environment({ repo, busy }: { repo: string; busy: boolean }) {
{s}
</SizableText>
<SizableText size="$1" color="$soft">
needed, not set
{t('needed, not set')}
</SizableText>
{host.admin ? (
<XStack render="button" aria-label={`Set ${s}`} px="$1" onPress={() => { setAdding(true); setName(s) }}>
<XStack render="button" aria-label={t('Set {name}', { name: s })} px="$1" onPress={() => { setAdding(true); setName(s) }}>
<Plus size={14} />
</XStack>
) : null}
@@ -200,37 +205,37 @@ export function Environment({ repo, busy }: { repo: string; busy: boolean }) {
))}
{adding ? (
<YStack gap="$2" px="$3" py="$3" borderTopWidth={shown.secrets.length || needed.length ? 1 : 0} borderColor="$borderColor">
<Input value={name} onChangeText={setName} placeholder="NAME" aria-label="Secret name" autoCapitalize="characters" />
<Input value={value} onChangeText={setValue} placeholder="Value" aria-label="Secret value" secureTextEntry />
<Input value={name} onChangeText={setName} placeholder={t('NAME')} aria-label={t('Secret name')} autoCapitalize="characters" />
<Input value={value} onChangeText={setValue} placeholder={t('Value')} aria-label={t('Secret value')} secureTextEntry />
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="ghost" onPress={() => { setAdding(false); setName(''); setValue('') }}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" disabled={working} onPress={add}>
Add secret
{t('Add secret')}
</Button>
</XStack>
</YStack>
) : null}
</YStack>
<SizableText size="$1" color="$soft">
Values are sealed in KMS and exported to every run on this codebase. They are never shown again.
{t('Values are sealed in KMS and exported to every run on this codebase. They are never shown again.')}
</SizableText>
</YStack>
{shown.state === 'none' && !busy ? (
<YStack gap="$2" items="flex-start">
<Button size="sm" disabled={working} onPress={() => void setup()}>
Set up with an agent
{t('Set up with an agent')}
</Button>
<SizableText size="$1" color="$soft">
An agent explores the codebase, installs and checks it, and proposes the scripts for you to review and save.
{t('An agent explores the codebase, installs and checks it, and proposes the scripts for you to review and save.')}
</SizableText>
</YStack>
) : null}
{!host.admin ? (
<SizableText size="$1" color="$soft">
An org admin saves the environment and sets its secrets.
{t('An org admin saves the environment and sets its secrets.')}
</SizableText>
) : null}
{note ? (
@@ -267,8 +272,9 @@ export function SetupDialog({
onSaved: (repo: string) => void
onRepo: (home: Home) => void
}) {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const [chosen, setChosen] = useState(repo)
const [q, setQ] = useState('')
const [working, setWorking] = useState<'' | 'save' | 'start'>('')
@@ -298,14 +304,14 @@ export function SetupDialog({
setNote('')
try {
if (what === 'save') {
await save(t, repo, { install: '', start: '' })
await save(target, repo, { install: '', start: '' })
onSaved(repo)
} else {
await onStart(repo)
onOpenChange(false)
}
} catch (e) {
setNote(plain(e, repo, what))
setNote(plain(e, repo, what, t))
} finally {
setWorking('')
}
@@ -315,44 +321,44 @@ export function SetupDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent maxW={480} showCloseButton={false}>
<XStack items="center" justify="space-between" gap="$2">
<DialogTitle>Set up an environment</DialogTitle>
<XStack render="button" aria-label="Close" p="$1" onPress={() => onOpenChange(false)}>
<DialogTitle>{t('Set up an environment')}</DialogTitle>
<XStack render="button" aria-label={t('Close')} p="$1" onPress={() => onOpenChange(false)}>
<X size={16} />
</XStack>
</XStack>
<YStack gap="$3">
<YStack gap="$1.5">
<SizableText size="$2" color="$soft">
Repository
{t('Repository')}
</SizableText>
<XStack px="$3" py="$2" rounded="$3" borderWidth={1} borderColor={stranger ? '$ink' : '$borderColor'} items="center" gap="$2" aria-label="Chosen repository">
<XStack px="$3" py="$2" rounded="$3" borderWidth={1} borderColor={stranger ? '$ink' : '$borderColor'} items="center" gap="$2" aria-label={t('Chosen repository')}>
<SizableText size="$2" color={home || (chosen && !stranger) ? '$ink' : '$soft'} numberOfLines={1} flex={1} minW={0}>
{home ? home.label : chosen || 'Choose a repository'}
{home ? home.label : chosen || t('Choose a repository')}
</SizableText>
</XStack>
{stranger ? (
<SizableText size="$1" color="$ink" role="alert">
{`${chosen} is not one of this organization’s repositories. Choose one below.`}
{t('{repo} is not one of this organization’s repositories. Choose one below.', { repo: chosen })}
</SizableText>
) : null}
<Input value={q} onChangeText={setQ} placeholder="Find a repository…" aria-label="Find a repository" />
<YStack gap={2} aria-label="Repositories">
<Input value={q} onChangeText={setQ} placeholder={t('Find a repository…')} aria-label={t('Find a repository')} />
<YStack gap={2} aria-label={t('Repositories')}>
{homes.loading && !list.length ? (
<SizableText size="$1" color="$soft">
Reading the repositories…
{t('Reading the repositories…')}
</SizableText>
) : homes.error && !list.length ? (
<XStack items="center" gap="$2" flexWrap="wrap">
<SizableText size="$1" color="$soft">
The repository list could not be read right now.
{t('The repository list could not be read right now.')}
</SizableText>
<Button size="sm" variant="outline" onPress={homes.reload}>
Read again
{t('Read again')}
</Button>
</XStack>
) : shown.length === 0 ? (
<SizableText size="$1" color="$soft">
{list.length ? 'Nothing matches.' : 'This organization has no repositories on the forge yet.'}
{list.length ? t('Nothing matches.') : t('This organization has no repositories on the forge yet.')}
</SizableText>
) : (
shown.map((h) => {
@@ -362,7 +368,7 @@ export function SetupDialog({
key={h.name}
render="button"
aria-pressed={on}
aria-label={`Choose ${h.label}`}
aria-label={t('Choose {repo}', { repo: h.label })}
onPress={() => {
setChosen(h.name)
setNote('')
@@ -384,8 +390,7 @@ export function SetupDialog({
</YStack>
</YStack>
<SizableText size="$2" color="$soft">
An agent onboards the codebase: it explores it, writes the install and start scripts, and names the secrets
it needs. It takes several minutes. Interrupt it anytime, or take over in the terminal.
{t('An agent onboards the codebase: it explores it, writes the install and start scripts, and names the secrets it needs. It takes several minutes. Interrupt it anytime, or take over in the terminal.')}
</SizableText>
{note ? (
<XStack items="center" gap="$2" flexWrap="wrap">
@@ -394,7 +399,7 @@ export function SetupDialog({
</SizableText>
{last ? (
<Button size="sm" variant="outline" disabled={working !== ''} onPress={() => void run(last)}>
Try again
{t('Try again')}
</Button>
) : null}
</XStack>
@@ -402,13 +407,13 @@ export function SetupDialog({
<XStack gap="$2" justify="space-between" items="center">
{host.admin ? (
<Button size="sm" variant="secondary" disabled={working !== '' || !ready} onPress={() => void run('save')}>
{working === 'save' ? 'Saving…' : 'Skip & save'}
{working === 'save' ? t('Saving…') : t('Skip & save')}
</Button>
) : (
<YStack />
)}
<Button size="sm" variant="primary" disabled={working !== '' || !ready} onPress={() => void run('start')}>
{working === 'start' ? 'Starting the agent…' : 'Start agent'}
{working === 'start' ? t('Starting the agent…') : t('Start agent')}
</Button>
</XStack>
</YStack>
+50 -41
View File
@@ -5,8 +5,12 @@
* or changed and the whole change as a patch, kept after its sandbox is gone; its
* previews, pull request and deployments — then its published site, its branch,
* its environment proposal and its project.
*
* Its words are the reader's language (@hanzo/i18n `t`); names, paths and a
* file's contents are drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { useI18n, type Say } from '@hanzo/i18n/react'
import { Download, ExternalLink, File, FileDiff, Folder, Globe, GitBranch, GitPullRequest, Layers, MonitorPlay, Rocket, Settings2 } from '@hanzogui/lucide-icons-2'
import { useState, type ReactNode } from 'react'
@@ -35,6 +39,7 @@ export function Files({
sandbox: string
live: boolean
}) {
const { t } = useI18n()
const reachable = live && Boolean(sandbox)
// Live once the sandbox is reachable, unless the branch was picked.
const [source, setSource] = useState<Source | null>(null)
@@ -45,10 +50,10 @@ export function Files({
{reachable ? (
<XStack gap="$1" items="center" flexWrap="wrap">
<Pick on={from === 'live'} onPress={() => setSource('live')}>
Live
{t('Live')}
</Pick>
<Pick on={from === 'branch'} onPress={() => setSource('branch')}>
Branch
{t('Branch')}
</Pick>
</XStack>
) : null}
@@ -59,18 +64,19 @@ export function Files({
/** The sandbox's working tree, as it is right now. */
function Live({ sandbox }: { sandbox: string }) {
const t = useTarget()
const target = useTarget()
const { t } = useI18n()
const [dir, setDir] = useState('')
const [file, setFile] = useState('')
const list = useRead(() => look(t, sandbox, dir), null as Node | null, [t, sandbox, dir])
const body = useRead(file ? () => look(t, sandbox, file) : null, null as Node | null, [t, sandbox, file])
const list = useRead(() => look(target, sandbox, dir), null as Node | null, [target, sandbox, dir])
const body = useRead(file ? () => look(target, sandbox, file) : null, null as Node | null, [target, sandbox, file])
const at = (name: string) => (dir ? `${dir}/${name}` : name)
// A name is only a name until it is read: the answer says whether it was a directory.
const open = async (name: string) => {
const path = at(name)
try {
const n = await look(t, sandbox, path)
const n = await look(target, sandbox, path)
if (n.dir) {
setDir(path)
setFile('')
@@ -95,10 +101,10 @@ function Live({ sandbox }: { sandbox: string }) {
? body.error.message
: body.value
? body.value.binary
? 'Binary file'
? t('Binary file')
: body.value.truncated
? 'Too large to show'
: body.value.text || 'Empty file'
? t('Too large to show')
: body.value.text || t('Empty file')
: ''
}
/>
@@ -108,14 +114,15 @@ function Live({ sandbox }: { sandbox: string }) {
/** The run's branch on the forge; its base until it has pushed. */
function Branch({ session, label }: { session: string; label: string }) {
const host = useHost()
const t = useTarget()
const target = useTarget()
const { t } = useI18n()
const signed = Boolean(host.person)
const [dir, setDir] = useState('')
const [file, setFile] = useState('')
const list = useRead(signed ? () => tree(t, session, dir) : null, [] as Entry[], [t, session, dir, signed])
const body = useRead(file ? () => blob(t, session, file) : null, null as Blob | null, [t, session, file])
const list = useRead(signed ? () => tree(target, session, dir) : null, [] as Entry[], [target, session, dir, signed])
const body = useRead(file ? () => blob(target, session, file) : null, null as Blob | null, [target, session, file])
if (!signed) return <Soft>Sign in to read this run’s files.</Soft>
if (!signed) return <Soft>{t('Sign in to read this run’s files.')}</Soft>
return (
<Listing
where={`${label}${dir ? `/${dir}` : ''}`}
@@ -134,10 +141,10 @@ function Branch({ session, label }: { session: string; label: string }) {
? body.error.message
: body.value
? body.value.binary
? 'Binary file'
? t('Binary file')
: body.value.truncated
? 'Too large to show'
: body.value.text || 'Empty file'
? t('Too large to show')
: body.value.text || t('Empty file')
: ''
}
/>
@@ -161,13 +168,14 @@ function Listing({
file: string
body: string
}) {
const { t } = useI18n()
return (
<YStack gap="$2">
<XStack gap="$2" items="center">
{up ? (
<XStack render="button" aria-label="Up one directory" onPress={up} px="$1" py="$1">
<XStack render="button" aria-label={t('Up one directory')} onPress={up} px="$1" py="$1">
<SizableText size="$1" color="$soft">
Up
{t('Up')}
</SizableText>
</XStack>
) : null}
@@ -181,18 +189,18 @@ function Listing({
</SizableText>
) : loading ? (
<SizableText size="$2" color="$soft">
Reading…
{t('Reading…')}
</SizableText>
) : rows.length === 0 ? (
<SizableText size="$2" color="$soft">
This directory is empty.
{t('This directory is empty.')}
</SizableText>
) : (
rows.map((r) => (
<XStack
key={r.key}
render="button"
aria-label={r.dir === false ? `Read ${r.name}` : `Open ${r.name}`}
aria-label={r.dir === false ? t('Read {name}', { name: r.name }) : t('Open {name}', { name: r.name })}
onPress={r.onPress}
items="center"
gap="$2"
@@ -200,7 +208,7 @@ function Listing({
hoverStyle={{ bg: '$hover' }}
>
{r.dir === false ? <File size={14} /> : r.dir ? <Folder size={14} /> : <File size={14} opacity={0.6} />}
<SizableText size="$2" color="$ink" numberOfLines={1} style={{ textAlign: 'left' }}>
<SizableText size="$2" color="$ink" numberOfLines={1} text="start">
{r.name}
</SizableText>
</XStack>
@@ -223,13 +231,13 @@ function Listing({
/** A stored artifact's size, as a person reads it. */
const size = (n: number): string => (n < 1024 ? `${n} B` : n < 1 << 20 ? `${(n / 1024).toFixed(1)} KB` : `${(n / (1 << 20)).toFixed(1)} MB`)
/** What each kind of artifact is drawn with, and says it is. */
const KIND: Record<Artifact['kind'], { icon: ReactNode; note: (a: Artifact) => string }> = {
file: { icon: <File size={16} />, note: (a) => `Changed file · ${size(a.size)}` },
patch: { icon: <FileDiff size={16} />, note: (a) => `The whole change as a patch · ${size(a.size)}` },
preview: { icon: <MonitorPlay size={16} />, note: (a) => `What the run served on port ${a.port}` },
pull: { icon: <GitPullRequest size={16} />, note: () => 'Pull request' },
deploy: { icon: <Rocket size={16} />, note: () => 'Deployment' },
/** What each kind of artifact is drawn with, and says it is, in the reader's language. */
const KIND: Record<Artifact['kind'], { icon: ReactNode; note: (a: Artifact, t: Say) => string }> = {
file: { icon: <File size={16} />, note: (a, t) => t('Changed file · {size}', { size: size(a.size) }) },
patch: { icon: <FileDiff size={16} />, note: (a, t) => t('The whole change as a patch · {size}', { size: size(a.size) }) },
preview: { icon: <MonitorPlay size={16} />, note: (a, t) => t('What the run served on port {port}', { port: a.port }) },
pull: { icon: <GitPullRequest size={16} />, note: (_, t) => t('Pull request') },
deploy: { icon: <Rocket size={16} />, note: (_, t) => t('Deployment') },
}
export function Artifacts({
@@ -256,14 +264,15 @@ export function Artifacts({
onEnvironment: () => void
}) {
const host = useHost()
const t = useTarget()
const left = useRead(host.person ? () => artifacts(t, session) : null, { saved: '', artifacts: [] }, [t, session, host.person])
const target = useTarget()
const { t } = useI18n()
const left = useRead(host.person ? () => artifacts(target, session) : null, { saved: '', artifacts: [] }, [target, session, host.person])
const [note, setNote] = useState('')
// Bytes come with the person's bearer, so a stored artifact is fetched, then saved.
const save = async (a: Artifact) => {
try {
const url = URL.createObjectURL(await artifact(t, session, a.name))
const url = URL.createObjectURL(await artifact(target, session, a.name))
const link = document.createElement('a')
link.href = url
link.download = a.name.split('/').pop() || a.name
@@ -274,7 +283,7 @@ export function Artifacts({
}
}
// A preview is opened with a fresh ticket, in a tab of its own.
const open = (a: Artifact) => away(preview(t, sandbox, a.port)).catch((e: Error) => setNote(e.message))
const open = (a: Artifact) => away(preview(target, sandbox, a.port)).catch((e: Error) => setNote(e.message))
const items: { key: string; icon: ReactNode; title: string; note: string; href?: string; onPress?: () => void; action?: ReactNode }[] = []
for (const a of left.value.artifacts) {
@@ -285,19 +294,19 @@ export function Artifacts({
key: `${a.kind}:${a.name}`,
icon: KIND[a.kind].icon,
title: link ? a.url.replace(/^https:\/\//, '') : a.name,
note: KIND[a.kind].note(a),
note: KIND[a.kind].note(a, t),
href: link ? a.url : undefined,
onPress: stored ? () => void save(a) : a.kind === 'preview' ? () => void open(a) : undefined,
action: stored ? <Download size={14} /> : a.kind === 'preview' ? <ExternalLink size={14} /> : undefined,
})
}
if (site) items.push({ key: 'site', icon: <Globe size={16} />, title: site.replace(/^https:\/\//, ''), note: 'Where this run is published', href: site })
if (pr.href) items.push({ key: 'pr', icon: <GitPullRequest size={16} />, title: `Pull request ${pr.label}`, note: 'Opened on the forge', href: pr.href })
if (branch) items.push({ key: 'branch', icon: <GitBranch size={16} />, title: branch, note: repo ? `The branch this run pushes to in ${repo}` : 'The branch this run pushes to' })
if (mode === 'setup') items.push({ key: 'environment', icon: <Settings2 size={16} />, title: 'Environment proposal', note: 'The install and start scripts this run found', onPress: onEnvironment })
if (project) items.push({ key: 'project', icon: <Layers size={16} />, title: project, note: 'The project this run builds', onPress: () => host.go(project) })
if (site) items.push({ key: 'site', icon: <Globe size={16} />, title: site.replace(/^https:\/\//, ''), note: t('Where this run is published'), href: site })
if (pr.href) items.push({ key: 'pr', icon: <GitPullRequest size={16} />, title: t('Pull request {pr}', { pr: pr.label }), note: t('Opened on the forge'), href: pr.href })
if (branch) items.push({ key: 'branch', icon: <GitBranch size={16} />, title: branch, note: repo ? t('The branch this run pushes to in {repo}', { repo }) : t('The branch this run pushes to') })
if (mode === 'setup') items.push({ key: 'environment', icon: <Settings2 size={16} />, title: t('Environment proposal'), note: t('The install and start scripts this run found'), onPress: onEnvironment })
if (project) items.push({ key: 'project', icon: <Layers size={16} />, title: project, note: t('The project this run builds'), onPress: () => host.go(project) })
if (!items.length) return <Soft>{left.error?.message || (left.loading ? 'Reading what this run left…' : 'This run has produced no artifacts yet.')}</Soft>
if (!items.length) return <Soft>{left.error?.message || (left.loading ? t('Reading what this run left…') : t('This run has produced no artifacts yet.'))}</Soft>
return (
<YStack gap="$2">
{note ? (
@@ -310,7 +319,7 @@ export function Artifacts({
const row = (
<XStack items="center" gap="$3" px="$3" py="$2.5" borderTopWidth={i ? 1 : 0} borderColor="$borderColor" hoverStyle={a.href || a.onPress ? { bg: '$hover' } : undefined}>
{a.icon}
<YStack flex={1} minW={0} style={{ textAlign: 'left' }}>
<YStack flex={1} minW={0} $platform-web={{ textAlign: 'start' }}>
<SizableText size="$2" color="$ink" numberOfLines={1}>
{a.title}
</SizableText>
+19 -16
View File
@@ -5,6 +5,8 @@
* the first page lands it searches the rail's own recents.
*/
import { ScrollView, SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { Button, Dialog, DialogContent, DialogTitle, Input } from '@hanzo/ui'
import type { RailSession } from '@hanzo/ui/chat'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -14,11 +16,11 @@ import { useHost, useTarget } from './host.tsx'
/** The statuses the platform filters on, and every run. */
const FILTERS = [
{ id: '', label: 'All' },
{ id: 'running', label: 'Running' },
{ id: 'paused', label: 'Paused' },
{ id: 'done', label: 'Done' },
{ id: 'error', label: 'Error' },
{ id: '', label: phrase('All') },
{ id: 'running', label: phrase('Running') },
{ id: 'paused', label: phrase('Paused') },
{ id: 'done', label: phrase('Done') },
{ id: 'error', label: phrase('Error') },
] as const
const PAGE = 50
@@ -40,6 +42,7 @@ export function Find({
recents: RailSession[]
onOpen: (id: string) => void
}) {
const { t: say } = useI18n()
const host = useHost()
const t = useTarget()
const signed = Boolean(host.person)
@@ -83,33 +86,33 @@ export function Find({
const hits = useMemo((): Hit[] => {
const n = q.trim().toLowerCase()
const all: Hit[] = rows
? rows.map((s) => ({ id: s.id, title: s.title || 'Untitled run', line: [s.repo, s.status].filter(Boolean).join(' · ') }))
? rows.map((s) => ({ id: s.id, title: s.title || say('Untitled run'), line: [s.repo, s.status].filter(Boolean).join(' · ') }))
: status
? []
: recents.map((r) => ({ id: r.id, title: r.title, line: '' }))
return n ? all.filter((h) => `${h.title} ${h.line}`.toLowerCase().includes(n)) : all
}, [q, rows, recents, status])
}, [q, rows, recents, status, say])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent maxW={520}>
<DialogTitle>Find a run</DialogTitle>
<DialogTitle>{say('Find a run')}</DialogTitle>
<Input
autoFocus
value={q}
onChangeText={setQ}
placeholder="Search runs…"
aria-label="Search runs"
placeholder={say('Search runs…')}
aria-label={say('Search runs')}
onKeyDown={(e: { key?: string }) => {
if (e.key === 'Enter' && hits[0]) onOpen(hits[0].id)
}}
/>
<XStack gap="$1" flexWrap="wrap" role="group" aria-label="Status">
<XStack gap="$1" flexWrap="wrap" role="group" aria-label={say('Status')}>
{FILTERS.map((f) => (
<XStack
key={f.id || 'all'}
render="button"
aria-label={f.label}
aria-label={say(f.label)}
aria-pressed={status === f.id}
onPress={() => setStatus(f.id)}
px="$2.5"
@@ -121,7 +124,7 @@ export function Find({
hoverStyle={{ bg: '$hover' }}
>
<SizableText size="$1" color={status === f.id ? '$ink' : '$soft'}>
{f.label}
{say(f.label)}
</SizableText>
</XStack>
))}
@@ -154,7 +157,7 @@ export function Find({
))}
{!loading && !error && hits.length === 0 ? (
<SizableText size="$2" color="$soft" px="$2">
{rows && next ? 'No run read so far matches.' : 'No run matches.'}
{rows && next ? say('No run read so far matches.') : say('No run matches.')}
</SizableText>
) : null}
</YStack>
@@ -166,12 +169,12 @@ export function Find({
) : null}
{loading ? (
<SizableText size="$1" color="$soft" role="status">
Reading runs…
{say('Reading runs…')}
</SizableText>
) : next ? (
<XStack>
<Button size="sm" variant="outline" onPress={() => void read(next)}>
Older runs
{say('Older runs')}
</Button>
</XStack>
) : null}
+16 -13
View File
@@ -8,6 +8,7 @@
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { Popover, SizableText, XStack, YStack } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import { CreditCard, Gauge, LifeBuoy, LogOut, Settings, Slack as Mark, Sparkles, X } from '@hanzogui/lucide-icons-2'
import { MenuLabel, MenuRow, MenuRule } from '@hanzo/ui/product'
@@ -20,21 +21,22 @@ export const DOCS = 'https://docs.hanzo.ai/docs/dev'
/** Hanzo in Slack is set up under Settings; the card goes there, and once dismissed stays gone. */
export function Slack({ onOpen, onDismiss }: { onOpen: () => void; onDismiss: () => void }) {
const { t } = useI18n()
return (
// A card on the rail's card: glass, a corner a step inside the rail's.
<XStack items="center" gap="$2.5" px="$3" py="$2.5" rounded="var(--radius-lg)" borderWidth={1} borderColor="$borderColor" bg="var(--glass)">
<Mark size={16} />
<YStack flex={1} minW={0} gap="$0.5">
<SizableText size="$2" color="$ink" numberOfLines={1}>
Try Hanzo in Slack
{t('Try Hanzo in Slack')}
</SizableText>
<XStack render="button" aria-label="Set up Hanzo in Slack" onPress={onOpen} cursor="pointer">
<XStack render="button" aria-label={t('Set up Hanzo in Slack')} onPress={onOpen} cursor="pointer">
<SizableText size="$1" color="$soft" textDecorationLine="underline">
Set up
{t('Set up')}
</SizableText>
</XStack>
</YStack>
<XStack render="button" aria-label="Dismiss" onPress={onDismiss} p="$1" rounded="$2" hoverStyle={{ bg: '$hover' }}>
<XStack render="button" aria-label={t('Dismiss')} onPress={onDismiss} p="$1" rounded="$2" hoverStyle={{ bg: '$hover' }}>
<X size={14} />
</XStack>
</XStack>
@@ -76,6 +78,7 @@ export function useWho(): { open: boolean; onOpenChange: (o: boolean) => void; t
* open rail, beside it on the collapsed one.
*/
export function Who({ open, onOpenChange, narrow }: { open: boolean; onOpenChange: (o: boolean) => void; narrow: boolean }) {
const { t } = useI18n()
const host = useHost()
const standing = useStanding(useTarget(), open)
// The organizations this person acts in; choosing one reloads the page scoped to it.
@@ -89,7 +92,7 @@ export function Who({ open, onOpenChange, narrow }: { open: boolean; onOpenChang
<Popover.Anchor position="absolute" l={8} b={8} width={narrow ? 48 : 256} height={36} pointerEvents="none" />
<Popover.Content
role="menu"
aria-label="Account"
aria-label={t('Account')}
items="stretch"
borderWidth={1}
elevation="$2"
@@ -112,8 +115,8 @@ export function Who({ open, onOpenChange, narrow }: { open: boolean; onOpenChang
{orgs.length ? (
<>
<MenuRule />
<MenuLabel>Organization</MenuLabel>
<YStack role="radiogroup" aria-label="Organizations" gap="$1">
<MenuLabel>{t('Organization')}</MenuLabel>
<YStack role="radiogroup" aria-label={t('Organizations')} gap="$1">
{orgs.map((o) => (
<MenuRow
key={o}
@@ -129,12 +132,12 @@ export function Who({ open, onOpenChange, narrow }: { open: boolean; onOpenChang
</>
) : null}
<MenuRule />
<MenuRow label="Settings" icon={<Settings size={16} />} onPress={() => go(path({ kind: 'settings', section: 'general' }))} />
<MenuRow label="Usage" icon={<Gauge size={16} />} onPress={() => go(path({ kind: 'settings', section: 'usage' }))} />
<MenuRow label="Billing" icon={<CreditCard size={16} />} onPress={() => go(path({ kind: 'settings', section: 'billing' }))} />
<MenuRow label="View all plans" icon={<Sparkles size={16} />} onPress={() => go(path({ kind: 'screen', screen: 'plans' }))} />
<MenuRow label={t('Settings')} icon={<Settings size={16} />} onPress={() => go(path({ kind: 'settings', section: 'general' }))} />
<MenuRow label={t('Usage')} icon={<Gauge size={16} />} onPress={() => go(path({ kind: 'settings', section: 'usage' }))} />
<MenuRow label={t('Billing')} icon={<CreditCard size={16} />} onPress={() => go(path({ kind: 'settings', section: 'billing' }))} />
<MenuRow label={t('View all plans')} icon={<Sparkles size={16} />} onPress={() => go(path({ kind: 'screen', screen: 'plans' }))} />
<MenuRow
label="Get help"
label={t('Get help')}
icon={<LifeBuoy size={16} />}
onPress={() => {
onOpenChange(false)
@@ -145,7 +148,7 @@ export function Who({ open, onOpenChange, narrow }: { open: boolean; onOpenChang
<>
<MenuRule />
<MenuRow
label="Log out"
label={t('Log out')}
icon={<LogOut size={16} />}
onPress={() => {
onOpenChange(false)
+131 -106
View File
@@ -8,8 +8,14 @@
* Choosing an issue opens New on the issue's own repository — its GitHub
* address, or the forge repository it is filed on, never a board's key — with
* the issue as the ask; an issue no project answers to asks which one.
*
* The page's words are the reader's language (@hanzo/i18n `t`, the target is
* `target`); a repository's org, name, branch and description, an issue's
* title, labels and people, and the forge's own refusals are drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import {
ArrowRight,
Check,
@@ -54,11 +60,11 @@ function Note({ children }: { children: string }) {
}
const STATUS: Record<string, string> = {
backlog: 'Backlog',
todo: 'To do',
in_progress: 'In progress',
done: 'Done',
canceled: 'Canceled',
backlog: phrase('Backlog'),
todo: phrase('To do'),
in_progress: phrase('In progress'),
done: phrase('Done'),
canceled: phrase('Canceled'),
}
export function Forge({ screen }: { screen: Exclude<Screen, 'artifacts' | 'templates'> }) {
@@ -80,7 +86,8 @@ const PAGE = 25
*/
function Projects() {
const host = useHost()
const t = useTarget()
const target = useTarget()
const { t } = useI18n()
const signed = Boolean(host.person)
const [q, setQ] = useState('')
const [sort, setSort] = useState<'name' | 'updated'>('updated')
@@ -89,7 +96,7 @@ function Projects() {
useEffect(() => {
setPage(0)
}, [q, sort])
const list = useRead(signed ? () => codebases(t) : null, [] as Codebase[], [t, signed, landed])
const list = useRead(signed ? () => codebases(target) : null, [] as Codebase[], [target, signed, landed])
const needle = q.trim().toLowerCase()
const pending = readPending(host.org)
const pendingKey = pending.map((p) => p.fullName).join('\n')
@@ -100,7 +107,7 @@ function Projects() {
const waiting = readPending(host.org)
if (!signed || waiting.length === 0) return
let stop = false
grants(t)
grants(target)
.then((g) => {
if (stop) return
const done = new Set(g.repos.filter((r) => r.imported).map((r) => r.fullName))
@@ -114,7 +121,7 @@ function Projects() {
return () => {
stop = true
}
}, [signed, t, host.org, pendingKey])
}, [signed, target, host.org, pendingKey])
const shown = [
...list.value,
@@ -159,20 +166,22 @@ function Projects() {
<XStack justify="space-between" items="flex-start" gap="$4" flexWrap="wrap">
<YStack gap="$1" flex={1} minW={240}>
<SizableText render="h1" size="$6" fontWeight="500" color="$ink">
Projects
{t('Projects')}
</SizableText>
<SizableText size="$2" color="$soft">
{`${host.org ? `${host.org}’s` : 'This organization’s'} repositories on the forge. Open one to work on it.`}
{host.org
? t('{org}’s repositories on the forge. Open one to work on it.', { org: host.org })
: t('This organization’s repositories on the forge. Open one to work on it.')}
</SizableText>
</YStack>
<XStack gap="$2" items="center" flexWrap="wrap">
<Button size="sm" variant="outline" onPress={template}>
<LayoutTemplate size={14} />
Create from template
{t('Create from template')}
</Button>
<Button size="sm" variant="primary" onPress={link}>
<Github size={14} />
Link a GitHub repo
{t('Link a GitHub repo')}
</Button>
</XStack>
</XStack>
@@ -180,23 +189,23 @@ function Projects() {
{list.error ? (
<Note>{list.error.message}</Note>
) : !signed ? (
<Note>Sign in to see this organization's projects.</Note>
<Note>{t("Sign in to see this organization's projects.")}</Note>
) : list.loading && list.value.length === 0 && pending.length === 0 ? (
<Note>Reading the forge…</Note>
<Note>{t('Reading the forge…')}</Note>
) : none ? (
<YStack gap="$3" py="$2">
<Note>No projects yet. A project is a repository: link one from GitHub, or start one from a template.</Note>
<Note>{t('No projects yet. A project is a repository: link one from GitHub, or start one from a template.')}</Note>
<XStack gap="$3" flexWrap="wrap">
<Choice
label="Link a GitHub repo"
label={t('Link a GitHub repo')}
icon={<Github size={20} />}
says="Mirror repositories you already have. They stay in step with GitHub, and their issues can come too."
says={t('Mirror repositories you already have. They stay in step with GitHub, and their issues can come too.')}
onPress={link}
/>
<Choice
label="Create from template"
label={t('Create from template')}
icon={<LayoutTemplate size={20} />}
says="Start from a finished product — a site, an app, a store — and make it yours."
says={t('Start from a finished product — a site, an app, a store — and make it yours.')}
onPress={template}
/>
</XStack>
@@ -206,26 +215,26 @@ function Projects() {
<XStack gap="$2" items="center">
<YStack flex={1} $max-md={{ display: 'none' }} />
<YStack width={320} minW={0} shrink={1} $max-md={{ width: 'auto', flex: 1 }}>
<Input value={q} onChangeText={setQ} placeholder="Find a project…" aria-label="Find a project" />
<Input value={q} onChangeText={setQ} placeholder={t('Find a project…')} aria-label={t('Find a project')} />
</YStack>
</XStack>
{shown.length === 0 ? (
<Note>Nothing matches.</Note>
<Note>{t('Nothing matches.')}</Note>
) : (
<YStack borderWidth={1} borderColor="$borderColor" rounded="$3" overflow="hidden">
<XStack px="$3" py="$2" gap="$3" items="center" bg="$panel">
<XStack flex={1} render="button" aria-label="Sort by name" items="center" gap="$1" onPress={() => setSort('name')}>
<XStack flex={1} render="button" aria-label={t('Sort by name')} items="center" gap="$1" onPress={() => setSort('name')}>
<SizableText size="$1" color="$soft">
Name
{t('Name')}
</SizableText>
{sort === 'name' ? <ChevronDown size={12} /> : null}
</XStack>
<SizableText size="$1" color="$soft" width={120} $max-md={{ display: 'none' }}>
Branch
{t('Branch')}
</SizableText>
<XStack render="button" aria-label="Sort by last updated" items="center" justify="flex-end" gap="$1" width={120} onPress={() => setSort('updated')}>
<XStack render="button" aria-label={t('Sort by last updated')} items="center" justify="flex-end" gap="$1" width={120} onPress={() => setSort('updated')}>
<SizableText size="$1" color="$soft">
Updated
{t('Updated')}
</SizableText>
{sort === 'updated' ? <ChevronDown size={12} /> : null}
</XStack>
@@ -234,9 +243,9 @@ function Projects() {
<XStack
key={`${c.org}/${c.name}`}
render="button"
aria-label={`Open ${c.org ? `${c.org}/` : ''}${c.name}`}
aria-label={t('Open {repo}', { repo: `${c.org ? `${c.org}/` : ''}${c.name}` })}
onPress={() => open(c)}
style={{ textAlign: 'left' }}
$platform-web={{ textAlign: 'start' }}
items="center"
gap="$3"
px="$3"
@@ -248,7 +257,8 @@ function Projects() {
>
<FolderGit2 size={15} color="$soft" />
<YStack flex={1} minW={0}>
<XStack minW={0} items="baseline">
{/* `org/name` is an address: it keeps its own order in a right-to-left page. */}
<XStack minW={0} items="baseline" self="flex-start" {...{ 'data-i18n-ltr': '' }}>
{c.org ? (
<SizableText size="$2" color="$soft" numberOfLines={1} shrink={0}>
{`${c.org}/`}
@@ -270,27 +280,27 @@ function Projects() {
{c.branch}
</SizableText>
</XStack>
<SizableText size="$1" color="$soft" width={120} style={{ textAlign: 'right' }}>
{syncing.has(c.name) ? 'Syncing…' : ago(c.updated)}
<SizableText size="$1" color="$soft" width={120} text="end">
{syncing.has(c.name) ? t('Syncing…') : ago(c.updated)}
</SizableText>
</XStack>
))}
<XStack px="$3" py="$2" justify="space-between" items="center" borderTopWidth={1} borderColor="$borderColor">
<SizableText size="$1" color="$soft">
{`Showing ${from}–${to} of ${shown.length}`}
{t('Showing {from}–{to} of {total}', { from, to, total: shown.length })}
</SizableText>
<XStack gap="$3" items="center">
<XStack render="button" aria-label="Previous page" disabled={safePage === 0} onPress={() => setPage(safePage - 1)}>
<XStack render="button" aria-label={t('Previous page')} disabled={safePage === 0} onPress={() => setPage(safePage - 1)}>
<SizableText size="$1" color={safePage === 0 ? '$soft' : '$ink'}>
Prev
{t('Prev')}
</SizableText>
</XStack>
<SizableText size="$1" color="$soft">
{`${safePage + 1} / ${pages}`}
</SizableText>
<XStack render="button" aria-label="Next page" disabled={safePage >= pages - 1} onPress={() => setPage(safePage + 1)}>
<XStack render="button" aria-label={t('Next page')} disabled={safePage >= pages - 1} onPress={() => setPage(safePage + 1)}>
<SizableText size="$1" color={safePage >= pages - 1 ? '$soft' : '$ink'}>
Next
{t('Next')}
</SizableText>
</XStack>
</XStack>
@@ -328,9 +338,9 @@ function Choice({ label, icon, says, onPress }: { label: string; icon: ReactNode
<SizableText size="$4" color="$ink" fontWeight="500">
{label}
</SizableText>
<ArrowRight size={14} />
<ArrowRight size={14} {...{ 'data-i18n-mirror': '' }} />
</XStack>
<SizableText size="$2" color="$soft" style={{ textAlign: 'left' }}>
<SizableText size="$2" color="$soft" text="start">
{says}
</SizableText>
</YStack>
@@ -344,11 +354,12 @@ function Choice({ label, icon, says, onPress }: { label: string; icon: ReactNode
*/
function GitHubLink({ onSync }: { onSync: () => void }) {
const host = useHost()
const t = useTarget()
const me = useRead(() => connection(t), null as Connection | null, [t])
const target = useTarget()
const { t } = useI18n()
const me = useRead(() => connection(target), null as Connection | null, [target])
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
useBack(t, true, setNote, me.reload)
useBack(target, true, setNote, me.reload)
const c = me.value
// A connection that cannot be read says nothing here: Sync from GitHub above still works.
if (me.error || !c || !c.configured) return note ? <Note>{note}</Note> : null
@@ -357,7 +368,7 @@ function GitHubLink({ onSync }: { onSync: () => void }) {
setBusy(true)
setNote('')
try {
host.open(await connect(t, here()))
host.open(await connect(target, here()))
} catch (e) {
setNote((e as Error).message)
setBusy(false)
@@ -379,21 +390,21 @@ function GitHubLink({ onSync }: { onSync: () => void }) {
<Github size={16} />
<YStack flex={1} minW={200}>
<SizableText size="$2" color="$ink">
{c.connected ? `GitHub connected as @${c.login}` : 'Connect GitHub to bring your repositories here'}
{c.connected ? t('GitHub connected as {login}', { login: `@${c.login}` }) : t('Connect GitHub to bring your repositories here')}
</SizableText>
<SizableText size="$1" color="$soft">
{note || (c.connected ? 'Choose repositories to mirror; each stays in step with GitHub.' : 'One step on GitHub, then pick the repositories to sync.')}
{note || (c.connected ? t('Choose repositories to mirror; each stays in step with GitHub.') : t('One step on GitHub, then pick the repositories to sync.'))}
</SizableText>
</YStack>
{c.connected ? (
<Button size="sm" variant="outline" onPress={onSync}>
<RefreshCw size={14} />
Sync repositories
{t('Sync repositories')}
</Button>
) : (
<Button size="sm" variant="primary" disabled={busy} onPress={() => void link()}>
<Github size={14} />
{busy ? 'Opening GitHub…' : 'Connect GitHub'}
{busy ? t('Opening GitHub…') : t('Connect GitHub')}
</Button>
)}
</XStack>
@@ -405,9 +416,10 @@ type Kind = 'all' | 'issue' | 'pr'
type Origin = 'all' | 'github' | 'hanzo'
type Order = 'newest' | 'oldest' | 'updated'
const KINDS: Record<Kind, string> = { all: 'Issues and pull requests', issue: 'Issues', pr: 'Pull requests' }
const ORIGINS: Record<Origin, string> = { all: 'Every source', github: 'GitHub', hanzo: 'Hanzo' }
const ORDERS: Record<Order, string> = { newest: 'Newest', oldest: 'Oldest', updated: 'Recently updated' }
/** The menus' words, marked (`phrase`) and drawn through `t`; GitHub and Hanzo are names, drawn as given. */
const KINDS: Record<Kind, string> = { all: phrase('Issues and pull requests'), issue: phrase('Issues'), pr: phrase('Pull requests') }
const ORIGINS: Record<Origin, string> = { all: phrase('Every source'), github: 'GitHub', hanzo: 'Hanzo' }
const ORDERS: Record<Order, string> = { newest: phrase('Newest'), oldest: phrase('Oldest'), updated: phrase('Recently updated') }
/** Mirrored from GitHub: the task index carried it from GitHub (`extRef` github:…). */
const fromGitHub = (i: Issue): boolean => i.sources.includes('github')
@@ -522,6 +534,7 @@ function useOpen(list: Project[], work: Issue[]) {
/** Asks which project an issue is for, from the org's own list: never a free string. */
function Which({ issue, list, onPick, onClose }: { issue: Issue | null; list: Project[]; onPick: (p: Project) => void; onClose: () => void }) {
const { t } = useI18n()
const [q, setQ] = useState('')
const shown = pickProjects(
list.filter((p) => p.forge || p.linked),
@@ -531,32 +544,37 @@ function Which({ issue, list, onPick, onClose }: { issue: Issue | null; list: Pr
return (
<Dialog open={issue !== null} onOpenChange={(o) => (o ? null : onClose())}>
<DialogContent maxW={480} gap="$3">
<DialogTitle>Which project is this for?</DialogTitle>
<DialogTitle>{t('Which project is this for?')}</DialogTitle>
<SizableText size="$2" color="$soft">
{named
? `“${named}” is not one of this organization’s projects. Choose the repository the run works on.`
: `This issue names no repository${issue?.board ? `: it is on the ${issue.board} board` : ''}. Choose the one the run works on.`}
? t('“{name}” is not one of this organization’s projects. Choose the repository the run works on.', { name: named })
: issue?.board
? t('This issue names no repository: it is on the {board} board. Choose the one the run works on.', { board: issue.board })
: t('This issue names no repository. Choose the one the run works on.')}
</SizableText>
<Input value={q} onChangeText={setQ} placeholder="Find a project…" aria-label="Find a project" />
<Input value={q} onChangeText={setQ} placeholder={t('Find a project…')} aria-label={t('Find a project')} />
<YStack gap="$1" maxH={320} overflow="scroll">
{shown.length === 0 ? (
<Note>{list.length ? 'Nothing matches.' : 'No projects are linked yet.'}</Note>
<Note>{list.length ? t('Nothing matches.') : t('No projects are linked yet.')}</Note>
) : (
shown.map((p) => (
<XStack key={p.key} render="button" aria-label={`Work on ${p.owner}/${p.name}`} onPress={() => onPick(p)} px="$2.5" py="$2" rounded="$3" items="center" justify="flex-start" hoverStyle={{ bg: '$hover' }}>
<SizableText size="$2" color="$soft" numberOfLines={1} shrink={0}>
{`${p.owner}/`}
</SizableText>
<SizableText size="$2" color="$ink" numberOfLines={1} shrink={1}>
{p.name}
</SizableText>
<XStack key={p.key} render="button" aria-label={t('Work on {repo}', { repo: `${p.owner}/${p.name}` })} onPress={() => onPick(p)} px="$2.5" py="$2" rounded="$3" items="center" justify="flex-start" hoverStyle={{ bg: '$hover' }}>
{/* `owner/name` is an address: it keeps its own order in a right-to-left page. */}
<XStack minW={0} shrink={1} {...{ 'data-i18n-ltr': '' }}>
<SizableText size="$2" color="$soft" numberOfLines={1} shrink={0}>
{`${p.owner}/`}
</SizableText>
<SizableText size="$2" color="$ink" numberOfLines={1} shrink={1}>
{p.name}
</SizableText>
</XStack>
</XStack>
))
)}
</YStack>
<XStack justify="flex-end">
<Button size="sm" variant="outline" onPress={onClose}>
Cancel
{t('Cancel')}
</Button>
</XStack>
</DialogContent>
@@ -573,7 +591,8 @@ function Which({ issue, list, onPick, onClose }: { issue: Issue | null; list: Pr
*/
function Issues() {
const host = useHost()
const t = useTarget()
const target = useTarget()
const { t } = useI18n()
const signed = Boolean(host.person)
const [board, setBoard] = useKept<string>(boardKey(host.org), '')
const [state, setState] = useState<State>('open')
@@ -586,10 +605,10 @@ function Issues() {
const [pulled, setPulled] = useState(0)
const [busy, setBusy] = useState(false)
const [said, setSaid] = useState<{ text: string; connect?: boolean } | null>(null)
const list = useRead(signed ? () => everything(t) : null, [] as Issue[], [t, signed, pulled])
const known = useRead(signed ? () => boards(t) : null, [] as Board[], [t, signed])
const list = useRead(signed ? () => everything(target) : null, [] as Issue[], [target, signed, pulled])
const known = useRead(signed ? () => boards(target) : null, [] as Board[], [target, signed])
// What the org's projects are made from, so an issue is placed on its own repository.
const made = useRead(signed ? () => sources(t) : null, NONE, [t, signed])
const made = useRead(signed ? () => sources(target) : null, NONE, [target, signed])
const projects = useMemo(
() => assemble({ granted: made.value.hub, linked: made.value.linked, held: made.value.held, sites: made.value.sites, work: list.value }),
[made.value, list.value],
@@ -638,11 +657,15 @@ function Issues() {
setBusy(true)
setSaid(null)
try {
const s = await syncIssues(t, board)
const where = board ? title : `${s.repos} ${s.repos === 1 ? 'repository' : 'repositories'}`
const parts = [`${s.issues} ${s.issues === 1 ? 'issue' : 'issues'} synced from ${where}`, `${s.created} new`, `${s.updated} updated`]
if (s.failed) parts.push(`${s.failed} failed`)
setSaid({ text: `${parts.join(' · ')}.${s.truncated ? ' More remain — sync again to continue.' : ''}` })
const s = await syncIssues(target, board)
const where = board ? title : s.repos === 1 ? t('1 repository') : t('{n} repositories', { n: s.repos })
const parts = [
s.issues === 1 ? t('1 issue synced from {where}', { where }) : t('{n} issues synced from {where}', { n: s.issues, where }),
t('{n} new', { n: s.created }),
t('{n} updated', { n: s.updated }),
]
if (s.failed) parts.push(t('{n} failed', { n: s.failed }))
setSaid({ text: `${parts.join(' · ')}.${s.truncated ? ` ${t('More remain — sync again to continue.')}` : ''}` })
setPulled((n) => n + 1)
} catch (e) {
const text = (e as Error).message
@@ -654,7 +677,7 @@ function Issues() {
const link = async () => {
try {
host.open(await connect(t, here()))
host.open(await connect(target, here()))
} catch (e) {
setSaid({ text: (e as Error).message })
}
@@ -666,7 +689,7 @@ function Issues() {
<XStack
key={key || '*'}
render="button"
aria-label={key ? `Show ${name}` : 'Show every project'}
aria-label={key ? t('Show {project}', { project: name }) : t('Show every project')}
aria-pressed={on}
onPress={() => setBoard(key)}
items="center"
@@ -678,7 +701,7 @@ function Issues() {
hoverStyle={{ bg: '$hover' }}
shrink={0}
>
<SizableText flex={1} size="$2" color={on ? '$ink' : '$soft'} numberOfLines={1} style={{ textAlign: 'left' }}>
<SizableText flex={1} size="$2" color={on ? '$ink' : '$soft'} numberOfLines={1} text="start">
{name}
</SizableText>
{count ? (
@@ -696,15 +719,17 @@ function Issues() {
<XStack justify="space-between" items="flex-start" gap="$4" flexWrap="wrap">
<YStack gap="$1" flex={1} minW={240}>
<SizableText render="h1" size="$6" fontWeight="500" color="$ink">
{board ? `Issues · ${title}` : 'Issues'}
{board ? t('Issues · {project}', { project: title }) : t('Issues')}
</SizableText>
<SizableText size="$2" color="$soft">
{board ? `Work on ${title}. Choosing an issue starts the next run on its own repository.` : 'Work across every project. Choosing an issue starts the next run on its own repository.'}
{board
? t('Work on {project}. Choosing an issue starts the next run on its own repository.', { project: title })
: t('Work across every project. Choosing an issue starts the next run on its own repository.')}
</SizableText>
</YStack>
<Button size="sm" variant="outline" disabled={!signed || busy} onPress={() => void pull()}>
<Github size={14} />
{busy ? 'Syncing…' : board ? `Sync ${title} from GitHub` : 'Sync all from GitHub'}
{busy ? t('Syncing…') : board ? t('Sync {project} from GitHub', { project: title }) : t('Sync all from GitHub')}
</Button>
</XStack>
{said ? (
@@ -713,7 +738,7 @@ function Issues() {
{said.connect ? (
<Button size="sm" variant="primary" onPress={() => void link()}>
<Github size={14} />
Connect GitHub
{t('Connect GitHub')}
</Button>
) : null}
</XStack>
@@ -721,62 +746,62 @@ function Issues() {
<XStack gap="$5" items="flex-start" $max-md={{ flexDirection: 'column', gap: '$3' }}>
<YStack
render="nav"
aria-label="Projects"
aria-label={t('Projects')}
width={220}
shrink={0}
gap="$0.5"
$max-md={{ width: '100%', flexDirection: 'row', overflow: 'scroll', gap: '$1' }}
>
<SizableText size="$1" color="$soft" px="$2.5" pb="$1" $max-md={{ display: 'none' }}>
Projects
{t('Projects')}
</SizableText>
{chip('', 'All projects', work.filter((i) => !closed(i)).length)}
{chip('', t('All projects'), work.filter((i) => !closed(i)).length)}
{rows.map((p) => chip(p.key, p.name, p.open))}
</YStack>
<YStack flex={1} minW={0} gap="$3" width="100%">
<Input value={q} onChangeText={setQ} placeholder="Search issues" aria-label="Search issues" disabled={!signed} />
<Input value={q} onChangeText={setQ} placeholder={t('Search issues')} aria-label={t('Search issues')} disabled={!signed} />
{list.error ? (
<Note>{list.error.message}</Note>
) : !signed ? (
<Note>Sign in to see this organization's issues.</Note>
<Note>{t("Sign in to see this organization's issues.")}</Note>
) : list.loading && list.value.length === 0 ? (
<Note>Reading the forge…</Note>
<Note>{t('Reading the forge…')}</Note>
) : (
<YStack borderWidth={1} borderColor="$borderColor" rounded="$3" overflow="hidden">
<XStack px="$3" py="$2" gap="$3" items="center" bg="$panel" flexWrap="wrap">
<XStack gap="$3" items="center" flex={1} minW={0}>
<XStack render="button" aria-label="Open issues" aria-pressed={state === 'open'} items="center" gap="$1.5" onPress={() => setState('open')}>
<XStack render="button" aria-label={t('Open issues')} aria-pressed={state === 'open'} items="center" gap="$1.5" onPress={() => setState('open')}>
<CircleDot size={14} color={state === 'open' ? '$ink' : '$soft'} />
<SizableText size="$2" color={state === 'open' ? '$ink' : '$soft'} fontWeight={state === 'open' ? '500' : '400'}>
{`${opened.length} Open`}
{t('{n} Open', { n: opened.length })}
</SizableText>
</XStack>
<XStack render="button" aria-label="Closed issues" aria-pressed={state === 'closed'} items="center" gap="$1.5" onPress={() => setState('closed')}>
<XStack render="button" aria-label={t('Closed issues')} aria-pressed={state === 'closed'} items="center" gap="$1.5" onPress={() => setState('closed')}>
<Check size={14} color={state === 'closed' ? '$ink' : '$soft'} />
<SizableText size="$2" color={state === 'closed' ? '$ink' : '$soft'} fontWeight={state === 'closed' ? '500' : '400'}>
{`${shut.length} Closed`}
{t('{n} Closed', { n: shut.length })}
</SizableText>
</XStack>
</XStack>
<XStack gap="$1" items="center" flexWrap="wrap" shrink={1} minW={0}>
<Pick label="Label" value={label} options={menu('Any label', labels)} onPick={setLabel} />
<Pick label="Assignee" value={assignee} options={menu('Anyone', assignees)} onPick={setAssignee} />
<Pick label="Kind" value={kind} options={KINDS} onPick={setKind} />
<Pick label="Source" value={origin} options={ORIGINS} onPick={setOrigin} />
<Pick label="Sort" value={order} options={ORDERS} onPick={setOrder} />
<Pick label={t('Label')} value={label} options={menu(t('Any label'), labels)} onPick={setLabel} />
<Pick label={t('Assignee')} value={assignee} options={menu(t('Anyone'), assignees)} onPick={setAssignee} />
<Pick label={t('Kind')} value={kind} options={{ all: t(KINDS.all), issue: t(KINDS.issue), pr: t(KINDS.pr) }} onPick={setKind} />
<Pick label={t('Source')} value={origin} options={{ ...ORIGINS, all: t(ORIGINS.all) }} onPick={setOrigin} />
<Pick label={t('Sort')} value={order} options={{ newest: t(ORDERS.newest), oldest: t(ORDERS.oldest), updated: t(ORDERS.updated) }} onPick={setOrder} />
</XStack>
</XStack>
{shown.length === 0 ? (
<YStack px="$3" py="$6" gap="$3" items="center" borderTopWidth={1} borderColor="$borderColor">
<Note>{state === 'open' ? (narrowed ? 'No open issues match.' : 'Nothing open.') : 'Nothing closed.'}</Note>
<Note>{state === 'open' ? (narrowed ? t('No open issues match.') : t('Nothing open.')) : t('Nothing closed.')}</Note>
{list.value.length === 0 ? (
<XStack gap="$2" flexWrap="wrap" justify="center">
<Button size="sm" variant="outline" disabled={busy} onPress={() => void pull()}>
<Github size={14} />
Sync from GitHub
{t('Sync from GitHub')}
</Button>
<Button size="sm" onPress={() => host.go('')}>
Start a new run
{t('Start a new run')}
</Button>
</XStack>
) : null}
@@ -785,17 +810,17 @@ function Issues() {
shown.map((i) => {
const named = ref(i)
return (
<XStack key={i.key || `${i.board}:${i.title}`} items="center" gap="$2" pr="$3" borderTopWidth={1} borderColor="$borderColor" hoverStyle={{ bg: '$hover' }}>
<XStack key={i.key || `${i.board}:${i.title}`} items="center" gap="$2" paddingInlineEnd="$3" borderTopWidth={1} borderColor="$borderColor" hoverStyle={{ bg: '$hover' }}>
<XStack
render="button"
aria-label={`Build ${named || i.title}`}
aria-label={t('Build {issue}', { issue: named || i.title })}
onPress={() => open(i)}
disabled={finding === i.key && i.key !== ''}
flex={1}
minW={0}
items="flex-start"
gap="$3"
pl="$3"
paddingInlineStart="$3"
py="$2.5"
focusVisibleStyle={{ outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }}
>
@@ -807,7 +832,7 @@ function Issues() {
<SizableText size="$3" color="$ink" fontWeight="500" numberOfLines={1} shrink={1}>
{i.title}
</SizableText>
{STATUS[i.status] && !closed(i) && i.status !== 'todo' ? <Tag>{STATUS[i.status]}</Tag> : null}
{STATUS[i.status] && !closed(i) && i.status !== 'todo' ? <Tag>{t(STATUS[i.status])}</Tag> : null}
{!STATUS[i.status] ? <Tag>{i.status}</Tag> : null}
{i.priority !== 'none' && i.priority ? <Tag>{i.priority}</Tag> : null}
{i.labels.slice(0, 4).map((l) => (
@@ -816,12 +841,12 @@ function Issues() {
</XStack>
<SizableText size="$1" color="$soft" numberOfLines={1}>
{[
named || (i.pull ? 'pr' : i.kind),
named || (i.pull ? t('pr') : i.kind),
!board && projectOf(i) && !named.startsWith(projectOf(i)) ? projectOf(i) : '',
i.created ? `opened ${ago(i.created)}` : '',
i.assignees.length ? `assigned to ${i.assignees.join(', ')}` : '',
i.created ? t('opened {when}', { when: ago(i.created) }) : '',
i.assignees.length ? t('assigned to {people}', { people: i.assignees.join(', ') }) : '',
fromGitHub(i) ? 'GitHub' : '',
finding === i.key && i.key !== '' ? 'opening…' : '',
finding === i.key && i.key !== '' ? t('opening…') : '',
]
.filter(Boolean)
.join(' · ')}
@@ -829,7 +854,7 @@ function Issues() {
</YStack>
</XStack>
{i.url ? (
<Out href={i.url} label={`Open ${named || i.title} on ${fromGitHub(i) ? 'GitHub' : 'the forge'}`}>
<Out href={i.url} label={fromGitHub(i) ? t('Open {issue} on GitHub', { issue: named || i.title }) : t('Open {issue} on the forge', { issue: named || i.title })}>
<ExternalLink size={14} opacity={0.6} />
</Out>
) : null}
+6 -4
View File
@@ -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. */
+44 -34
View File
@@ -4,8 +4,13 @@
* with Merge while it is open, Commits is its branch's history since the base.
* While the run works it is read again every little while, because a run
* pushes when it finishes a step.
*
* Its words are the reader's language (@hanzo/i18n `t`); branches, paths,
* commit messages, a pull request's title and its reviews are drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n, type Say } from '@hanzo/i18n/react'
import { ChevronDown, ChevronRight } from '@hanzogui/lucide-icons-2'
import { Button } from '@hanzo/ui'
import { useEffect, useState } from 'react'
@@ -23,10 +28,11 @@ const mono = { fontFamily: 'var(--f-mono, ui-monospace, monospace)', whiteSpace:
export function Git({ session, title, live }: { session: string; title: string; live: boolean }) {
const host = useHost()
const t = useTarget()
const target = useTarget()
const { t } = useI18n()
const signed = Boolean(host.person)
const [view, setView] = useState<View>('diff')
const got = useRead(signed ? () => read(t, session) : null, NONE, [t, session, signed, live])
const got = useRead(signed ? () => read(target, session) : null, NONE, [target, session, signed, live])
const { reload } = got
useEffect(() => {
@@ -48,7 +54,7 @@ export function Git({ session, title, live }: { session: string; title: string;
{c.head ? (
<SizableText size="$1" color="$soft" numberOfLines={1}>
{`${c.head} → ${c.base}`}
{c.files.length ? ` · ${c.files.length} ${c.files.length === 1 ? 'file' : 'files'} +${added} −${removed}` : ''}
{c.files.length ? ` · ${c.files.length === 1 ? t('1 file') : t('{n} files', { n: c.files.length })} +${added} −${removed}` : ''}
</SizableText>
) : null}
</YStack>
@@ -57,7 +63,7 @@ export function Git({ session, title, live }: { session: string; title: string;
<XStack
key={v}
render="button"
aria-label={label(v)}
aria-label={t(label(v))}
onPress={() => setView(v)}
px="$2.5"
py="$1"
@@ -65,17 +71,17 @@ export function Git({ session, title, live }: { session: string; title: string;
bg={view === v ? '$hover' : 'transparent'}
>
<SizableText size="$2" color={view === v ? '$ink' : '$soft'}>
{label(v)}
{t(label(v))}
</SizableText>
</XStack>
))}
</XStack>
{!signed ? (
<Soft>Sign in to see what this run pushed.</Soft>
<Soft>{t('Sign in to see what this run pushed.')}</Soft>
) : got.error ? (
<Soft>{got.error.message}</Soft>
) : got.loading && !c.head ? (
<Soft>Reading the branch…</Soft>
<Soft>{t('Reading the branch…')}</Soft>
) : view === 'diff' ? (
c.files.length ? (
<YStack borderWidth={1} borderColor="$borderColor" rounded="$3" overflow="hidden">
@@ -84,7 +90,7 @@ export function Git({ session, title, live }: { session: string; title: string;
))}
</YStack>
) : (
<Soft>No pushed changes</Soft>
<Soft>{t('No pushed changes')}</Soft>
)
) : view === 'review' ? (
c.pull ? (
@@ -96,18 +102,18 @@ export function Git({ session, title, live }: { session: string; title: string;
<SizableText size="$1" color="$soft">
{c.pull.state === 'open'
? c.pull.mergeable === false
? 'Open · conflicts with its base'
? t('Open · conflicts with its base')
: c.pull.mergeable
? 'Open · ready to merge'
: 'Open'
? t('Open · ready to merge')
: t('Open')
: c.pull.state === 'merged'
? 'Merged'
: 'Closed'}
? t('Merged')
: t('Closed')}
</SizableText>
{c.pull.url.startsWith('https://') ? (
<Out href={c.pull.url} label={`Open pull request #${c.pull.number}`}>
<Out href={c.pull.url} label={t('Open pull request {pr}', { pr: `#${c.pull.number}` })}>
<SizableText size="$1" color="$ink" textDecorationLine="underline">
Open on the forge
{t('Open on the forge')}
</SizableText>
</Out>
) : null}
@@ -117,7 +123,7 @@ export function Git({ session, title, live }: { session: string; title: string;
c.pull.reviews.map((r, i) => (
<YStack key={i} gap="$1" pb="$2" borderBottomWidth={1} borderColor="$borderColor">
<SizableText size="$1" color="$soft">
{[r.author || 'Someone', verdict(r.state), when(r.at)].filter(Boolean).join(' · ')}
{[r.author || t('Someone'), verdict(r.state, t), when(r.at)].filter(Boolean).join(' · ')}
</SizableText>
{r.body ? (
<SizableText size="$2" color="$ink">
@@ -127,11 +133,11 @@ export function Git({ session, title, live }: { session: string; title: string;
</YStack>
))
) : (
<Soft>No reviews yet</Soft>
<Soft>{t('No reviews yet')}</Soft>
)}
</YStack>
) : (
<Soft>No pull request yet. The run opens one when it pushes its changes.</Soft>
<Soft>{t('No pull request yet. The run opens one when it pushes its changes.')}</Soft>
)
) : c.commits.length ? (
<YStack>
@@ -152,7 +158,7 @@ export function Git({ session, title, live }: { session: string; title: string;
))}
</YStack>
) : (
<Soft>No pushed commits</Soft>
<Soft>{t('No pushed commits')}</Soft>
)}
</YStack>
)
@@ -166,7 +172,8 @@ export function Git({ session, title, live }: { session: string; title: string;
* what closes the button: the pull request reads merged.
*/
export function Merge({ session, open, onMerged }: { session: string; open: boolean; onMerged: () => void }) {
const t = useTarget()
const target = useTarget()
const { t } = useI18n()
const [busy, setBusy] = useState(false)
const [note, setNote] = useState('')
const press = async () => {
@@ -174,8 +181,8 @@ export function Merge({ session, open, onMerged }: { session: string; open: bool
setBusy(true)
setNote('')
try {
const done = await merge(t, session)
setNote(done.base ? `Merged into ${done.base}` : 'Merged')
const done = await merge(target, session)
setNote(done.base ? t('Merged into {branch}', { branch: done.base }) : t('Merged'))
onMerged()
} catch (e) {
setNote((e as Error).message)
@@ -188,7 +195,7 @@ export function Merge({ session, open, onMerged }: { session: string; open: bool
<XStack items="center" gap="$2" flexWrap="wrap">
{open ? (
<Button size="sm" variant="outline" aria-busy={busy} onPress={() => void press()}>
{busy ? 'Merging…' : 'Merge'}
{busy ? t('Merging…') : t('Merge')}
</Button>
) : null}
{note ? (
@@ -201,13 +208,14 @@ export function Merge({ session, open, onMerged }: { session: string; open: bool
}
function File({ change: f, first }: { change: Change; first: boolean }) {
const { t } = useI18n()
const [open, setOpen] = useState(false)
const lines = f.patch ? f.patch.split('\n') : []
return (
<YStack borderTopWidth={first ? 0 : 1} borderColor="$borderColor">
<XStack
render="button"
aria-label={`${open ? 'Hide' : 'Show'} ${f.path}`}
aria-label={open ? t('Hide {path}', { path: f.path }) : t('Show {path}', { path: f.path })}
onPress={() => setOpen(!open)}
items="center"
gap="$2"
@@ -215,11 +223,11 @@ function File({ change: f, first }: { change: Change; first: boolean }) {
py="$2"
hoverStyle={{ bg: '$hover' }}
>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} {...{ 'data-i18n-mirror': '' }} />}
<SizableText size="$1" color="$soft" width={12}>
{f.status[0]!.toUpperCase()}
</SizableText>
<SizableText flex={1} minW={0} size="$2" color="$ink" numberOfLines={1} style={{ textAlign: 'left' }}>
<SizableText flex={1} minW={0} size="$2" color="$ink" numberOfLines={1} text="start">
{f.from ? `${f.from} → ${f.path}` : f.path}
</SizableText>
<SizableText size="$1" color="$green10">{`+${f.additions}`}</SizableText>
@@ -231,12 +239,12 @@ function File({ change: f, first }: { change: Change; first: boolean }) {
<Patch text={f.patch} />
) : (
<SizableText size="$1" color="$soft">
{f.truncated ? 'Too large to show here.' : 'Binary file'}
{f.truncated ? t('Too large to show here.') : t('Binary file')}
</SizableText>
)}
{lines.length && f.truncated ? (
<SizableText size="$1" color="$soft">
The rest of this file’s change is too large to show here.
{t('The rest of this file’s change is too large to show here.')}
</SizableText>
) : null}
</YStack>
@@ -263,20 +271,22 @@ export function Patch({ text }: { text: string }) {
)
}
/** A view's name, marked (`phrase`): drawn through `t`. */
function label(v: View): string {
return v === 'diff' ? 'Diff' : v === 'review' ? 'Review' : 'Commits'
return v === 'diff' ? phrase('Diff') : v === 'review' ? phrase('Review') : phrase('Commits')
}
function verdict(state: string): string {
/** What a reviewer did, in the reader's language; a state the forge names that is not one of these is drawn as given. */
function verdict(state: string, t: Say): string {
switch (state.toUpperCase()) {
case 'APPROVED':
return 'approved'
return t('approved')
case 'REQUEST_CHANGES':
return 'asked for changes'
return t('asked for changes')
case 'COMMENT':
return 'commented'
return t('commented')
default:
return state.toLowerCase() || 'reviewed'
return state.toLowerCase() || t('reviewed')
}
}
+69 -61
View File
@@ -11,8 +11,13 @@
* names one, and an ask with none chosen opens the picker rather than making a
* project up from its words. The choices a person made here are kept in this
* browser, per org, so the next New starts where the last one did.
*
* Its words are the reader's language (@hanzo/i18n `t`, the target is
* `target`); a codebase's, a machine's and a branch's names are drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { Cloud, Monitor } from '@hanzogui/lucide-icons-2'
import { Button } from '@hanzo/ui'
import { BranchSelect, ChipSelect, FooterLink, RepoSelect, type Repo as RowRepo } from '@hanzo/ui/product'
@@ -45,10 +50,10 @@ import { Monitor as Device } from '@hanzogui/lucide-icons-2'
import { compose, Files, pick, read as readFiles, type Attached } from './tools.tsx'
import { useSpoken } from './voice.ts'
/** One vocabulary for the mode, on New and in a workspace. */
/** One vocabulary for the mode, on New and in a workspace: English, marked (`phrase`), drawn through `t`. */
export const MODES = [
{ id: 'build', label: 'Build', hint: 'Edits, commits and pushes a branch' },
{ id: 'plan', label: 'Plan', hint: 'Plans the change and writes nothing' },
{ id: 'build', label: phrase('Build'), hint: phrase('Edits, commits and pushes a branch') },
{ id: 'plan', label: phrase('Plan'), hint: phrase('Plans the change and writes nothing') },
] as const
/** What a person chose last time, per org. */
@@ -113,7 +118,8 @@ export function hold(u: Unsent): void {
export function Landing({ onStarted }: { onStarted: (session: string) => void }) {
const host = useHost()
const t = useTarget()
const target = useTarget()
const { t } = useI18n()
const signed = Boolean(host.person)
const { prefs, save: savePrefs } = usePrefs()
const [stored, keep] = useKept<Partial<Kept> | null>(`hanzo.build.new.${host.org ?? 'none'}`, null)
@@ -145,20 +151,20 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const places = usePlaces(t, signed)
const places = usePlaces(target, signed)
// The model and effort Chat shares, and dictation into the ask.
const { limits } = useLimits(signed ? (signal) => readLimits(t, signal) : null, t)
const mind = useMind(t, (limits as { plan?: string } | null)?.plan)
const voice = useVoice({ speech: useEar(t), language: useSpoken(), ...useDictation(draft, setDraft) })
const talk = useTalk({ baseUrl: t.api, token: () => t.token(), ...(t.org ? { org: t.org } : {}) })
const { limits } = useLimits(signed ? (signal) => readLimits(target, signal) : null, target)
const mind = useMind(target, (limits as { plan?: string } | null)?.plan)
const voice = useVoice({ speech: useEar(target), language: useSpoken(), ...useDictation(draft, setDraft) })
const talk = useTalk({ baseUrl: target.api, token: () => target.token(), ...(target.org ? { org: target.org } : {}) })
// What the run carries beside the ask: skills, memory and the mode, as Chat's turns do.
const carry = useCarry(t, '', (tab) => host.go(path({ kind: 'customize', tab })), {
const carry = useCarry(target, '', (tab) => host.go(path({ kind: 'customize', tab })), {
draft,
setDraft,
// A run reads text: a file that is one rides the ask, a picture goes with a chat.
attach: (list) => {
const text = list.filter((f) => !f.type.startsWith('image/'))
if (text.length < list.length) setNote('A run reads text files; send a picture in Chat.')
if (text.length < list.length) setNote(t('A run reads text files; send a picture in Chat.'))
if (text.length) {
const dt = new DataTransfer()
for (const f of text) dt.items.add(f)
@@ -171,17 +177,17 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
go: (href) => (href.startsWith('http') ? host.open(href) : host.go(href)),
}, DEV_MODES)
const mode: Mode = carry.mode.value === 'plan' ? 'plan' : 'build'
const hints = useSuggestions(t)
const hints = useSuggestions(target)
// The chosen codebase's environment, so New can say when it has none yet.
const codebase = isForge(kept.repo) ? kept.repo.name : ''
// The org's repositories on the forge: what a run and an environment can name.
const homes = useCached<Homes>(signed ? `${host.org}:homes` : null, (signal) => readHomes(t, signal), NOHOMES)
const homes = useCached<Homes>(signed ? `${host.org}:homes` : null, (signal) => readHomes(target, signal), NOHOMES)
// A kept codebase the org's whole list does not hold — a board's key once handed over as one — is not offered as one.
const stranger =
codebase !== '' && homes.value.whole && !homes.value.homes.some((h) => h.name.toLowerCase() === codebase.toLowerCase())
// Read once the list has answered (or failed), so a name that is not the org's is never asked for.
const settled = homes.value !== NOHOMES || homes.error !== null
const found = useRead(signed && codebase && settled && !stranger ? () => read(t, codebase) : null, null as Environment | null, [t, signed, codebase, settled, stranger])
const found = useRead(signed && codebase && settled && !stranger ? () => read(target, codebase) : null, null as Environment | null, [target, signed, codebase, settled, stranger])
// A read kept from the codebase before is not this one's.
const env = { ...found, value: found.value && found.value.repo === codebase && !stranger ? found.value : null }
const place: Place = places.value.find((p) => p.id === kept.place) ?? SANDBOX
@@ -195,26 +201,26 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
// fails leaves the forge's list standing.
const loadRepos = useCallback(
async (q: string) => {
const [page, hub] = await Promise.all([codebases(t, q), githubRepos(t, { q }).catch(() => null)])
const [page, hub] = await Promise.all([codebases(target, q), githubRepos(target, { q }).catch(() => null)])
const repos = page.map(asRepo)
for (const r of repos) known.current.set(r.name, r)
const github = (hub?.repos ?? []).map(asHub)
for (const r of github) hubs.current.set(r.full_name.toLowerCase(), r)
return { repos: [...repos, ...github], next: null }
},
[t],
[target],
)
// Drawn only once a forge codebase is chosen; one the forge no longer lists offers its default branch.
const home = kept.repo?.default_branch || 'main'
const loadBranches = useCallback(
async (q: string) => {
const got = await one(t, codebase)
const got = await one(target, codebase)
const needle = q.trim().toLowerCase()
const names = (got?.branches ?? [home]).filter((b) => !needle || b.toLowerCase().includes(needle))
return { branches: names.map((name) => ({ name })), next: null }
},
[t, codebase, home],
[target, codebase, home],
)
// The composer sends only a draft with words in it, and not while a send is out.
@@ -227,18 +233,18 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
const repo = isForge(kept.repo) ? kept.repo : null
const hub = isHub(kept.repo) ? kept.repo : null
if (!repo && !hub) {
setNote('Choose the project this run works on: a repository of this organization, or one on its GitHub.')
setNote(t('Choose the project this run works on: a repository of this organization, or one on its GitHub.'))
setPicking(true)
return
}
// A plan or a setup routed to a machine is refused here, in its own words, before it leaves the page.
const why = unhonoured(mode, place.id)
const why = unhonoured(mode, place.id, t)
if (why) {
setNote(why)
return
}
if (stranger) {
setNote(`${codebase} is not one of this organization’s repositories. Choose one, or leave it empty to start a new project.`)
setNote(t('{repo} is not one of this organization’s repositories. Choose one, or leave it empty to start a new project.', { repo: codebase }))
setPicking(true)
return
}
@@ -254,11 +260,11 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
return
}
// Manual: the run is asked for before it starts; it edits, commits and pushes on its own once it has.
if (!(await carry.allow({ id: 'run', name: 'Dev to start this run', note: 'It edits, tests, commits and pushes its own branch' }))) {
setNote('Not started.')
if (!(await carry.allow({ id: 'run', name: t('Dev to start this run'), note: t('It edits, tests, commits and pushes its own branch') }))) {
setNote(t('Not started.'))
return
}
const run = await start(t, {
const run = await start(target, {
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.
@@ -275,7 +281,7 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
setIssue(0)
onStarted(run.session)
} catch (e) {
setNote(plain(e, repo?.name ?? hub?.full_name ?? 'a new project', 'run'))
setNote(plain(e, repo?.name ?? hub?.full_name ?? t('a new project'), 'run', t))
} finally {
setBusy(false)
}
@@ -322,9 +328,9 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
return
case 'schedule':
setNote('')
void book(t, a, 'dev').then(
void book(target, 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.'),
(e: unknown) => setNote(e instanceof Error && e.message ? e.message : t('The scheduled task could not be made.')),
)
return
}
@@ -332,7 +338,7 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
/** 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 })
const run = await start(target, { prompt: SETUP, repo, mode: 'setup', model: mind.model, effort: mind.effort })
onStarted(run.session)
}
@@ -347,23 +353,23 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
const setUp =
codebase && env.value
? env.value.state === 'ready'
? `${codebase} environment`
? t('{repo} environment', { repo: codebase })
: env.value.state === 'proposed'
? `${codebase} environment proposed`
? t('{repo} environment proposed', { repo: codebase })
: unset(env.value)
? `no ${codebase} environment yet`
: `${codebase} environment, empty`
? t('no {repo} environment yet', { repo: codebase })
: t('{repo} environment, empty', { repo: codebase })
: ''
const placeItems = useMemo(
() =>
(places.value.length ? places.value : [SANDBOX]).map((p) => ({
id: p.id || 'sandbox',
label: p.id ? p.label : 'Cloud',
hint: p.id ? ['Remote control', p.status, p.capacity].filter(Boolean).join(' · ') : ['Hanzo sandbox', setUp].filter(Boolean).join(' · '),
label: p.id ? p.label : t('Cloud'),
hint: p.id ? [t('Remote control'), p.status, p.capacity].filter(Boolean).join(' · ') : [t('Hanzo sandbox'), setUp].filter(Boolean).join(' · '),
disabled: !ready(p),
place: p,
})),
[places.value, setUp],
[places.value, setUp, t],
)
const branch = kept.branch || kept.repo?.default_branch || 'main'
@@ -371,26 +377,28 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
const where = (
<XStack gap={6} items="center" flexWrap="wrap">
<ChipSelect
name="Where the run runs"
name={t('Where the run runs')}
icon={place.id ? <Monitor size={13} /> : <Cloud size={13} />}
label={place.id ? place.label : 'Cloud'}
label={place.id ? place.label : t('Cloud')}
chosen={placeItems.find((i) => i.place.id === place.id)}
items={placeItems}
onChange={(i) => set({ place: i.place.id })}
placeholder="Search machines…"
placeholder={t('Search machines…')}
loading={places.loading}
error={places.error?.message ?? null}
footer={
<YStack gap="$0.5">
<SizableText size="$1" color="$ink">
Set up remote control
{t('Set up remote control')}
</SizableText>
<SizableText size="$1" color="$soft">
Run{' '}
<SizableText size="$1" color="$ink" style={{ fontFamily: 'var(--f-mono, ui-monospace, monospace)' }}>
hanzo link
</SizableText>{' '}
on a machine: it joins this list, and a run there uses its checkout and credentials.
{t('Run {command} on a machine: it joins this list, and a run there uses its checkout and credentials.', {
command: (
<SizableText size="$1" color="$ink" fontFamily="$mono" {...{ 'data-i18n-ltr': '' }}>
hanzo link
</SizableText>
),
})}
</SizableText>
</YStack>
}
@@ -412,28 +420,28 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
}}
load={loadRepos}
troubleshoot={{
label: 'Missing a GitHub repository? Bring it onto the forge',
label: t('Missing a GitHub repository? Bring it onto the forge'),
onPress: () => host.go(path({ kind: 'screen', screen: 'sync' })),
}}
connect={
<XStack justify="flex-end">
<FooterLink label="Refresh list" onPress={() => setRound((n) => n + 1)} />
<FooterLink label={t('Refresh list')} onPress={() => setRound((n) => n + 1)} />
</XStack>
}
note="Repositories on the forge and on your organization's GitHub. Type to search."
note={t("Repositories on the forge and on your organization's GitHub. Type to search.")}
action={{
label: 'Add to project',
label: t('Add to project'),
onPress: (r) => {
const row = known.current.get(r.name)
setAdding({
repo: row?.clone || `${t.api}/v1/git/${r.owner}/${r.name}.git`,
repo: row?.clone || `${target.api}/v1/git/${r.owner}/${r.name}.git`,
title: row?.full_name || `${r.owner}/${r.name}`,
ref: r.default_branch || 'main',
name: r.name,
})
},
}}
placeholder="Choose a project"
placeholder={t('Choose a project')}
disabled={!signed}
/>
{isForge(kept.repo) ? <BranchSelect value={branch} onChange={(b) => set({ branch: b })} load={loadBranches} /> : null}
@@ -459,39 +467,39 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
<YStack flex={1} minH={0} minW={0} items="center" justify="center" pb="$10" overflowY="auto" overflowX="hidden">
<YStack width="100%" maxW={MEASURE} gap="$3">
<SizableText render="h1" fontSize="$7" lineHeight="$7" fontWeight="700" color="$ink" style={{ textAlign: 'center' }}>
Build what’s next.
{t('Build what’s next.')}
</SizableText>
<YStack gap="$2">
{stranger ? (
<XStack items="center" justify="space-between" gap="$3" px="$3" py="$2" rounded="$10" borderWidth={1} borderColor="$borderColor" role="alert">
<SizableText size="$2" color="$ink" flex={1} minW={0}>
{`${codebase} is not one of this organization’s repositories.`}
{t('{repo} is not one of this organization’s repositories.', { repo: codebase })}
</SizableText>
<Button size="sm" variant="outline" onPress={() => setPicking(true)}>
Choose a repository
{t('Choose a repository')}
</Button>
</XStack>
) : env.value && (env.value.state === 'proposed' || unset(env.value)) && !place.id ? (
<XStack items="center" justify="space-between" gap="$3" px="$3" py="$2" rounded="$10" borderWidth={1} borderColor="$borderColor">
<SizableText size="$2" color="$ink" flex={1} minW={0}>
{env.value.state === 'proposed'
? `A proposed environment for ${codebase} is waiting for review.`
: `${codebase} has no environment yet, so every run starts it bare.`}
? t('A proposed environment for {repo} is waiting for review.', { repo: codebase })
: t('{repo} has no environment yet, so every run starts it bare.', { repo: codebase })}
</SizableText>
{env.value.state === 'proposed' && env.value.session ? (
<Button size="sm" variant="outline" onPress={() => host.go(env.value!.session)}>
Review
{t('Review')}
</Button>
) : (
<Button size="sm" onPress={() => setOffering(true)}>
Set up environment
{t('Set up environment')}
</Button>
)}
</XStack>
) : null}
{note || unhonoured(mode, place.id) || mind.note ? (
{note || unhonoured(mode, place.id, t) || mind.note ? (
<SizableText size="$1" color="$soft" role="status">
{note || unhonoured(mode, place.id) || mind.note}
{note || unhonoured(mode, place.id, t) || mind.note}
</SizableText>
) : null}
<Prompt
@@ -500,10 +508,10 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
onChange={setDraft}
onSend={() => void send()}
busy={busy}
label="Describe a task or ask a question"
label={t('Describe a task or ask a question')}
head={head}
plus={{
upload: () => pick(files, setFiles, setNote),
upload: () => pick(files, setFiles, setNote, t),
skills: carry.skills,
design: carry.design,
plugins: carry.plugins,
+113 -90
View File
@@ -10,6 +10,9 @@
* with the org, copy its link, rename, move to a project, use in Studio or in
* Chat, and delete. What a colleague shared is drawn with their name and offers
* only what a reader may do with it.
*
* Its words are the reader's language (@hanzo/i18n `t`); an item's name, a
* colleague's, a project's and what an item holds are drawn as given.
*/
import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui'
import {
@@ -35,11 +38,14 @@ import {
Type,
Video,
} from '@hanzogui/lucide-icons-2'
import { phrase } from '@hanzo/i18n'
import { useI18n, type Say } from '@hanzo/i18n/react'
import { Button, Dialog, DialogContent, DialogTitle, DropdownMenu, Input, type DropdownMenuProps } from '@hanzo/ui'
import { ChipSelect } from '@hanzo/ui/product'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ago } from './ago.ts'
import { told } from './api/call.ts'
import { change, days, get, items as list, KINDS, remove, size, SOURCE, type Item, type Kind as ItemKind, type Scope, type Source } from './api/library.ts'
import { Confirm, Rename } from './ask.tsx'
import { useKept, useProjects, useRead } from './data.ts'
@@ -53,20 +59,25 @@ import { useDeck } from './tabs.ts'
type MenuItems = NonNullable<DropdownMenuProps['items']>
/** Where an item was made: Chat, Dev and Studio by their names, the rest in words, marked (`phrase`) and drawn through `t`. */
const FROM: readonly { id: Source | ''; label: string }[] = [
{ id: '', label: 'Anywhere' },
{ id: '', label: phrase('Anywhere') },
{ id: 'chat', label: 'Chat' },
{ id: 'dev', label: 'Dev' },
{ id: 'studio', label: 'Studio' },
{ id: 'workflow', label: 'Workflows' },
{ id: 'workflow', label: phrase('Workflows') },
]
const WHOSE: readonly { id: Scope; label: string }[] = [
{ id: 'all', label: 'Mine and shared' },
{ id: 'mine', label: 'Mine' },
{ id: 'shared', label: 'Shared with me' },
{ id: 'all', label: phrase('Mine and shared') },
{ id: 'mine', label: phrase('Mine') },
{ id: 'shared', label: phrase('Shared with me') },
]
/** A day's list, named: the days the Library says in words each a sentence, a month as given. */
const MADE: Record<string, string> = { Today: phrase('Made today'), Yesterday: phrase('Made yesterday'), Earlier: phrase('Made earlier') }
const made = (label: string, t: Say): string => (MADE[label] ? t(MADE[label]) : t('Made {month}', { month: label.toLowerCase() }))
/** How many items a page reads. */
const PAGE = 60
@@ -117,8 +128,8 @@ export function Thumb({ x, size: n }: { x: Item; size?: number }) {
}
/** What a tile says under its name: where it came from, whose, and when. */
function meta(x: Item): string {
return [SOURCE[x.source], x.mine ? '' : `shared by ${x.owner}`, ago(x.created)].filter(Boolean).join(' · ')
function meta(x: Item, t: Say): string {
return [t(SOURCE[x.source]), x.mine ? '' : t('shared by {owner}', { owner: x.owner }), ago(x.created)].filter(Boolean).join(' · ')
}
/** An item's link, to paste: this page with the item named. */
@@ -159,8 +170,9 @@ function named(): string {
/** What can be done with one item, as menu rows. */
function useActs(onChanged: (x: Item) => void, onGone: (id: string) => void) {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const [renaming, setRenaming] = useState<Item | null>(null)
const [deleting, setDeleting] = useState<Item | null>(null)
const [moving, setMoving] = useState<Item | null>(null)
@@ -171,7 +183,7 @@ function useActs(onChanged: (x: Item) => void, onGone: (id: string) => void) {
try {
await work()
} catch (e) {
setNote((e as Error).message)
setNote(told(e, t))
}
}
const copy = (text: string, said: string) =>
@@ -181,34 +193,34 @@ function useActs(onChanged: (x: Item) => void, onGone: (id: string) => void) {
})
const rows = (x: Item): MenuItems => [
...(x.origin ? [{ key: 'open', label: 'Open where it was made', icon: <ExternalLink size={16} />, onSelect: () => host.open(x.origin) }] : []),
...(x.download ? [{ key: 'download', label: 'Download', icon: <Download size={16} />, onSelect: () => window.open(x.download, '_blank', 'noopener,noreferrer') }] : []),
...(x.origin ? [{ key: 'open', label: t('Open where it was made'), icon: <ExternalLink size={16} />, onSelect: () => host.open(x.origin) }] : []),
...(x.download ? [{ key: 'download', label: t('Download'), icon: <Download size={16} />, onSelect: () => window.open(x.download, '_blank', 'noopener,noreferrer') }] : []),
...(x.mine
? [
{
key: 'share',
label: x.shared ? 'Stop sharing' : 'Share with organization',
label: x.shared ? t('Stop sharing') : t('Share with organization'),
icon: <Share2 size={16} />,
description: x.shared ? 'Only you see it' : 'Everyone in it can open it',
description: x.shared ? t('Only you see it') : t('Everyone in it can open it'),
onSelect: () =>
void act(async () => {
const next = await change(t, x.id, { shared: !x.shared })
const next = await change(target, x.id, { shared: !x.shared })
onChanged(next)
setNote(next.shared ? `${next.name} is shared with the organization` : `${next.name} is yours alone`)
setNote(next.shared ? t('{name} is shared with the organization', { name: next.name }) : t('{name} is yours alone', { name: next.name }))
}),
},
]
: []),
{ key: 'link', label: 'Copy link', icon: <Copy size={16} />, onSelect: () => void copy(linkTo(x), 'Link copied') },
{ key: 'link', label: t('Copy link'), icon: <Copy size={16} />, onSelect: () => void copy(linkTo(x), t('Link copied')) },
...(x.mine
? [
{ key: 'rename', label: 'Rename', icon: <Pencil size={16} />, onSelect: () => setRenaming(x) },
{ key: 'move', label: 'Move to project', icon: <FolderInput size={16} />, onSelect: () => setMoving(x) },
{ key: 'rename', label: t('Rename'), icon: <Pencil size={16} />, onSelect: () => setRenaming(x) },
{ key: 'move', label: t('Move to project'), icon: <FolderInput size={16} />, onSelect: () => setMoving(x) },
]
: []),
{ key: 'studio', label: 'Use in Studio', icon: <Sparkles size={16} />, onSelect: () => host.open(`/studio?asset=${encodeURIComponent(x.id)}`) },
{ key: 'chat', label: 'Use in Chat', icon: <MessageSquare size={16} />, onSelect: () => host.open(`/chat?attach=${encodeURIComponent(x.id)}`) },
...(x.mine ? [{ key: 'delete', label: 'Delete', icon: <Trash2 size={16} />, onSelect: () => setDeleting(x) }] : []),
{ key: 'studio', label: t('Use in Studio'), icon: <Sparkles size={16} />, onSelect: () => host.open(`/studio?asset=${encodeURIComponent(x.id)}`) },
{ key: 'chat', label: t('Use in Chat'), icon: <MessageSquare size={16} />, onSelect: () => host.open(`/chat?attach=${encodeURIComponent(x.id)}`) },
...(x.mine ? [{ key: 'delete', label: t('Delete'), icon: <Trash2 size={16} />, onSelect: () => setDeleting(x) }] : []),
]
const dialogs = (
@@ -216,21 +228,21 @@ function useActs(onChanged: (x: Item) => void, onGone: (id: string) => void) {
<Rename
open={Boolean(renaming)}
onOpenChange={(o) => !o && setRenaming(null)}
title="Rename"
title={t('Rename')}
name={renaming?.name ?? ''}
run={async (name) => {
const next = await change(t, renaming!.id, { name })
const next = await change(target, renaming!.id, { name })
onChanged(next)
}}
/>
<Confirm
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title={`Delete ${deleting?.name ?? ''}?`}
says="It leaves the Library and its file is deleted. Anyone it was shared with loses it too."
act="Delete"
title={t('Delete {name}?', { name: deleting?.name ?? '' })}
says={t('It leaves the Library and its file is deleted. Anyone it was shared with loses it too.')}
act={t('Delete')}
run={async () => {
await remove(t, deleting!.id)
await remove(target, deleting!.id)
onGone(deleting!.id)
}}
/>
@@ -239,7 +251,7 @@ function useActs(onChanged: (x: Item) => void, onGone: (id: string) => void) {
onClose={() => setMoving(null)}
onMoved={(next) => {
onChanged(next)
setNote(next.project ? `${next.name} is in ${next.project}` : `${next.name} is in no project`)
setNote(next.project ? t('{name} is in {project}', { name: next.name, project: next.project }) : t('{name} is in no project', { name: next.name }))
}}
/>
</>
@@ -249,9 +261,10 @@ function useActs(onChanged: (x: Item) => void, onGone: (id: string) => void) {
/** Choosing the project an item belongs to, or none. */
function Move({ item: x, onClose, onMoved }: { item: Item | null; onClose: () => void; onMoved: (x: Item) => void }) {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const projects = useProjects(t, Boolean(host.person) && Boolean(x))
const target = useTarget()
const projects = useProjects(target, Boolean(host.person) && Boolean(x))
const [busy, setBusy] = useState(false)
const [note, setNote] = useState('')
const pick = async (project: string) => {
@@ -259,10 +272,10 @@ function Move({ item: x, onClose, onMoved }: { item: Item | null; onClose: () =>
setBusy(true)
setNote('')
try {
onMoved(await change(t, x.id, { project }))
onMoved(await change(target, x.id, { project }))
onClose()
} catch (e) {
setNote((e as Error).message)
setNote(told(e, t))
} finally {
setBusy(false)
}
@@ -270,22 +283,22 @@ function Move({ item: x, onClose, onMoved }: { item: Item | null; onClose: () =>
return (
<Dialog open={Boolean(x)} onOpenChange={(o) => !o && !busy && onClose()}>
<DialogContent maxW={420}>
<DialogTitle>Move to project</DialogTitle>
<DialogTitle>{t('Move to project')}</DialogTitle>
<YStack gap="$1" maxH={320} overflow="scroll">
{projects.loading && !projects.value.length ? (
<SizableText size="$2" color="$soft">
Reading projects…
{t('Reading projects…')}
</SizableText>
) : projects.error ? (
<SizableText size="$2" color="$soft">
{projects.error.message}
{told(projects.error, t)}
</SizableText>
) : (
[{ slug: '', name: 'No project' }, ...projects.value].map((p) => (
[{ slug: '', name: t('No project') }, ...projects.value].map((p) => (
<XStack
key={p.slug || 'none'}
render="button"
aria-label={p.slug ? `Move to ${p.name || p.slug}` : 'Move to no project'}
aria-label={p.slug ? t('Move to {project}', { project: p.name || p.slug }) : t('Move to no project')}
aria-current={x?.project === p.slug ? 'true' : undefined}
disabled={busy}
onPress={() => void pick(p.slug)}
@@ -315,10 +328,11 @@ function Move({ item: x, onClose, onMoved }: { item: Item | null; onClose: () =>
/** One item's menu, as a quiet control. */
function Acts({ x, rows }: { x: Item; rows: MenuItems }) {
const { t } = useI18n()
return (
<DropdownMenu
trigger={
<XStack render="button" aria-label={`Actions for ${x.name}`} width={30} height={30} items="center" justify="center" rounded="$2" hoverStyle={{ bg: '$hover' }} cursor="pointer" shrink={0}>
<XStack render="button" aria-label={t('Actions for {name}', { name: x.name })} width={30} height={30} items="center" justify="center" rounded="$2" hoverStyle={{ bg: '$hover' }} cursor="pointer" shrink={0}>
<MoreHorizontal size={16} />
</XStack>
}
@@ -329,11 +343,12 @@ function Acts({ x, rows }: { x: Item; rows: MenuItems }) {
/** One tile of the grid: the still, then its name and where it came from; its menu beside the name. */
function Tile({ x, onOpen, rows }: { x: Item; onOpen: () => void; rows: MenuItems }) {
const { t } = useI18n()
return (
<YStack role="listitem" gap="$2" minW={0}>
<YStack
render="button"
aria-label={`Open ${x.name}`}
aria-label={t('Open {name}', { name: x.name })}
onPress={onOpen}
rounded="$4"
overflow="hidden"
@@ -349,7 +364,7 @@ function Tile({ x, onOpen, rows }: { x: Item; onOpen: () => void; rows: MenuItem
{x.name}
</SizableText>
<SizableText size="$1" color="$soft" numberOfLines={1}>
{meta(x)}
{meta(x, t)}
</SizableText>
</YStack>
<Acts x={x} rows={rows} />
@@ -360,19 +375,20 @@ function Tile({ x, onOpen, rows }: { x: Item; onOpen: () => void; rows: MenuItem
/** One row of the list: a small still, its name, where it came from, how large, and its menu. */
function Row({ x, onOpen, rows }: { x: Item; onOpen: () => void; rows: MenuItems }) {
const { t } = useI18n()
return (
<XStack role="listitem" items="center" gap="$3" px="$2" py="$2" rounded="$3" hoverStyle={{ bg: '$hover' }} minW={0}>
<XStack render="button" aria-label={`Open ${x.name}`} onPress={onOpen} flex={1} minW={0} items="center" gap="$3" cursor="pointer" style={{ textAlign: 'left' }}>
<XStack render="button" aria-label={t('Open {name}', { name: x.name })} onPress={onOpen} flex={1} minW={0} items="center" gap="$3" cursor="pointer" $platform-web={{ textAlign: 'start' }}>
<Thumb x={x} size={40} />
<YStack flex={1} minW={0} gap="$0.5">
<SizableText size="$2" color="$ink" numberOfLines={1}>
{x.name}
</SizableText>
<SizableText size="$1" color="$soft" numberOfLines={1}>
{meta(x)}
{meta(x, t)}
</SizableText>
</YStack>
<SizableText size="$1" color="$soft" width={72} style={{ textAlign: 'right' }} $max-md={{ display: 'none' }}>
<SizableText size="$1" color="$soft" width={72} text="end" $max-md={{ display: 'none' }}>
{size(x.bytes)}
</SizableText>
</XStack>
@@ -411,14 +427,15 @@ function Chip({ label, on, onPress }: { label: string; on: boolean; onPress: ()
/** One item, read fresh so its signed URLs are live: the thing itself, then what it is. */
export function Preview({ id, rows }: { id: string; rows?: (x: Item) => MenuItems }) {
const t = useTarget()
const { t } = useI18n()
const target = useTarget()
const host = useHost()
const one = useRead(host.person ? () => get(t, id) : null, null as Item | null, [t, id, host.person])
const one = useRead(host.person ? () => get(target, id) : null, null as Item | null, [target, id, host.person])
const x = one.value
if (!x) {
return (
<SizableText size="$2" color="$soft">
{!host.person ? 'Sign in to open it.' : one.error ? one.error.message : 'Reading…'}
{!host.person ? t('Sign in to open it.') : one.error ? told(one.error, t) : t('Reading…')}
</SizableText>
)
}
@@ -431,7 +448,7 @@ export function Preview({ id, rows }: { id: string; rows?: (x: Item) => MenuItem
{x.name}
</SizableText>
<SizableText size="$1" color="$soft">
{[meta(x), size(x.bytes), x.width && x.height ? `${x.width}×${x.height}` : '', x.project ? `in ${x.project}` : '', x.shared ? 'shared with the organization' : '']
{[meta(x, t), size(x.bytes), x.width && x.height ? `${x.width}×${x.height}` : '', x.project ? t('in {project}', { project: x.project }) : '', x.shared ? t('shared with the organization') : '']
.filter(Boolean)
.join(' · ')}
</SizableText>
@@ -442,13 +459,13 @@ export function Preview({ id, rows }: { id: string; rows?: (x: Item) => MenuItem
{x.origin ? (
<Button size="sm" variant="outline" onPress={() => host.open(x.origin)}>
<ExternalLink size={14} />
Open where it was made
{t('Open where it was made')}
</Button>
) : null}
{x.download ? (
<Button size="sm" variant="outline" onPress={() => window.open(x.download, '_blank', 'noopener,noreferrer')}>
<Download size={14} />
Download
{t('Download')}
</Button>
) : null}
</XStack>
@@ -470,6 +487,7 @@ function Shown({ x }: { x: Item }) {
/** A text item's first 200 KB, as written; in a frame where the store does not answer this page's read. */
function Text({ x }: { x: Item }) {
const { t } = useI18n()
const read = useRead(
async () => {
const res = await fetch(x.url, { cache: 'no-store' })
@@ -483,7 +501,7 @@ function Text({ x }: { x: Item }) {
return (
<YStack rounded="$3" bg="$raised" p="$3" maxH="60vh" overflow="scroll">
<SizableText size="$1" color="$ink" style={{ whiteSpace: 'pre-wrap', fontFamily: 'var(--f-mono, ui-monospace, monospace)' }}>
{read.loading && !read.value ? 'Reading…' : read.value}
{read.loading && !read.value ? t('Reading…') : read.value}
</SizableText>
</YStack>
)
@@ -491,26 +509,27 @@ function Text({ x }: { x: Item }) {
/** A run's outputs as small tiles: each opens in the Library. */
export function Outputs({ ids }: { ids: string[] }) {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const key = ids.join(',')
const got = useRead(host.person && ids.length ? () => Promise.all(ids.slice(0, 12).map((id) => get(t, id).catch(() => null))) : null, [] as (Item | null)[], [t, key, host.person])
const got = useRead(host.person && ids.length ? () => Promise.all(ids.slice(0, 12).map((id) => get(target, id).catch(() => null))) : null, [] as (Item | null)[], [target, key, host.person])
const shown = got.value.filter((x): x is Item => x !== null)
if (!ids.length) return null
return (
<XStack gap="$2" flexWrap="wrap" rowGap="$2" pt="$1.5" role="list" aria-label="What it made">
<XStack gap="$2" flexWrap="wrap" rowGap="$2" pt="$1.5" role="list" aria-label={t('What it made')}>
{shown.map((x) => (
<XStack key={x.id} role="listitem">
<XStack
render="button"
aria-label={`Open ${x.name} in the Library`}
aria-label={t('Open {name} in the Library', { name: x.name })}
onPress={() => {
want(x.id)
host.go('-/artifacts')
}}
items="center"
gap="$2"
pr="$2.5"
paddingInlineEnd="$2.5"
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
@@ -528,7 +547,7 @@ export function Outputs({ ids }: { ids: string[] }) {
))}
{got.loading && !shown.length ? (
<SizableText size="$1" color="$soft">
{`Reading ${ids.length === 1 ? 'what it made' : `the ${ids.length} things it made`}…`}
{ids.length === 1 ? t('Reading what it made…') : t('Reading the {n} things it made…', { n: ids.length })}
</SizableText>
) : null}
</XStack>
@@ -601,8 +620,9 @@ function useEnd(on: () => void) {
export function Library() {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const signed = Boolean(host.person)
const wide = useMedia().md
// The kind is the address's (-/artifacts/<kind>), so the sidebar, a link and Back all name it.
@@ -624,8 +644,8 @@ export function Library() {
return () => clearTimeout(id)
}, [typed])
const load = useMemo(() => (signed ? (cursor: string, signal: AbortSignal) => list(t, { kind, source, scope, q, limit: PAGE, cursor }, { signal }) : null), [signed, t, kind, source, scope, q])
const pages = usePages(load, `${kind}|${source}|${scope}|${q}|${t.org}`)
const load = useMemo(() => (signed ? (cursor: string, signal: AbortSignal) => list(target, { kind, source, scope, q, limit: PAGE, cursor }, { signal }) : null), [signed, target, kind, source, scope, q])
const pages = usePages(load, `${kind}|${source}|${scope}|${q}|${target.org}`)
const end = useEnd(pages.more)
const reload = pages.reload
useEffect(() => {
@@ -646,8 +666,8 @@ export function Library() {
if (arrived.current || !signed) return
arrived.current = true
const id = named()
if (id) get(t, id).then(look, () => look({ id, name: 'Item' }))
}, [signed, t, look])
if (id) get(target, id).then(look, () => look({ id, name: t('Item') }))
}, [signed, target, look, t])
const acts = useActs(
(x) => {
@@ -663,11 +683,14 @@ export function Library() {
const kinds: Kind[] = side.tabs
.filter((tab) => tab.kind.startsWith(TAB))
.map((tab) => ({ id: tab.kind, label: tab.title, render: () => <Preview key={tab.kind} id={tab.kind.slice(TAB.length)} rows={acts.rows} /> }))
const panel = (sheet: boolean) => <Panel side={side} kinds={kinds} sheet={sheet} onHide={() => setShown(false)} label="Item preview" />
const panel = (sheet: boolean) => <Panel side={side} kinds={kinds} sheet={sheet} onHide={() => setShown(false)} label={t('Item preview')} />
const open = shown && side.tabs.length > 0
const groups = days(pages.rows)
const narrowed = Boolean(kind || source || q || scope !== 'all')
// The choices in the reader's words; Chat, Dev and Studio stay their names.
const from = FROM.map((f) => ({ ...f, label: t(f.label) }))
const whose = WHOSE.map((w) => ({ ...w, label: t(w.label) }))
return (
<XStack flex={1} minH={0} minW={0} width="100%">
@@ -675,13 +698,13 @@ export function Library() {
<YStack width="100%" maxW={1120} mx="auto" gap="$4">
<XStack items="center" gap="$3">
<SizableText render="h1" size="$7" fontWeight="600" color="$ink" flex={1}>
{KINDS.find((k) => k.id === kind)?.id ? KINDS.find((k) => k.id === kind)!.label : 'Library'}
{KINDS.find((k) => k.id === kind)?.id ? t(KINDS.find((k) => k.id === kind)!.label) : t('Library')}
</SizableText>
<XStack role="radiogroup" aria-label="View" gap="$1">
<ViewMark label="Grid" on={view === 'grid'} onPress={() => setView('grid')}>
<XStack role="radiogroup" aria-label={t('View')} gap="$1">
<ViewMark label={t('Grid')} on={view === 'grid'} onPress={() => setView('grid')}>
<LayoutGrid size={16} />
</ViewMark>
<ViewMark label="List" on={view === 'list'} onPress={() => setView('list')}>
<ViewMark label={t('List')} on={view === 'list'} onPress={() => setView('list')}>
<List size={16} />
</ViewMark>
</XStack>
@@ -690,34 +713,34 @@ export function Library() {
<XStack items="center" gap="$2" maxW={480}>
<Search size={16} opacity={0.6} />
<YStack flex={1} minW={0}>
<Input value={typed} onChangeText={setTyped} placeholder="Search your library" aria-label="Search your library" disabled={!signed} />
<Input value={typed} onChangeText={setTyped} placeholder={t('Search your library')} aria-label={t('Search your library')} disabled={!signed} />
</YStack>
</XStack>
<YStack gap="$2">
{/* A host whose sidebar lists the kinds draws them there. */}
{host.sections ? null : (
<XStack role="radiogroup" aria-label="Kind" gap="$2" overflowX="auto" pb="$1" $platform-web={{ scrollbarWidth: 'none' }}>
<XStack role="radiogroup" aria-label={t('Kind')} gap="$2" overflowX="auto" pb="$1" $platform-web={{ scrollbarWidth: 'none' }}>
{KINDS.map((k) => (
<Chip key={k.id || 'all'} label={k.label} on={kind === k.id} onPress={() => setKind(k.id)} />
<Chip key={k.id || 'all'} label={t(k.label)} on={kind === k.id} onPress={() => setKind(k.id)} />
))}
</XStack>
)}
<XStack gap="$2" flexWrap="wrap" rowGap="$2">
<ChipSelect
name="From"
label={FROM.find((f) => f.id === source)!.label}
chosen={FROM.find((f) => f.id === source)}
items={FROM}
name={t('From')}
label={from.find((f) => f.id === source)!.label}
chosen={from.find((f) => f.id === source)}
items={from}
onChange={(f) => setSource(f.id)}
placement="bottom-start"
width={200}
/>
<ChipSelect
name="Whose"
label={WHOSE.find((w) => w.id === scope)!.label}
chosen={WHOSE.find((w) => w.id === scope)}
items={WHOSE}
name={t('Whose')}
label={whose.find((w) => w.id === scope)!.label}
chosen={whose.find((w) => w.id === scope)}
items={whose}
onChange={(w) => setScope(w.id)}
placement="bottom-start"
width={220}
@@ -732,36 +755,36 @@ export function Library() {
) : null}
{!signed ? (
<Said says="Sign in to see your library.">
<Said says={t('Sign in to see your library.')}>
<Button size="sm" onPress={() => host.signIn?.()}>
Sign in
{t('Sign in')}
</Button>
</Said>
) : pages.error && !pages.rows.length ? (
<Said says={pages.error.message}>
<Said says={told(pages.error, t)}>
<Button size="sm" variant="outline" onPress={pages.reload}>
Try again
{t('Try again')}
</Button>
</Said>
) : pages.loading && !pages.rows.length ? (
<Said says="Reading…" />
<Said says={t('Reading…')} />
) : !pages.rows.length ? (
<Said says={narrowed ? 'Nothing matches.' : 'Nothing here yet. What you make in Chat, Dev and Studio lands here.'} />
<Said says={narrowed ? t('Nothing matches.') : t('Nothing here yet. What you make in Chat, Dev and Studio lands here.')} />
) : (
<YStack gap="$5">
{groups.map((g) => (
<YStack key={g.label} gap="$3" role="group" aria-label={g.label}>
<YStack key={g.label} gap="$3" role="group" aria-label={t(g.label)}>
<SizableText size="$2" color="$soft" fontWeight="500">
{g.label}
{t(g.label)}
</SizableText>
{view === 'grid' ? (
<div role="list" aria-label={`Made ${g.label.toLowerCase()}`} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(46%, 168px), 1fr))', gap: 16 }}>
<YStack role="list" aria-label={made(g.label, t)} gap={16} $platform-web={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(46%, 168px), 1fr))' }}>
{g.rows.map((x) => (
<Tile key={x.id} x={x} onOpen={() => look(x)} rows={acts.rows(x)} />
))}
</div>
</YStack>
) : (
<YStack role="list" aria-label={`Made ${g.label.toLowerCase()}`} gap="$0.5">
<YStack role="list" aria-label={made(g.label, t)} gap="$0.5">
{g.rows.map((x) => (
<Row key={x.id} x={x} onOpen={() => look(x)} rows={acts.rows(x)} />
))}
@@ -772,11 +795,11 @@ export function Library() {
<YStack ref={end} height={1} />
{pages.loading ? (
<SizableText size="$1" color="$soft">
Reading more…
{t('Reading more…')}
</SizableText>
) : pages.error ? (
<SizableText size="$1" color="$soft" role="status">
{pages.error.message}
{told(pages.error, t)}
</SizableText>
) : null}
</YStack>
+20 -10
View File
@@ -12,25 +12,31 @@
* balance, because that balance is what it spends.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { money } from './api/billing.ts'
import type { Read } from './data.ts'
import { left, rows, type Standing } from './standing.ts'
import { left, rows, type Row, type Standing } from './standing.ts'
import { when, type Label, type State } from './plan.ts'
/** The ink a share is said in: quiet until it is close, and the one red when it is used. */
export const INK = { ok: '$soft', near: '$yellow10', limited: '$red10' } as const satisfies Record<State, string>
/** A plan's name with its rung beside it, small: `Max` `20x`. */
/** A window's name, in English; drawn through `t`. */
const WINDOW: Record<Row['name'], string> = { Session: phrase('Session'), Today: phrase('Today'), Month: phrase('Month') }
/** A plan's name with its rung beside it, small: `Max` `20x`. A family's name is a name; Free and the like are words. */
export function Title({ label, size = '$3' }: { label: Label; size?: '$3' | '$5' | '$6' }) {
const { t } = useI18n()
return (
<XStack items="baseline" gap="$1.5" minW={0} data-slot="plan-name">
<SizableText size={size} color="$ink" style={{ fontWeight: 600 }} numberOfLines={1}>
{label.name}
{t(label.name)}
</SizableText>
{label.tag ? (
<SizableText size="$1" color="$soft" px="$1.5" rounded="$2" borderWidth={1} borderColor="$borderColor" data-slot="plan-tag">
{label.tag}
{t(label.tag)}
</SizableText>
) : null}
</XStack>
@@ -50,19 +56,20 @@ function Act({ label, onPress }: { label: string; onPress: () => void }) {
/** The standing, drawn: what `Meter` shows once it has read. */
export function Reading({ s, onPlans, onBilling, now }: { s: Standing; onPlans: () => void; onBilling: () => void; now?: number }) {
const windows = s.kind === 'plan' ? rows(s.limits, now) : []
const { t } = useI18n()
const windows = s.kind === 'plan' ? rows(s.limits, now, t) : []
const share = s.kind === 'free' ? left(s.allowance) : null
const resets = s.allowance ? when(s.allowance.resets, now) : ''
return (
<YStack px="$2" py="$1.5" gap="$1" data-slot="meter" data-kind={s.kind}>
<XStack items="center" gap="$2" minW={0}>
<XStack flex={1} minW={0}>{s.label ? <Title label={s.label} /> : null}</XStack>
{s.kind === 'free' ? <Act label="Upgrade" onPress={onPlans} /> : s.kind === 'credits' ? <Act label="Add funds" onPress={onBilling} /> : null}
{s.kind === 'free' ? <Act label={t('Upgrade')} onPress={onPlans} /> : s.kind === 'credits' ? <Act label={t('Add funds')} onPress={onBilling} /> : null}
</XStack>
{windows.map((w) => (
<XStack key={w.name} items="baseline" gap="$2" data-slot="meter-window" data-state={w.state}>
<SizableText size="$1" color="$soft" width={56} shrink={0}>
{w.name}
{t(WINDOW[w.name])}
</SizableText>
<SizableText size="$1" color={INK[w.state]} style={{ fontVariantNumeric: 'tabular-nums' }} shrink={0}>
{w.percent}%
@@ -74,12 +81,14 @@ export function Reading({ s, onPlans, onBilling, now }: { s: Standing; onPlans:
))}
{s.kind === 'free' && share !== null ? (
<SizableText size="$1" color={share === 0 ? '$red10' : '$soft'} data-slot="meter-free">
{`${share}% of today’s free usage left${resets ? ` · Resets ${resets}` : ''}`}
{resets
? `${t('{n}% of today’s free usage left', { n: share })} · ${t('Resets {time}', { time: resets })}`
: t('{n}% of today’s free usage left', { n: share })}
</SizableText>
) : null}
{s.kind === 'credits' && s.balance !== null ? (
<SizableText size="$1" color="$soft" data-slot="meter-balance">
{`${money(s.balance)} in credits`}
{t('{amount} in credits', { amount: money(s.balance) })}
</SizableText>
) : null}
</YStack>
@@ -92,10 +101,11 @@ export function Reading({ s, onPlans, onBilling, now }: { s: Standing; onPlans:
* live on; the host says where each is.
*/
export function Meter({ read, onPlans, onBilling }: { read: Read<Standing | null>; onPlans: () => void; onBilling: () => void }) {
const { t } = useI18n()
if (read.value) return <Reading s={read.value} onPlans={onPlans} onBilling={onBilling} />
return (
<SizableText size="$1" color="$soft" px="$2" py="$1.5" data-slot="meter">
{read.error ? 'Your plan could not be read.' : 'Reading your plan…'}
{read.error ? t('Your plan could not be read.') : t('Reading your plan…')}
</SizableText>
)
}
+18 -10
View File
@@ -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
@@ -122,8 +128,9 @@ function Meter({ device, on }: { device: string | null; on: boolean }) {
height={4}
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' }}
// Empty at rest; the level is written per frame onto the node's transform, so no class is minted per value.
scaleX={0}
transformOrigin={dir === 'rtl' ? 'right' : 'left'}
/>
</YStack>
</XStack>
@@ -142,6 +149,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 +174,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 +182,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 +202,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 +219,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 +232,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
View File
@@ -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. */
+10
View File
@@ -35,6 +35,16 @@ describe('the builder’s places, once for every rail', () => {
])
})
it('says each place in the caller’s language, and English with no caller', () => {
const FR: Record<string, string> = { Work: 'Travail', 'New run': 'Nouvelle exécution', Projects: 'Projets', Docs: 'Docs', Agents: 'Agents', Skills: 'Compétences', Upload: 'Importer' }
const t = ((source: string) => FR[source] ?? source) as Parameters<typeof nav>[2]
const work = nav(host(), () => {}, t)[0]!
expect([work.label, work.links.map((l) => l.label)]).toEqual(['Travail', ['Nouvelle exécution', 'Projets', 'Issues', 'Templates']])
expect(own(host(), () => {}, t)[0]!.label).toBe('Travail')
expect(tabs(host(), () => {}, t)[0]!.links.map((l) => l.label)).toEqual(['Agents', 'Compétences', 'Connectors', 'Plugins'])
expect(library(host(), () => {}, t)[0]!.links[0]!.label).toBe('Importer')
})
it('moves to an address the grammar reads back as that place', () => {
// Issues forgets the board it last pinned, in this browser: one with nothing kept.
vi.stubGlobal('window', { dispatchEvent: () => true })
+37 -30
View File
@@ -7,8 +7,13 @@
*
* Every row moves the builder through `go` with the address `path` writes, so
* where a place lives is said here and in route.ts and nowhere else.
*
* The rows' words are the reader's language when the caller hands in its
* @hanzo/i18n `t` (English without one); a task's name is the person's own.
*/
import { AudioLines, Bell, BookOpen, Bot, Box, Brain, CircleDot, Clock, Code, Container, Cpu, CreditCard, File, FileText, FolderGit2, Gauge, Image, Key, LayoutTemplate, Library, Plug, Plus, Presentation, Puzzle, Settings, Shield, SlidersHorizontal, Sparkles, SquarePen, Upload, User, Users, Video } from '@hanzogui/lucide-icons-2'
import { phrase } from '@hanzo/i18n'
import { useI18n, type Say } from '@hanzo/i18n/react'
import type { RailLink } from '@hanzo/ui/chat'
import { useMemo, type ReactNode } from 'react'
@@ -19,6 +24,7 @@ import { useRead } from './data.ts'
import { DOCS } from './foot.tsx'
import type { Host } from './host.tsx'
import { path, route, TABS, type Section, type Tab } from './route.ts'
import { english } from './say.ts'
import { GROUPS, heading, LIST } from './settings/list.ts'
/** A run of places under one quiet label; '' draws none. */
@@ -31,7 +37,7 @@ export interface Group {
/** The id of the row that starts a new run: a rail with a New control of its own draws that instead. */
export const NEW = 'new'
export function nav(host: Host, go: (path: string) => void): Group[] {
export function nav(host: Host, go: (path: string) => void, t: Say = english): Group[] {
const r = route(host.path)
const screen = r.kind === 'screen' ? r.screen : ''
const section = r.kind === 'settings' ? r.section : ''
@@ -39,20 +45,20 @@ export function nav(host: Host, go: (path: string) => void): Group[] {
return [
{
id: 'work',
label: 'Work',
label: t('Work'),
links: [
{ id: NEW, label: 'New run', icon: <SquarePen size={16} />, onPress: to(''), active: r.kind === 'new' },
{ id: NEW, label: t('New run'), icon: <SquarePen size={16} />, onPress: to(''), active: r.kind === 'new' },
// A project is a repository: its list, linking one from GitHub, and one's workspace.
{
id: 'projects',
label: 'Projects',
label: t('Projects'),
icon: <FolderGit2 size={16} />,
onPress: to(path({ kind: 'screen', screen: 'projects' })),
active: screen === 'projects' || screen === 'sync' || r.kind === 'repo',
},
{
id: 'issues',
label: 'Issues',
label: t('Issues'),
icon: <CircleDot size={16} />,
onPress: () => {
pinBoard(host.org, '')
@@ -60,65 +66,65 @@ export function nav(host: Host, go: (path: string) => void): Group[] {
},
active: screen === 'issues',
},
{ id: 'templates', label: 'Templates', icon: <LayoutTemplate size={16} />, onPress: to(path({ kind: 'screen', screen: 'templates' })), active: screen === 'templates' },
{ id: 'templates', label: t('Templates'), icon: <LayoutTemplate size={16} />, onPress: to(path({ kind: 'screen', screen: 'templates' })), active: screen === 'templates' },
],
},
{
id: 'make',
label: 'Make',
label: t('Make'),
links: [
{ id: 'artifacts', label: 'Library', icon: <Library size={16} />, onPress: to(path({ kind: 'screen', screen: 'artifacts' })), active: screen === 'artifacts' },
{ id: 'automations', label: 'Scheduled tasks', icon: <Clock size={16} />, onPress: to(path({ kind: 'screen', screen: 'automations' })), active: screen === 'automations' || r.kind === 'automation' },
{ id: 'artifacts', label: t('Library'), icon: <Library size={16} />, onPress: to(path({ kind: 'screen', screen: 'artifacts' })), active: screen === 'artifacts' },
{ id: 'automations', label: t('Scheduled tasks'), icon: <Clock size={16} />, onPress: to(path({ kind: 'screen', screen: 'automations' })), active: screen === 'automations' || r.kind === 'automation' },
],
},
{
id: 'run',
label: 'Run',
label: t('Run'),
links: [
{ id: 'machines', label: 'Machines', icon: <Cpu size={16} />, onPress: to(path({ kind: 'settings', section: 'machines' })), active: section === 'machines' },
{ id: 'environments', label: 'Environments', icon: <Container size={16} />, onPress: to(path({ kind: 'settings', section: 'environments' })), active: section === 'environments' },
{ id: 'machines', label: t('Machines'), icon: <Cpu size={16} />, onPress: to(path({ kind: 'settings', section: 'machines' })), active: section === 'machines' },
{ id: 'environments', label: t('Environments'), icon: <Container size={16} />, onPress: to(path({ kind: 'settings', section: 'environments' })), active: section === 'environments' },
],
},
{
id: 'setup',
label: 'Setup',
label: t('Setup'),
links: [
{ id: 'customize', label: 'Customize', icon: <SlidersHorizontal size={16} />, onPress: to(path({ kind: 'customize', tab: 'agents' })), active: r.kind === 'customize' || r.kind === 'agent' || screen === 'mcp' },
{ id: 'docs', label: 'Docs', icon: <BookOpen size={16} />, onPress: () => window.open(DOCS, '_blank', 'noopener,noreferrer') },
{ id: 'customize', label: t('Customize'), icon: <SlidersHorizontal size={16} />, onPress: to(path({ kind: 'customize', tab: 'agents' })), active: r.kind === 'customize' || r.kind === 'agent' || screen === 'mcp' },
{ id: 'docs', label: t('Docs'), icon: <BookOpen size={16} />, onPress: () => window.open(DOCS, '_blank', 'noopener,noreferrer') },
],
},
]
}
/** Dev's own places: what a host whose rail holds the rest (Library, Scheduled tasks, Customize) lists in Dev's sidebar. */
export const own = (host: Host, go: (path: string) => void): Group[] => nav(host, go).filter((g) => g.id === 'work')
export const own = (host: Host, go: (path: string) => void, t: Say = english): Group[] => nav(host, go, t).filter((g) => g.id === 'work')
/**
* Settings' sections as a host's sidebar lists them: one group per part, a
* part's label drawn only where it is not its first row's name (Code leads
* Code's part, so the part is not labelled twice).
*/
export function sections(host: Host, go: (path: string) => void): Group[] {
export function sections(host: Host, go: (path: string) => void, t: Say = english): Group[] {
const r = route(host.path)
const at = r.kind === 'settings' ? r.section : ''
return GROUPS.map((g) => {
const rows = LIST.filter((e) => e.group === g)
return {
id: g,
label: heading(g),
links: rows.map((e) => ({ id: e.id, label: e.label, icon: SECTION_ICON[e.id], onPress: () => go(path({ kind: 'settings', section: e.id })), active: e.id === at })),
label: t(heading(g)),
links: rows.map((e) => ({ id: e.id, label: t(e.label), icon: SECTION_ICON[e.id], onPress: () => go(path({ kind: 'settings', section: e.id })), active: e.id === at })),
}
})
}
/** Customize's tabs as a host's sidebar lists them, Agents first; an agent's profile lights Agents. */
export function tabs(host: Host, go: (path: string) => void): Group[] {
export function tabs(host: Host, go: (path: string) => void, t: Say = english): Group[] {
const r = route(host.path)
const at = r.kind === 'customize' ? r.tab : r.kind === 'agent' ? 'agents' : ''
return [{ id: 'customize', label: '', links: TABS.map((t) => ({ id: t, label: TAB_LABEL[t], icon: TAB_ICON[t], onPress: () => go(path({ kind: 'customize', tab: t })), active: t === at })) }]
return [{ id: 'customize', label: '', links: TABS.map((tab) => ({ id: tab, label: t(TAB_LABEL[tab]), icon: TAB_ICON[tab], onPress: () => go(path({ kind: 'customize', tab })), active: tab === at })) }]
}
const TAB_LABEL: Record<Tab, string> = { agents: 'Agents', skills: 'Skills', connectors: 'Connectors', plugins: 'Plugins' }
const TAB_LABEL: Record<Tab, string> = { agents: phrase('Agents'), skills: phrase('Skills'), connectors: phrase('Connectors'), plugins: phrase('Plugins') }
const TAB_ICON: Record<Tab, ReactNode> = { agents: <Bot size={16} />, skills: <BookOpen size={16} />, connectors: <Plug size={16} />, plugins: <Puzzle size={16} /> }
const SECTION_ICON: Record<Section, ReactNode> = {
general: <Settings size={16} />,
@@ -153,7 +159,7 @@ const KIND_ICON: Record<string, ReactNode> = {
export const KEPT = 'hanzo:library:kept'
/** Asks for files and keeps each in the Library; a refusal is said in the page's own words. */
function keep(host: Host): void {
function keep(host: Host, say: Say): void {
const input = document.createElement('input')
input.type = 'file'
input.multiple = true
@@ -163,7 +169,7 @@ function keep(host: Host): void {
try {
await upload(t, { name: f.name, type: f.type, bytes: new Uint8Array(await f.arrayBuffer()) }, { source: 'chat' })
} catch (e) {
window.alert(e instanceof Error ? e.message : `${f.name} was not kept`)
window.alert(e instanceof Error ? e.message : say('{file} was not kept', { file: f.name }))
}
}
window.dispatchEvent(new Event(KEPT))
@@ -172,17 +178,17 @@ function keep(host: Host): void {
}
/** The Library's sidebar: Upload, then every kind it narrows to, the one in the address lit. */
export function library(host: Host, go: (path: string) => void): Group[] {
export function library(host: Host, go: (path: string) => void, t: Say = english): Group[] {
const r = route(host.path)
const at = r.kind === 'screen' && r.screen === 'artifacts' ? (r.of ?? '') : null
return [
{ id: 'library-do', label: '', links: [{ id: 'upload', label: 'Upload', icon: <Upload size={16} />, onPress: () => keep(host) }] },
{ id: 'library-do', label: '', links: [{ id: 'upload', label: t('Upload'), icon: <Upload size={16} />, onPress: () => keep(host, t) }] },
{
id: 'library-kinds',
label: '',
links: KINDS.map((k) => ({
id: `kind-${k.id || 'all'}`,
label: k.label,
label: t(k.label),
icon: KIND_ICON[k.id],
onPress: () => go(path({ kind: 'screen', screen: 'artifacts', ...(k.id ? { of: k.id } : {}) })),
active: at === k.id,
@@ -193,6 +199,7 @@ export function library(host: Host, go: (path: string) => void): Group[] {
/** Scheduled tasks' sidebar: New task, then each of the person's tasks, the one open lit. Read only while `on`. */
export function useScheduled(host: Host, go: (path: string) => void, on: boolean): Group[] {
const { t: say } = useI18n()
const t = useMemo(() => ({ api: host.api, token: host.token, org: host.org }), [host.api, host.token, host.org])
const list = useRead(on && host.person ? () => automations(t) : null, [], [t, on, host.path])
const r = route(host.path)
@@ -201,12 +208,12 @@ export function useScheduled(host: Host, go: (path: string) => void, on: boolean
{
id: 'scheduled-do',
label: '',
links: [{ id: 'new-task', label: 'New task', icon: <Plus size={16} />, onPress: () => go(path({ kind: 'automation', id: 'new' })), active: at === 'new' }],
links: [{ id: 'new-task', label: say('New task'), icon: <Plus size={16} />, onPress: () => go(path({ kind: 'automation', id: 'new' })), active: at === 'new' }],
},
{
id: 'scheduled-tasks',
label: list.value.length ? 'Tasks' : '',
links: list.value.map((a) => ({ id: a.id, label: a.name || 'Untitled task', icon: <Clock size={16} />, onPress: () => go(path({ kind: 'automation', id: a.id })), active: at === a.id })),
label: list.value.length ? say('Tasks') : '',
links: list.value.map((a) => ({ id: a.id, label: a.name || say('Untitled task'), icon: <Clock size={16} />, onPress: () => go(path({ kind: 'automation', id: a.id })), active: at === a.id })),
},
]
}
+78 -59
View File
@@ -22,6 +22,8 @@
* panel is the whole window instead (`sheet`).
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
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,
@@ -73,7 +75,10 @@ const KEYS = MAC ? '⌘⇧B' : 'Ctrl+Shift+B'
const SHORTCUTS = 'Control+Shift+B Meta+Shift+B Control+Period Meta+Period'
/** A tab's title where it has none of its own. */
const BLANK = 'New tab'
const BLANK = phrase('New tab')
/** On an icon that points along the line (Back, Forward): it turns in a right-to-left page. */
const MIRROR = { 'data-i18n-mirror': '' } as object
/** The row heights, px: the strip (the conversation header's row), and a page's bar. */
const STRIP = 56
@@ -98,19 +103,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}
@@ -189,13 +196,14 @@ function Mark({ icon, title }: { icon?: string; title: string }) {
/** An empty page: the places the surface suggests, as cards in a row that scrolls sideways. */
function Suggested({ suggested, onGo }: { suggested: readonly Suggestion[]; onGo: (url: string) => void }) {
const { t } = useI18n()
return (
<YStack data-slot="side-suggested" px="$4" pt="$4" gap="$3">
<SizableText fontSize={13} color="$soft">
Suggested
{t('Suggested')}
</SizableText>
{suggested.length ? (
<XStack role="list" aria-label="Suggested" gap="$2" overflowX="auto" overflowY="hidden" pb="$2">
<XStack role="list" aria-label={t('Suggested')} gap="$2" overflowX="auto" overflowY="hidden" pb="$2">
{suggested.map((s) => (
<YStack
key={s.url}
@@ -223,7 +231,7 @@ function Suggested({ suggested, onGo }: { suggested: readonly Suggestion[]; onGo
</XStack>
) : (
<SizableText fontSize={13} color="$soft">
No suggestions.
{t('No suggestions.')}
</SizableText>
)}
</YStack>
@@ -232,6 +240,7 @@ function Suggested({ suggested, onGo }: { suggested: readonly Suggestion[]; onGo
/** A remote address a browser page may not frame: what it is, and the one way to open it. */
function Elsewhere({ tab }: { tab: Tab }) {
const { t } = useI18n()
return (
<YStack flex={1} items="center" justify="center" gap="$3" p="$5" data-slot="side-elsewhere">
<Globe size={20} opacity={0.6} />
@@ -254,7 +263,7 @@ function Elsewhere({ tab }: { tab: Tab }) {
>
<ExternalLink size={13} />
<SizableText fontSize={13} color="$ink">
Open in a new window
{t('Open in a new window')}
</SizableText>
</XStack>
</YStack>
@@ -330,6 +339,7 @@ function Row({ label, onSelect, disabled, end }: { label: string; onSelect: () =
/** The zoom row: the level between − and +, and the way back to 100. Plain buttons, so a press keeps the menu open. */
function Zoom({ level, onLevel, disabled }: { level: number; onLevel: (percent: number) => void; disabled: boolean }) {
const { t } = useI18n()
const step = (way: 1 | -1) => () => onLevel(zoomed(level, way))
const box = (label: string, onPress: () => void, child: ReactNode) => (
<XStack render="button" role="button" aria-label={label} aria-disabled={disabled || undefined} onPress={disabled ? undefined : onPress} width={24} height={24} {...quiet(false, disabled)} rounded={6}>
@@ -337,18 +347,18 @@ function Zoom({ level, onLevel, disabled }: { level: number; onLevel: (percent:
</XStack>
)
return (
<XStack data-slot="page-zoom" role="group" aria-label="Zoom" items="center" gap="$2" px={8} height={38} opacity={disabled ? 0.5 : 1}>
<XStack data-slot="page-zoom" role="group" aria-label={t('Zoom')} items="center" gap="$2" px={8} height={38} opacity={disabled ? 0.5 : 1}>
<SizableText fontSize={13} color="$ink" flex={1}>
Zoom
{t('Zoom')}
</SizableText>
<XStack items="center" gap={2} borderWidth={1} borderColor="$borderColor" rounded={8} px={2} height={28}>
{box('Zoom out', step(-1), <Minus size={12} />)}
{box(t('Zoom out'), step(-1), <Minus size={12} />)}
<SizableText fontSize={12} color="$ink" width={40} text="center" aria-live="polite">
{`${level}%`}
</SizableText>
{box('Zoom in', step(1), <Plus size={12} />)}
{box(t('Zoom in'), step(1), <Plus size={12} />)}
</XStack>
{box('Reset zoom', () => onLevel(100), <RotateCcw size={13} />)}
{box(t('Reset zoom'), () => onLevel(100), <RotateCcw size={13} />)}
</XStack>
)
}
@@ -358,12 +368,13 @@ function Zoom({ level, onLevel, disabled }: { level: number; onLevel: (percent:
* laid out over the menu and takes its presses.
*/
function Skills({ skills, here }: { skills: readonly Skill[]; here: Here | null }) {
const { t } = useI18n()
const [open, setOpen] = useState(false)
return (
<DropdownMenuSub open={open} onOpenChange={setOpen}>
<DropdownMenuSubTrigger disabled={!here} minH={ROW} py={0}>
<SizableText fontSize={13} color="$ink" flex={1}>
Skills
{t('Skills')}
</SizableText>
</DropdownMenuSubTrigger>
{open ? (
@@ -412,6 +423,7 @@ function Actions({
browser: Browser | null
onPin?: (on: boolean) => void
}) {
const { t } = useI18n()
const shown = here ? { id: tab.id, ...here } : null
const none = !shown
const pick = () => {
@@ -436,32 +448,32 @@ function Actions({
const print = browser?.print ?? (same ? () => frame.current?.contentWindow?.print() : undefined)
const zoom = Boolean(browser?.zoom) || framed
const ours = [
acts.onAsk ? <Row key="ask" label="Ask about this page" disabled={none} onSelect={() => void ask()} /> : null,
acts.onDev && shown && hosted(shown.url) ? <Row key="dev" label="Open in Dev" onSelect={() => acts.onDev?.({ url: shown.url, title: shown.title })} /> : null,
acts.onAsk ? <Row key="ask" label={t('Ask about this page')} disabled={none} onSelect={() => void ask()} /> : null,
acts.onDev && shown && hosted(shown.url) ? <Row key="dev" label={t('Open in Dev')} onSelect={() => acts.onDev?.({ url: shown.url, title: shown.title })} /> : null,
acts.skills?.length ? <Skills key="skills" skills={acts.skills} here={here} /> : null,
<Row key="out" label="Open in a new window" disabled={none} onSelect={() => popOut(tab)} />,
<Row key="copy" label="Copy link" disabled={none || !tab.url} onSelect={() => tab.url && void navigator.clipboard?.writeText(tab.url)} />,
onPin ? <Row key="pin" label={tab.pinned ? 'Unpin tab' : 'Pin tab'} onSelect={() => onPin(!tab.pinned)} /> : null,
<Row key="out" label={t('Open in a new window')} disabled={none} onSelect={() => popOut(tab)} />,
<Row key="copy" label={t('Copy link')} disabled={none || !tab.url} onSelect={() => tab.url && void navigator.clipboard?.writeText(tab.url)} />,
onPin ? <Row key="pin" label={tab.pinned ? t('Unpin tab') : t('Pin tab')} onSelect={() => onPin(!tab.pinned)} /> : null,
].filter(Boolean)
const page = [
browser?.find ? <Row key="find" label="Find in page" disabled={none} onSelect={() => shown && browser.find?.(shown)} /> : null,
print ? <Row key="print" label="Print" disabled={none} onSelect={() => shown && print(shown)} /> : null,
browser?.find ? <Row key="find" label={t('Find in page')} disabled={none} onSelect={() => shown && browser.find?.(shown)} /> : null,
print ? <Row key="print" label={t('Print')} disabled={none} onSelect={() => shown && print(shown)} /> : null,
zoom ? <Zoom key="zoom" level={level} onLevel={onLevel} disabled={none} /> : null,
browser?.devices ? <Row key="devices" label="Show device toolbar" disabled={none} onSelect={() => shown && browser.devices?.(shown)} /> : null,
browser?.capture && acts.onCapture ? <Row key="capture" label="Take a screenshot" disabled={none} onSelect={() => void capture()} /> : null,
browser?.devices ? <Row key="devices" label={t('Show device toolbar')} disabled={none} onSelect={() => shown && browser.devices?.(shown)} /> : null,
browser?.capture && acts.onCapture ? <Row key="capture" label={t('Take a screenshot')} disabled={none} onSelect={() => void capture()} /> : null,
].filter(Boolean)
const own = [
browser?.passwords ? <Row key="passwords" label="Passwords and autofill" onSelect={browser.passwords} end={<ChevronRight size={14} opacity={0.6} />} /> : null,
browser?.downloads ? <Row key="downloads" label="Downloads" onSelect={browser.downloads} /> : null,
browser?.history ? <Row key="history" label="History" onSelect={browser.history} /> : null,
browser?.clear ? <Row key="clear" label="Clear browsing data" onSelect={browser.clear} /> : null,
browser?.passwords ? <Row key="passwords" label={t('Passwords and autofill')} onSelect={browser.passwords} end={<ChevronRight size={14} opacity={0.6} />} /> : null,
browser?.downloads ? <Row key="downloads" label={t('Downloads')} onSelect={browser.downloads} /> : null,
browser?.history ? <Row key="history" label={t('History')} onSelect={browser.history} /> : null,
browser?.clear ? <Row key="clear" label={t('Clear browsing data')} onSelect={browser.clear} /> : null,
].filter(Boolean)
const settings = browser?.settings ? [<Row key="settings" label="Browser settings" onSelect={browser.settings} />] : []
const settings = browser?.settings ? [<Row key="settings" label={t('Browser settings')} onSelect={browser.settings} />] : []
const groups = [ours, page, own, settings].filter((g) => g.length)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<XStack render="button" role="button" aria-label="Page actions" width={PILL} height={PILL} {...quiet()} rounded={999} bg="$edge">
<XStack render="button" role="button" aria-label={t('Page actions')} width={PILL} height={PILL} {...quiet()} rounded={999} bg="$edge">
<MoreHorizontal size={16} />
</XStack>
</DropdownMenuTrigger>
@@ -481,6 +493,7 @@ function Actions({
* remote page takes its own origin, and withholding same-origin breaks it.
*/
export function Page({ tab, onGo, onBack, onForward, onTitle, onPin, suggested = [], ...acts }: PageProps) {
const { t } = useI18n()
const Browse = useBrowse()
const browser = useBrowser()
const frame = useRef<HTMLIFrameElement | null>(null)
@@ -527,13 +540,13 @@ export function Page({ tab, onGo, onBack, onForward, onTitle, onPin, suggested =
<YStack flex={1} minH={0} data-slot="side-page">
<XStack data-slot="side-bar" height={BAR} px="$2" gap="$2" items="center" borderBottomWidth={1} borderColor="$borderColor" shrink={0}>
<XStack items="center" height={PILL} px={4} gap={2} rounded={999} bg="$edge" shrink={0}>
<XStack render="button" role="button" aria-label="Back" aria-disabled={!back || undefined} onPress={back ? onBack : undefined} width={28} height={28} {...quiet(false, !back)} rounded={999}>
<ArrowLeft size={15} />
<XStack render="button" role="button" aria-label={t('Back')} aria-disabled={!back || undefined} onPress={back ? onBack : undefined} width={28} height={28} {...quiet(false, !back)} rounded={999}>
<ArrowLeft size={15} {...MIRROR} />
</XStack>
<XStack render="button" role="button" aria-label="Forward" aria-disabled={!ahead || undefined} onPress={ahead ? onForward : undefined} width={28} height={28} {...quiet(false, !ahead)} rounded={999}>
<ArrowRight size={15} />
<XStack render="button" role="button" aria-label={t('Forward')} aria-disabled={!ahead || undefined} onPress={ahead ? onForward : undefined} width={28} height={28} {...quiet(false, !ahead)} rounded={999}>
<ArrowRight size={15} {...MIRROR} />
</XStack>
<XStack render="button" role="button" aria-label="Reload this tab" onPress={() => again((n) => n + 1)} width={28} height={28} {...quiet()} rounded={999}>
<XStack render="button" role="button" aria-label={t('Reload this tab')} onPress={() => again((n) => n + 1)} width={28} height={28} {...quiet()} rounded={999}>
<RotateCw size={14} />
</XStack>
</XStack>
@@ -554,8 +567,8 @@ export function Page({ tab, onGo, onBack, onForward, onTitle, onPin, suggested =
key={tab.id}
value={own ? tab.title : typed}
readOnly={own}
placeholder="Search or enter a URL"
aria-label="Address"
placeholder={t('Search or enter a URL')}
aria-label={t('Address')}
spellCheck={false}
autoFocus={!own && !tab.url}
onFocus={(e) => {
@@ -576,8 +589,8 @@ export function Page({ tab, onGo, onBack, onForward, onTitle, onPin, suggested =
color: 'var(--foreground)',
font: 'inherit',
fontSize: 14,
// The resting bar reads centred, as a browser's new tab does; typed in, it reads from the left.
textAlign: typing || typed ? 'left' : 'center',
// The resting bar reads centred, as a browser's new tab does; typed in, it reads from the line's start.
textAlign: typing || typed ? 'start' : 'center',
}}
/>
</XStack>
@@ -619,9 +632,9 @@ export function Page({ tab, onGo, onBack, onForward, onTitle, onPin, suggested =
</YStack>
) : (
<YStack flex={1} items="center" justify="center" p="$5" gap="$2">
<SizableText size="$2" color="$soft">{`${(ext || type || 'This').toUpperCase()} previews aren’t supported yet.`}</SizableText>
<SizableText size="$2" color="$soft">{t('{type} previews aren’t supported yet.', { type: (ext || type || 'This').toUpperCase() })}</SizableText>
<SizableText size="$2" color="$soft">
Open it in a new window to view it.
{t('Open it in a new window to view it.')}
</SizableText>
</YStack>
)}
@@ -637,15 +650,16 @@ const DRAG = 'text/x-hanzo-tab'
* lets it go, and closes it, whatever kind it is.
*/
function Leaf({ tab, on, side, index, label, icon }: { tab: Tab; on: boolean; side: Deck; index: number; label: string; icon: ReactNode }) {
const { t } = useI18n()
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<Strip tab={tab} on={on} side={side} index={index} label={label} icon={icon} />
</ContextMenuTrigger>
<ContextMenuContent minW={180} aria-label={`${label} tab`}>
<ContextMenuItem onSelect={() => (tab.pinned ? side.unpin(tab.id) : side.pin(tab.id))}>{tab.pinned ? 'Unpin tab' : 'Pin tab'}</ContextMenuItem>
<ContextMenuContent minW={180} aria-label={t('{tab} tab', { tab: label })}>
<ContextMenuItem onSelect={() => (tab.pinned ? side.unpin(tab.id) : side.pin(tab.id))}>{tab.pinned ? t('Unpin tab') : t('Pin tab')}</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => side.shut(tab.id)}>Close tab</ContextMenuItem>
<ContextMenuItem onSelect={() => side.shut(tab.id)}>{t('Close tab')}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
@@ -653,6 +667,7 @@ function Leaf({ tab, on, side, index, label, icon }: { tab: Tab; on: boolean; si
/** The tab itself, as the strip draws it. */
function Strip({ tab, on, side, index, label, icon, ...rest }: { tab: Tab; on: boolean; side: Deck; index: number; label: string; icon: ReactNode }) {
const { t } = useI18n()
return (
<XStack
{...rest}
@@ -746,7 +761,7 @@ function Strip({ tab, on, side, index, label, icon, ...rest }: { tab: Tab; on: b
<XStack
render="button"
role="button"
aria-label={`Unpin ${label}`}
aria-label={t('Unpin {tab}', { tab: label })}
tabIndex={-1}
onPress={() => side.unpin(tab.id)}
width={22}
@@ -764,7 +779,7 @@ function Strip({ tab, on, side, index, label, icon, ...rest }: { tab: Tab; on: b
<XStack
render="button"
role="button"
aria-label={`Close ${label}`}
aria-label={t('Close {tab}', { tab: label })}
tabIndex={-1}
onPress={() => side.shut(tab.id)}
width={22}
@@ -801,7 +816,8 @@ export interface PanelProps extends Acts {
}
/** The panel: the strip, and the chosen tab. */
export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = 'Side panel', suggested, ...acts }: PanelProps) {
export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label, suggested, ...acts }: PanelProps) {
const { t: say } = useI18n()
const tab = side.tabs.find((t) => t.id === side.at) ?? null
// The column it stands in; a phone's sheet is the whole window already.
const stands = useSide()
@@ -813,27 +829,30 @@ export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = '
const kind = tab && tab.kind !== PAGE ? kinds.find((k) => k.id === tab.kind) : undefined
const bleed = !tab || tab.kind === PAGE || Boolean(kind?.bleed)
const closed = kinds.filter((k) => !side.tabs.some((t) => t.kind === k.id))
const title = (t: Tab) => (t.kind === PAGE ? t.title || BLANK : (kinds.find((k) => k.id === t.kind)?.label ?? t.title))
// A page's title is the page's own; an empty one is a new tab, in the reader's language.
const title = (t: Tab) => (t.kind === PAGE ? (t.title && t.title !== BLANK ? t.title : say(BLANK)) : (kinds.find((k) => k.id === t.kind)?.label ?? t.title))
const mark = (t: Tab) => (t.kind === PAGE ? <Globe size={14} /> : (kinds.find((k) => k.id === t.kind)?.icon ?? null))
const blank = () => side.open({ kind: PAGE, title: BLANK, url: '' })
const poppable = tab?.kind === PAGE && (tab.body !== undefined || Boolean(tab.url))
const plus = (
<XStack render="button" role="button" aria-label="Open a tab" width={28} height={28} {...quiet()} onPress={closed.length ? undefined : blank}>
<XStack render="button" role="button" aria-label={say('Open a tab')} width={28} height={28} {...quiet()} onPress={closed.length ? undefined : blank}>
<Plus size={16} />
</XStack>
)
const body = (
<YStack role="complementary" aria-label={label} data-slot="side" flex={1} minW={0} minH={0} overflow="hidden" style={{ borderRadius: 'inherit' }}>
<YStack role="complementary" aria-label={label ?? say('Side panel')} data-slot="side" flex={1} minW={0} minH={0} overflow="hidden" rounded="inherit">
<XStack data-slot="side-strip" height={STRIP} px="$2" gap="$1.5" items="center" borderBottomWidth={1} borderColor="$borderColor" shrink={0}>
{column?.hidden && !column.full ? (
<XStack render="button" role="button" data-slot="side-show" onPress={column.show} height={32} px="$2" gap="$1.5" {...quiet()}>
<PanelLeftOpen size={16} />
<SizableText fontSize={13} color="$ink">{`Show ${column.main}`}</SizableText>
<SizableText fontSize={13} color="$ink">
{column.main === 'run' ? say('Show run') : column.main === 'chat' ? say('Show chat') : say('Show {main}', { main: column.main })}
</SizableText>
</XStack>
) : null}
{/* The strip scrolls sideways in a narrow panel; `+`, the menu, pop-out and hide stay put. */}
<XStack role="tablist" aria-label="Tabs" shrink={1} minW={0} gap="$1" items="center" overflowX="auto" overflowY="hidden" $platform-web={{ scrollbarWidth: 'none' }}>
<XStack role="tablist" aria-label={say('Tabs')} shrink={1} minW={0} gap="$1" items="center" overflowX="auto" overflowY="hidden" $platform-web={{ scrollbarWidth: 'none' }}>
{side.tabs.map((t, i) => (
<Leaf key={t.id} tab={t} on={t.id === side.at} side={side} index={i} label={title(t)} icon={mark(t)} />
))}
@@ -843,7 +862,7 @@ export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = '
trigger={plus}
items={[
...closed.map((k) => ({ key: k.id, label: k.label, onSelect: () => side.open({ kind: k.id, title: k.label }) })),
{ key: PAGE, label: 'Page', description: 'An address, framed here', onSelect: blank },
{ key: PAGE, label: say('Page'), description: say('An address, framed here'), onSelect: blank },
]}
/>
) : (
@@ -854,7 +873,7 @@ export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = '
<XStack
render="button"
role="button"
aria-label="Open this tab in a new window"
aria-label={say('Open this tab in a new window')}
aria-disabled={!poppable || undefined}
onPress={poppable ? () => popOut(tab) : undefined}
width={28}
@@ -868,10 +887,10 @@ export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = '
render="button"
role="button"
data-slot="side-full"
aria-label={column.full ? 'Exit full screen' : 'Full screen'}
aria-label={column.full ? say('Exit full screen') : say('Full screen')}
aria-pressed={column.full}
aria-keyshortcuts={column.full ? 'Escape' : undefined}
{...({ title: column.full ? 'Exit full screen (Esc)' : 'Full screen' } as object)}
{...({ title: column.full ? say('Exit full screen ({key})', { key: 'Esc' }) : say('Full screen') } as object)}
onPress={() => column.fill(!column.full)}
width={28}
height={28}
@@ -884,10 +903,10 @@ export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = '
render="button"
role="button"
data-slot="side-toggle"
aria-label="Hide tabs"
aria-label={say('Hide tabs')}
aria-expanded
aria-keyshortcuts={SHORTCUTS}
{...({ title: `Hide tabs (${KEYS})` } as object)}
{...({ title: say('Hide tabs ({keys})', { keys: KEYS }) } as object)}
onPress={onHide}
width={32}
height={32}
@@ -913,7 +932,7 @@ export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = '
{!tab ? (
<YStack flex={1} items="center" justify="center" p="$5">
<SizableText fontSize={13} color="$soft">
No tabs open.
{say('No tabs open.')}
</SizableText>
</YStack>
) : tab.kind === PAGE ? (
@@ -932,7 +951,7 @@ export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = '
kind.render()
) : (
<SizableText size="$2" color="$soft">
This tab is not offered here.
{say('This tab is not offered here.')}
</SizableText>
)}
</YStack>
+28 -10
View File
@@ -14,7 +14,15 @@
* THREE KINDS OF ACCOUNT, three meters: on a PLAN its windows are the meter, on
* FREE the free allowance left today is, and with CREDITS and no plan (pay as
* you go) the balance is. A plan's meter never shows money.
*
* WORDS in English: a family's name is a name, the rest is marked (`phrase`)
* or takes the caller's @hanzo/i18n `t`, so a surface says it in the reader's
* language.
*/
import { phrase } from '@hanzo/i18n'
import type { Say } from '@hanzo/i18n/react'
import { english } from './say.ts'
export interface Label {
/** The family: `Max`. */
@@ -25,7 +33,7 @@ export interface Label {
/** The families, by the slug's first word. `dev` is the slug Pro is sold under. */
const FAMILY: Record<string, string> = {
free: 'Free',
free: phrase('Free'),
go: 'Go',
dev: 'Pro',
pro: 'Pro',
@@ -60,13 +68,13 @@ export function label(slug?: string | null, tier?: string | null): Label | null
}
if (!raw) return fallback()
// A rung that is an id still IS a rung: the account is on a plan, so it reads as one.
if (ID.test(raw)) return fallback() ?? { name: 'Paid plan', tag: '' }
if (ID.test(raw)) return fallback() ?? { name: phrase('Paid plan'), tag: '' }
const s = raw.replace(/[\s_]+/g, '-')
const [head, ...rest] = s.split('-').filter(Boolean)
const family = FAMILY[head]
if (!family) return { name: s.split('-').filter(Boolean).map(word).join(' '), tag: '' }
const tail = rest.join('-')
const tag = /^\d+x$/.test(tail) ? tail : /^(annual|yearly|year)$/.test(tail) ? 'Annual' : tail ? rest.map(word).join(' ') : ''
const tag = /^\d+x$/.test(tail) ? tail : /^(annual|yearly|year)$/.test(tail) ? phrase('Annual') : tail ? rest.map(word).join(' ') : ''
return { name: family, tag }
}
@@ -136,16 +144,26 @@ export function when(iso: string, now: number = Date.now()): string {
/** A class of model as a sentence names it. */
const CLASS: Record<string, string> = { premium: 'premium', ours: 'Hanzo' }
/** What was used, by the classes that used it, as one phrase each. */
const USED: Record<string, string> = {
'': phrase('usage'),
premium: phrase('premium model usage'),
Hanzo: phrase('Hanzo model usage'),
'premium and Hanzo': phrase('premium and Hanzo model usage'),
}
/**
* What a reader whose plan's included usage is used is told: the plan, which
* models, and when they come back — `Your Max 20x plan’s included premium model
* usage is used until 5:00 PM.`
* usage is used until 5:00 PM.` In the language of `t`.
*/
export function spent(l: Label | null, classes: readonly string[], resets: string, now: number = Date.now()): string {
const names = classes.map((c) => CLASS[c]).filter(Boolean)
const what = names.length ? `${names.join(' and ')} model usage` : 'usage'
export function spent(l: Label | null, classes: readonly string[], resets: string, now: number = Date.now(), t: Say = english): string {
const names = classes.map((c) => CLASS[c]).filter(Boolean).join(' and ')
const what = USED[names] !== undefined ? t(USED[names]) : `${names} model usage`
const at = when(resets, now)
return `Your ${l ? `${said(l)} plan’s` : 'plan’s'} included ${what} is used${at ? ` until ${at}` : ''}.`
if (!l) return at ? t('Your plan’s included {what} is used until {time}.', { what, time: at }) : t('Your plan’s included {what} is used.', { what })
const plan = said({ name: t(l.name), tag: l.tag ? t(l.tag) : '' })
return at ? t('Your {plan} plan’s included {what} is used until {time}.', { plan, what, time: at }) : t('Your {plan} plan’s included {what} is used.', { plan, what })
}
/**
@@ -154,10 +172,10 @@ export function spent(l: Label | null, classes: readonly string[], resets: strin
* upgrade; and a switch to another model where the server offers one. Taken
* from what the server sent, so nothing is offered that it would refuse.
*/
export function ways<A extends { kind: string; label: string }>(actions: readonly A[]): A[] {
export function ways<A extends { kind: string; label: string }>(actions: readonly A[], t: Say = english): A[] {
const pick = (kind: string) => actions.find((a) => a.kind === kind)
const topup = pick('topup')
return [pick('credits') ?? (topup ? { ...topup, label: 'Add credits' } : undefined), pick('upgrade'), pick('switch')].filter(
return [pick('credits') ?? (topup ? { ...topup, label: t('Add credits') } : undefined), pick('upgrade'), pick('switch')].filter(
(a): a is A => a !== undefined,
)
}
+65 -46
View File
@@ -6,8 +6,12 @@
* price; the platform sells one paid plan at a time, so a move between paid
* plans is the current one ending first. Going back to Free is ending the paid
* plan at the close of the period already paid for.
*
* Its words are the reader's language (@hanzo/i18n `t`); a plan's name,
* description, features and price are the catalog's, drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import { Check, ChevronLeft } from '@hanzogui/lucide-icons-2'
import { Button, Dialog, DialogContent, DialogTitle } from '@hanzo/ui'
import { useState } from 'react'
@@ -29,11 +33,12 @@ function price(p: Plan, term: Term): { month: number; now: number } {
export function Plans() {
const host = useHost()
const t = useTarget()
const target = useTarget()
const { t } = useI18n()
const signed = Boolean(host.person)
const list = useRead(() => plans(t), [] as Plan[], [t])
const subs = useRead(signed ? () => subscriptions(t) : null, [] as Subscription[], [t, signed])
const cards = useRead(signed ? () => methods(t) : null, [] as Method[], [t, signed])
const list = useRead(() => plans(target), [] as Plan[], [target])
const subs = useRead(signed ? () => subscriptions(target) : null, [] as Subscription[], [target, signed])
const cards = useRead(signed ? () => methods(target) : null, [] as Method[], [target, signed])
const [term, setTerm] = useState<Term>('month')
const [buying, setBuying] = useState<Plan | null>(null)
const [leaving, setLeaving] = useState(false)
@@ -42,7 +47,7 @@ export function Plans() {
const [note, setNote] = useState('')
const on = current(subs.value)
const who = host.org ?? 'This organization'
const org = host.org
const card = chosen(cards.value)
const yearly = list.value.some((p) => p.yearly > 0)
const free = (p: Plan) => p.monthly === 0 && !p.sales
@@ -70,35 +75,35 @@ export function Plans() {
if (mine(p)) {
return (
<Button size="sm" variant="outline" disabled>
Current plan
{t('Current plan')}
</Button>
)
}
if (p.sales) return <Line>Priced with our team for your organization.</Line>
if (!signed) return <Line>Sign in to choose a plan.</Line>
if (!host.admin) return <Line>An org admin changes the plan.</Line>
if (p.sales) return <Line>{t('Priced with our team for your organization.')}</Line>
if (!signed) return <Line>{t('Sign in to choose a plan.')}</Line>
if (!host.admin) return <Line>{t('An org admin changes the plan.')}</Line>
if (!settled) return null
if (free(p)) {
// A free plan that is not the org's means a paid one is.
const paid = on!
if (paid.ending) return <Line>{`You move to ${p.name} when ${paid.name} ends on ${day(paid.ends)}.`}</Line>
if (paid.ending) return <Line>{t('You move to {plan} when {current} ends on {date}.', { plan: p.name, current: paid.name, date: day(paid.ends) })}</Line>
return (
<Button size="sm" variant="outline" onPress={() => setLeaving(true)}>
{`Switch to ${p.name}`}
{t('Switch to {plan}', { plan: p.name })}
</Button>
)
}
if (on && on.price > 0) return <Line>{`One paid plan at a time: ${on.name} ends before ${p.name} begins.`}</Line>
if (on && on.price > 0) return <Line>{t('One paid plan at a time: {current} ends before {plan} begins.', { current: on.name, plan: p.name })}</Line>
if (!card) {
return (
<Button size="sm" variant="outline" onPress={() => setAdding(true)}>
Add a card to upgrade
{t('Add a card to upgrade')}
</Button>
)
}
return (
<Button size="sm" variant={p.popular ? 'primary' : 'outline'} onPress={() => setBuying(p)}>
{`Upgrade to ${p.name}`}
{t('Upgrade to {plan}', { plan: p.name })}
</Button>
)
}
@@ -108,39 +113,43 @@ export function Plans() {
<YStack width="100%" maxW={1100} mx="auto" px="$5" py="$5" gap="$5">
<XStack
render="button"
aria-label="Back to billing"
aria-label={t('Back to billing')}
items="center"
gap="$1"
self="flex-start"
onPress={() => host.go(path({ kind: 'settings', section: 'billing' }))}
>
<ChevronLeft size={16} />
<ChevronLeft size={16} {...{ 'data-i18n-mirror': '' }} />
<SizableText size="$2" color="$soft">
Billing
{t('Billing')}
</SizableText>
</XStack>
<XStack items="flex-end" gap="$4" flexWrap="wrap">
<YStack flex={1} minW={240} gap="$1">
<SizableText render="h1" size="$7" color="$ink">
Plans that grow with you
{t('Plans that grow with you')}
</SizableText>
<SizableText size="$2" color="$soft">
{!signed
? 'Sign in to see the plan you are on.'
? t('Sign in to see the plan you are on.')
: on
? `${who} is on ${on.name}.`
: `${who} is on the free plan.`}
? org
? t('{org} is on {plan}.', { org, plan: on.name })
: t('This organization is on {plan}.', { plan: on.name })
: org
? t('{org} is on the free plan.', { org })
: t('This organization is on the free plan.')}
</SizableText>
</YStack>
{yearly ? (
<XStack role="radiogroup" aria-label="Billing period" p="$1" gap="$1" rounded="$10" borderWidth={1} borderColor="$borderColor">
<XStack role="radiogroup" aria-label={t('Billing period')} p="$1" gap="$1" rounded="$10" borderWidth={1} borderColor="$borderColor">
{(['month', 'year'] as const).map((k) => (
<XStack
key={k}
render="button"
role="radio"
aria-checked={term === k}
aria-label={k === 'month' ? 'Monthly' : 'Yearly'}
aria-label={k === 'month' ? t('Monthly') : t('Yearly')}
onPress={() => setTerm(k)}
px="$3"
py="$1.5"
@@ -148,7 +157,7 @@ export function Plans() {
bg={term === k ? '$hover' : 'transparent'}
>
<SizableText size="$2" color={term === k ? '$ink' : '$soft'}>
{k === 'month' ? 'Monthly' : 'Yearly'}
{k === 'month' ? t('Monthly') : t('Yearly')}
</SizableText>
</XStack>
))}
@@ -159,9 +168,9 @@ export function Plans() {
{list.error ? (
<Line>{list.error.message}</Line>
) : list.loading && !list.value.length ? (
<Line>Reading plans…</Line>
<Line>{t('Reading plans…')}</Line>
) : list.value.length === 0 ? (
<Line>This brand sells no plans.</Line>
<Line>{t('This brand sells no plans.')}</Line>
) : (
<XStack flexWrap="wrap" gap="$4">
{list.value.map((p) => {
@@ -169,7 +178,7 @@ export function Plans() {
return (
<YStack
key={p.id}
aria-label={`${p.name} plan`}
aria-label={t('{plan} plan', { plan: p.name })}
flex={1}
minW={240}
maxW={360}
@@ -187,11 +196,11 @@ export function Plans() {
</SizableText>
{mine(p) ? (
<SizableText size="$1" color="$soft">
Current
{t('Current')}
</SizableText>
) : p.popular ? (
<SizableText size="$1" color="$soft">
Popular
{t('Popular')}
</SizableText>
) : null}
</XStack>
@@ -202,14 +211,20 @@ export function Plans() {
) : null}
<YStack gap="$0.5">
<SizableText size="$7" color="$ink">
{p.sales ? 'Custom' : cost.month === 0 ? 'Free' : money(cost.month, p.currency)}
{p.sales ? t('Custom') : cost.month === 0 ? t('Free') : money(cost.month, p.currency)}
</SizableText>
<SizableText size="$1" color="$soft">
{p.sales
? 'Talk to us'
? t('Talk to us')
: cost.month === 0
? 'Forever'
: `${p.perSeat ? 'per seat, ' : ''}a month${term === 'year' && p.yearly ? `, ${money(cost.now, p.currency)} billed yearly` : ''}`}
? t('Forever')
: term === 'year' && p.yearly
? p.perSeat
? t('per seat, a month, {price} billed yearly', { price: money(cost.now, p.currency) })
: t('a month, {price} billed yearly', { price: money(cost.now, p.currency) })
: p.perSeat
? t('per seat, a month')
: t('a month')}
</SizableText>
</YStack>
<XStack>{move(p)}</XStack>
@@ -241,27 +256,29 @@ export function Plans() {
<Dialog open={Boolean(buying)} onOpenChange={(o) => !o && setBuying(null)}>
<DialogContent maxW={420} showCloseButton={false}>
<DialogTitle>{buying ? `Upgrade to ${buying.name}` : 'Upgrade'}</DialogTitle>
<DialogTitle>{buying ? t('Upgrade to {plan}', { plan: buying.name }) : t('Upgrade')}</DialogTitle>
{buying && card ? (
<YStack gap="$3">
<SizableText size="$2" color="$soft">
{`${label(card)} is charged ${money(price(buying, term).now, buying.currency)} now, and again every ${term} until you cancel.`}
{term === 'year'
? t('{card} is charged {amount} now, and again every year until you cancel.', { card: label(card), amount: money(price(buying, term).now, buying.currency) })
: t('{card} is charged {amount} now, and again every month until you cancel.', { card: label(card), amount: money(price(buying, term).now, buying.currency) })}
</SizableText>
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="ghost" onPress={() => setBuying(null)}>
Not now
{t('Not now')}
</Button>
<Button
size="sm"
disabled={working}
onPress={() =>
void act(async () => {
await subscribe(t, { plan: buying.id, interval: yearly && buying.yearly ? term : 'month', method: card.id })
return `${who} is on ${buying.name}`
await subscribe(target, { plan: buying.id, interval: yearly && buying.yearly ? term : 'month', method: card.id })
return org ? t('{org} is on {plan}', { org, plan: buying.name }) : t('This organization is on {plan}', { plan: buying.name })
})
}
>
Upgrade
{t('Upgrade')}
</Button>
</XStack>
</YStack>
@@ -271,15 +288,17 @@ export function Plans() {
<Dialog open={leaving} onOpenChange={setLeaving}>
<DialogContent maxW={420} showCloseButton={false}>
<DialogTitle>Switch to the free plan?</DialogTitle>
<DialogTitle>{t('Switch to the free plan?')}</DialogTitle>
{on ? (
<YStack gap="$3">
<SizableText size="$2" color="$soft">
{`${on.name} stays on until ${day(on.ends) || 'the end of this period'}, and nothing is charged after that.`}
{day(on.ends)
? t('{plan} stays on until {date}, and nothing is charged after that.', { plan: on.name, date: day(on.ends) })
: t('{plan} stays on until the end of this period, and nothing is charged after that.', { plan: on.name })}
</SizableText>
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="ghost" onPress={() => setLeaving(false)}>
{`Keep ${on.name}`}
{t('Keep {plan}', { plan: on.name })}
</Button>
<Button
size="sm"
@@ -287,12 +306,12 @@ export function Plans() {
disabled={working}
onPress={() =>
void act(async () => {
await cancel(t, on.id)
return `${on.name} ends on ${day(on.ends)}`
await cancel(target, on.id)
return t('{plan} ends on {date}', { plan: on.name, date: day(on.ends) })
})
}
>
Switch
{t('Switch')}
</Button>
</XStack>
</YStack>
@@ -305,7 +324,7 @@ export function Plans() {
onOpenChange={setAdding}
onSaved={(m) => {
cards.reload()
setNote(`${label(m)} is saved`)
setNote(t('{card} is saved', { card: label(m) }))
}}
/>
</YStack>
+3 -2
View File
@@ -7,6 +7,7 @@
* everything made after it follows it. From `+` › Design system it attaches to
* any later conversation, in Chat or in Dev.
*/
import { phrase } from '@hanzo/i18n'
import { Palette } from '@hanzogui/lucide-icons-2'
import { call, type Target } from '../api/call.ts'
@@ -73,8 +74,8 @@ export async function make(turn: Turn, t: Target, signal: AbortSignal): Promise<
export const designer: Plugin = {
id: 'design',
name: 'Design system',
note: 'Make one from a brief or a site',
name: phrase('Design system'),
note: phrase('Make one from a brief or a site'),
icon: <Palette size={16} />,
prepare: (turn, c, signal) => make(turn, c.target, signal),
}
+12 -9
View File
@@ -15,6 +15,7 @@
* off, and `prep` asks it when that is on. Every other plugin that prepares is on
* while its id is in `Gear.plugins`; one that opens something runs when picked.
*/
import type { Say } from '@hanzo/i18n/react'
import { useEffect, useState } from 'react'
import type { Target } from '../api/call.ts'
@@ -26,6 +27,7 @@ import type { Gear, GearState } from '../gear.ts'
import type { Composer, Plugin, Prep, Turn } from '../plugin.ts'
import type { Shelf } from '../plus.tsx'
import { path } from '../route.ts'
import { english } from '../say.ts'
import { pdf } from './pdf.tsx'
import { project } from './project.tsx'
import { designer } from './design.tsx'
@@ -91,10 +93,10 @@ export function usePlugins(t: Target): Plugins {
/** Whether a plugin that prepares is on in this conversation. */
const on = (p: Plugin, g: Gear): boolean => (p.id === deep.id ? g.research : g.plugins.includes(p.id))
/** The `+` menu's Plugins shelf. */
export function shelf(p: Plugins, g: GearState, c: Composer): Shelf {
/** The `+` menu's Plugins shelf, in the words of the caller's `t` (a plugin's name and note are marked where it is written). */
export function shelf(p: Plugins, g: GearState, c: Composer, t: Say = english): Shelf {
return {
rows: p.plugins.map((x) => ({ id: x.id, label: x.name, note: x.note, icon: x.icon, on: x.prepare ? on(x, g.gear) : undefined })),
rows: p.plugins.map((x) => ({ id: x.id, label: t(x.name), note: t(x.note), icon: x.icon, on: x.prepare ? on(x, g.gear) : undefined })),
pick: (id) => {
const x = p.plugins.find((y) => y.id === id)
if (!x) return
@@ -104,9 +106,9 @@ export function shelf(p: Plugins, g: GearState, c: Composer): Shelf {
},
loading: p.loading,
error: p.error,
search: 'Type to search plugins',
empty: 'No plugins here',
create: { label: 'Create plugin', onPress: () => c.go(BUILD) },
search: t('Type to search plugins'),
empty: t('No plugins here'),
create: { label: t('Create plugin'), onPress: () => c.go(BUILD) },
}
}
@@ -132,16 +134,17 @@ export function merge(preps: readonly Prep[]): Prep {
/**
* Ask every plugin that is on what the message carries. A plugin that fails
* fails the message, with its name and its reason: a report asked for and not
* researched must not arrive looking researched.
* researched must not arrive looking researched. Its name is said in the
* caller's `t`; the reason is `say`'s.
*/
export async function prep(plugins: readonly Plugin[], g: Gear, turn: Turn, c: Composer, signal: AbortSignal, denied: readonly string[] = []): Promise<Prep> {
export async function prep(plugins: readonly Plugin[], g: Gear, turn: Turn, c: Composer, signal: AbortSignal, denied: readonly string[] = [], t: Say = english): Promise<Prep> {
const preps = await Promise.all(
active(plugins, g)
.filter((p) => !denied.includes(p.id))
.map((p) =>
p.prepare!(turn, c, signal).catch((e: unknown) => {
if (signal.aborted) throw e
throw new Error(`${p.name}: ${say(e)}`)
throw new Error(t('{plugin}: {reason}', { plugin: t(p.name), reason: say(e) }))
}),
),
)
+2 -1
View File
@@ -6,6 +6,7 @@
* `document` template writes the ask up and renders it as a PDF, and the answer
* is the file.
*/
import { phrase } from '@hanzo/i18n'
import { FileText } from '@hanzogui/lucide-icons-2'
import type { Target } from '../api/call.ts'
@@ -81,7 +82,7 @@ export function pdf(makes: boolean): Plugin {
return {
id: 'pdf',
name: 'PDF',
note: makes ? 'Read, create and verify PDFs' : 'Read and verify PDFs',
note: makes ? phrase('Read, create and verify PDFs') : phrase('Read and verify PDFs'),
icon: <FileText size={16} />,
prepare: (turn, c, signal) => (turn.files.some(isPdf) || !makes ? study(turn.files) : create(turn, c.target, signal)),
}
+24
View File
@@ -4,6 +4,8 @@
* the research plan and its sources, a template's skill, a deck's answer, a
* PDF's place in the system turn — with the platform's wire as sent.
*/
import { translate } from '@hanzo/i18n'
import type { Say } from '@hanzo/i18n/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Target } from '../api/call.ts'
@@ -141,6 +143,28 @@ describe('the shelf', () => {
const broken = [{ ...list[0]!, prepare: async () => Promise.reject(new Error('the web search did not answer: down')) }]
await expect(prep(broken, g, turn('hi'), c, new AbortController().signal)).rejects.toThrow('Deep research: the web search did not answer: down')
})
it('says its words, and a failed plugin’s name, in the caller’s language; the reason as said', async () => {
const fr: Record<string, string> = {
Sketch: 'Croquis',
Draw: 'Dessiner',
'Deep research': 'Recherche approfondie',
'Type to search plugins': 'Tapez pour chercher des plugins',
'No plugins here': 'Aucun plugin ici',
'Create plugin': 'Créer un plugin',
'{plugin}: {reason}': '{plugin} : {reason}',
}
const t = ((source: string, values?: Record<string, string | number>) => translate(fr, source, values)) as Say
const s = shelf({ plugins, loading: false, error: null }, gears(), composer(target(admin)), t)
expect(s.rows.slice(0, 2).map((r) => [r.label, r.note])).toEqual([
['Croquis', 'Dessiner'],
['Recherche approfondie', 'Report'],
])
expect([s.search, s.empty, s.create!.label]).toEqual(['Tapez pour chercher des plugins', 'Aucun plugin ici', 'Créer un plugin'])
const broken = [{ ...plugins[1]!, prepare: async () => Promise.reject(new Error('the web search did not answer: down')) }]
const g = { ...BARE, research: true }
await expect(prep(broken, g, turn('hi'), composer(target(admin)), new AbortController().signal, [], t)).rejects.toThrow('Recherche approfondie : the web search did not answer: down')
})
})
describe('deep research', () => {
+15 -12
View File
@@ -6,6 +6,8 @@
* written, the project's workspace opens to start one there.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { Folder } from '@hanzogui/lucide-icons-2'
import { Button, Dialog, DialogContent, DialogDescription, DialogTitle, Input } from '@hanzo/ui'
import { useEffect, useState } from 'react'
@@ -25,6 +27,7 @@ export function find(list: readonly Project[], q: string): Project[] {
}
function Picker({ c }: { c: Composer }) {
const { t } = useI18n()
const [list, setList] = useState<Project[] | null>(null)
const [q, setQ] = useState('')
const [note, setNote] = useState('')
@@ -65,25 +68,25 @@ function Picker({ c }: { c: Composer }) {
return (
<Dialog open onOpenChange={(o) => !o && c.show(null)}>
<DialogContent maxW={520} width="100%">
<DialogTitle>Work in a project</DialogTitle>
<DialogDescription>{ask ? 'Your message starts a Dev run on the project you pick.' : 'Open a project to start working in it.'}</DialogDescription>
<Input value={q} onChangeText={setQ} placeholder="Search projects" aria-label="Search projects" autoFocus />
<DialogTitle>{t('Work in a project')}</DialogTitle>
<DialogDescription>{ask ? t('Your message starts a Dev run on the project you pick.') : t('Open a project to start working in it.')}</DialogDescription>
<Input value={q} onChangeText={setQ} placeholder={t('Search projects')} aria-label={t('Search projects')} autoFocus />
<YStack borderWidth={1} borderColor="$borderColor" rounded="$3" overflow="hidden" maxH={360} style={{ overflowY: 'auto' }}>
{!list && !note ? (
<SizableText size="$2" color="$soft" p="$3">
Reading your projects…
{t('Reading your projects…')}
</SizableText>
) : null}
{list && !shown.length ? (
<SizableText size="$2" color="$soft" p="$3">
{q ? 'Nothing matches' : 'No projects yet'}
{q ? t('Nothing matches') : t('No projects yet')}
</SizableText>
) : null}
{shown.map((p, i) => (
<XStack
key={p.slug}
render="button"
aria-label={ask ? `Start a run on ${p.name}` : `Open ${p.name}`}
aria-label={ask ? t('Start a run on {name}', { name: p.name }) : t('Open {name}', { name: p.name })}
disabled={Boolean(busy)}
onPress={() => void pick(p)}
items="center"
@@ -94,15 +97,15 @@ function Picker({ c }: { c: Composer }) {
borderColor="$borderColor"
cursor={busy ? 'default' : 'pointer'}
hoverStyle={{ bg: '$hover' }}
style={{ textAlign: 'left' }}
$platform-web={{ textAlign: 'start' }}
>
<Folder size={16} opacity={0.7} />
<YStack flex={1} minW={0}>
<SizableText size="$2" color="$ink" numberOfLines={1}>
{busy === p.slug ? 'Starting…' : p.name}
{busy === p.slug ? t('Starting…') : p.name}
</SizableText>
<SizableText size="$1" color="$soft" numberOfLines={1}>
{p.repo ? address(p.repo) : 'No repository yet'}
{p.repo ? address(p.repo) : t('No repository yet')}
</SizableText>
</YStack>
</XStack>
@@ -115,7 +118,7 @@ function Picker({ c }: { c: Composer }) {
) : null}
<XStack justify="flex-end">
<Button size="sm" variant="ghost" onPress={() => c.show(null)}>
Cancel
{t('Cancel')}
</Button>
</XStack>
</DialogContent>
@@ -125,8 +128,8 @@ function Picker({ c }: { c: Composer }) {
export const project: Plugin = {
id: 'project',
name: 'Work in a project',
note: 'Start a Dev run in a project',
name: phrase('Work in a project'),
note: phrase('Start a Dev run in a project'),
icon: <Folder size={16} />,
open: (c) => c.show(<Picker c={c} />),
}
+3 -2
View File
@@ -12,6 +12,7 @@
* POST /v1/websearch {q} → {results: [{url, title, content}]}
* POST /v1/crawl {url} → {success, data: {markdown}}; a page it could not read is success:false
*/
import { phrase } from '@hanzo/i18n'
import { Telescope } from '@hanzogui/lucide-icons-2'
import { call, type Target } from '../api/call.ts'
@@ -155,8 +156,8 @@ export async function research(turn: Turn, t: Target, signal: AbortSignal, now =
export const deep: Plugin = {
id: 'research',
name: 'Deep research',
note: 'Get a detailed, cited report',
name: phrase('Deep research'),
note: phrase('Get a detailed, cited report'),
icon: <Telescope size={16} />,
prepare: (turn, c, signal) => research(turn, c.target, signal),
}
+12 -9
View File
@@ -3,6 +3,8 @@
* paperclip attaches a file. Nothing leaves the page until the message goes.
*/
import { XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { Signature } from '@hanzogui/lucide-icons-2'
import { Button, Dialog, DialogContent, DialogDescription, DialogTitle } from '@hanzo/ui'
import { useEffect, useRef, useState, type PointerEvent } from 'react'
@@ -37,6 +39,7 @@ export function paint(g: CanvasRenderingContext2D, strokes: readonly Stroke[]):
}
function Pad({ c }: { c: Composer }) {
const { t } = useI18n()
const canvas = useRef<HTMLCanvasElement>(null)
const strokes = useRef<Stroke[]>([])
const drawing = useRef(false)
@@ -93,14 +96,14 @@ function Pad({ c }: { c: Composer }) {
return (
<Dialog open onOpenChange={(o) => !o && c.show(null)}>
<DialogContent maxW={720} width="100%">
<DialogTitle>Sketch</DialogTitle>
<DialogDescription>Draw, then attach it to your message.</DialogDescription>
<DialogTitle>{t('Sketch')}</DialogTitle>
<DialogDescription>{t('Draw, then attach it to your message.')}</DialogDescription>
<YStack borderWidth={1} borderColor="$borderColor" rounded={8} overflow="hidden">
<canvas
ref={canvas}
width={W}
height={H}
aria-label="Sketch pad"
aria-label={t('Sketch pad')}
data-slot="sketch-pad"
onPointerDown={down}
onPointerMove={move}
@@ -111,17 +114,17 @@ function Pad({ c }: { c: Composer }) {
</YStack>
<XStack gap="$2" items="center">
<Button size="sm" variant="ghost" disabled={!count} onPress={undo}>
Undo
{t('Undo')}
</Button>
<Button size="sm" variant="ghost" disabled={!count} onPress={clear}>
Clear
{t('Clear')}
</Button>
<XStack flex={1} />
<Button size="sm" variant="ghost" onPress={() => c.show(null)}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" disabled={!count || busy} onPress={attach}>
Attach
{t('Attach')}
</Button>
</XStack>
</DialogContent>
@@ -131,8 +134,8 @@ function Pad({ c }: { c: Composer }) {
export const sketch: Plugin = {
id: 'sketch',
name: 'Sketch',
note: 'Draw and attach an image',
name: phrase('Sketch'),
note: phrase('Draw and attach an image'),
icon: <Signature size={16} />,
open: (c) => c.show(<Pad c={c} />),
}
+3 -2
View File
@@ -4,6 +4,7 @@
* the file, and the run in Studio, where it is edited. A later message in the
* same conversation revises the last deck rather than starting over.
*/
import { phrase } from '@hanzo/i18n'
import { Presentation } from '@hanzogui/lucide-icons-2'
import { json } from '../api/chat.ts'
@@ -53,8 +54,8 @@ export async function deck(turn: Turn, t: Target, signal: AbortSignal): Promise<
export const slides: Plugin = {
id: 'slides',
name: 'Presentations',
note: 'Create and edit decks',
name: phrase('Presentations'),
note: phrase('Create and edit decks'),
icon: <Presentation size={16} />,
prepare: (turn, c, signal) => deck(turn, c.target, signal),
}
+13 -10
View File
@@ -6,6 +6,8 @@
* it is saved. Writing a skill takes an org admin, so only an admin is offered it.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { LayoutTemplate } from '@hanzogui/lucide-icons-2'
import { Button, Dialog, DialogContent, DialogDescription, DialogTitle, Input, Textarea } from '@hanzo/ui'
import { useEffect, useState } from 'react'
@@ -57,6 +59,7 @@ export function document(d: Draft): string {
}
function Creator({ c }: { c: Composer }) {
const { t } = useI18n()
const from = c.draft.trim()
const [d, setD] = useState<Draft>({ name: from ? slug(from.split(/\s+/).slice(0, 6).join(' ')) : '', description: first(from), body: from })
const [drafting, setDrafting] = useState(Boolean(from))
@@ -97,15 +100,15 @@ function Creator({ c }: { c: Composer }) {
return (
<Dialog open onOpenChange={(o) => !o && c.show(null)}>
<DialogContent maxW={640} width="100%">
<DialogTitle>Template Creator</DialogTitle>
<DialogTitle>{t('Template Creator')}</DialogTitle>
<DialogDescription>
{saved ? `Saved as ${saved}. It is under + › Skills.` : drafting ? 'Hanzo is drafting the template from your message…' : 'A reusable template, saved as one of your skills.'}
{saved ? t('Saved as {name}. It is under + › Skills.', { name: saved }) : drafting ? t('Hanzo is drafting the template from your message…') : t('A reusable template, saved as one of your skills.')}
</DialogDescription>
{saved ? null : (
<YStack gap="$3">
<Input value={d.name} onChangeText={(name: string) => setD({ ...d, name })} placeholder="weekly-update" aria-label="Name" autoCapitalize="none" />
<Input value={d.description} onChangeText={(description: string) => setD({ ...d, description })} placeholder="When to use it" aria-label="Description" />
<Textarea value={d.body} onChangeText={(body: string) => setD({ ...d, body })} placeholder={'# Weekly update\n\nInputs: {{team}}, {{week}}\n\n1. …'} aria-label="Template" rows={12} style={{ maxHeight: '40vh', overflowY: 'auto' }} />
<Input value={d.name} onChangeText={(name: string) => setD({ ...d, name })} placeholder="weekly-update" aria-label={t('Name')} autoCapitalize="none" />
<Input value={d.description} onChangeText={(description: string) => setD({ ...d, description })} placeholder={t('When to use it')} aria-label={t('Description')} />
<Textarea value={d.body} onChangeText={(body: string) => setD({ ...d, body })} placeholder={'# Weekly update\n\nInputs: {{team}}, {{week}}\n\n1. …'} aria-label={t('Template')} rows={12} maxH="40vh" overflowY="auto" />
</YStack>
)}
{note ? (
@@ -116,15 +119,15 @@ function Creator({ c }: { c: Composer }) {
<XStack gap="$2" justify="flex-end">
{saved ? (
<Button size="sm" onPress={() => c.show(null)}>
Done
{t('Done')}
</Button>
) : (
<>
<Button size="sm" variant="ghost" onPress={() => c.show(null)}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" disabled={busy || drafting || !d.name || !d.body.trim()} onPress={() => void save()}>
Save
{t('Save')}
</Button>
</>
)}
@@ -136,8 +139,8 @@ function Creator({ c }: { c: Composer }) {
export const template: Plugin = {
id: 'template',
name: 'Template Creator',
note: 'Templates saved as skills',
name: phrase('Template Creator'),
note: phrase('Templates saved as skills'),
icon: <LayoutTemplate size={16} />,
open: (c) => c.show(<Creator c={c} />),
}
+34 -20
View File
@@ -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>
))}
</>
+2 -1
View File
@@ -12,6 +12,7 @@
* panel), and the builder's own then reads through that one: one document, one
* read. The look (`Look`) is the builder's alone: a host has its own.
*/
import { phrase } from '@hanzo/i18n'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode } from 'react'
import { merge, read, save, type Patch, type Prefs } from './api/pref.ts'
@@ -49,7 +50,7 @@ function Keep({ children }: { children: ReactNode }) {
const write = useCallback(
async (patch: Patch) => {
if (!signed) throw new Error('Sign in to save your settings.')
if (!signed) throw new Error(phrase('Sign in to save your settings.'))
const before = now.current
now.current = merge(before, patch)
keep(now.current)
+102 -95
View File
@@ -36,8 +36,13 @@
*
* Derived from the Hanzo App v2 editor (github.com/hanzoai/build-v2), itself
* derived from OSW Studio and DeepSite (MIT). See NOTICE.
*
* Its words are the reader's language (@hanzo/i18n `t`, the target is
* `target`); a project's and a run's names, branches, paths and the
* platform's reasons are drawn as given.
*/
import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import {
Clock,
ExternalLink,
@@ -136,14 +141,15 @@ export function slugOf(name: string): string {
/** One run in the conversation: what was asked, and what it came to. */
function Turn({ run, open, onOpen, project }: { run: Session; open: boolean; onOpen: () => void; project: string }) {
const t = useTarget()
const target = useTarget()
const { t } = useI18n()
const [v, setV] = useState<Verdict>(null)
const [note, setNote] = useState('')
return (
<YStack gap="$2">
<Message role="user">
<SizableText size="$2" color="$ink">
{run.title || 'Untitled run'}
{run.title || t('Untitled run')}
</SizableText>
</Message>
<Message
@@ -156,7 +162,7 @@ function Turn({ run, open, onOpen, project }: { run: Session; open: boolean; onO
verdict={v}
onVerdict={(next) => {
setV(next)
record(t, run.id, project, next).catch((e: unknown) => {
record(target, run.id, project, next).catch((e: unknown) => {
setV(null)
setNote((e as Error).message)
})
@@ -170,7 +176,7 @@ function Turn({ run, open, onOpen, project }: { run: Session; open: boolean; onO
{!open ? (
<XStack render="button" onPress={onOpen} hitSlop={8}>
<SizableText size="$1" color="$soft" textDecorationLine="underline">
Show steps
{t('Show steps')}
</SizableText>
</XStack>
) : null}
@@ -178,7 +184,7 @@ function Turn({ run, open, onOpen, project }: { run: Session; open: boolean; onO
}
>
<SizableText size="$2" color="$ink">
{run.status === 'done' ? 'Done.' : run.status === 'error' ? 'This run hit an error.' : run.status === 'stopped' ? 'Stopped.' : 'Working…'}
{run.status === 'done' ? t('Done.') : run.status === 'error' ? t('This run hit an error.') : run.status === 'stopped' ? t('Stopped.') : t('Working…')}
</SizableText>
</Message>
</YStack>
@@ -187,12 +193,13 @@ function Turn({ run, open, onOpen, project }: { run: Session; open: boolean; onO
export function Project({ at }: { at: Where }) {
const host = useHost()
const t = useTarget()
const target = useTarget()
const { t } = useI18n()
const signed = Boolean(host.person)
const all = useProjects(t, signed)
const all = useProjects(target, signed)
// The repository the address names, and the forge's record of it.
const named = 'slug' in at ? null : at
const forge = useRead(named && signed ? () => one(t, named.name) : null, null as Codebase | null, [t, signed, named?.name])
const forge = useRead(named && signed ? () => one(target, named.name) : null, null as Codebase | null, [target, signed, named?.name])
const project: Row | null = all.value.find((p) => (named ? siteOf(p, named) : 'slug' in at && p.slug === at.slug)) ?? null
// The site this workspace publishes to: the project's, or the repository's name as one.
const slug = project?.slug ?? ('slug' in at ? at.slug : slugOf(named?.name ?? ''))
@@ -213,7 +220,7 @@ export function Project({ at }: { at: Where }) {
return () => clearInterval(every)
}, [building, again])
// When the build began, and why it failed, are its latest deployment's to say.
const failure = useRead(building || failed ? () => deployments(t, slug) : null, [], [t, slug, building, failed, project?.updated])
const failure = useRead(building || failed ? () => deployments(target, slug) : null, [], [target, slug, building, failed, project?.updated])
const latest = failure.value[0]
const began = latest?.status === 'building' ? latest.created : 0
const ran = began ? Math.max(0, Math.floor(now / 1000) - began) : 0
@@ -227,7 +234,7 @@ export function Project({ at }: { at: Where }) {
if (!clone || redo.busy) return
setRedo({ busy: true, error: '' })
try {
await declare(t, { repo: clone, ref: home, name: slug, project: slug, mode: 'branch' })
await declare(target, { repo: clone, ref: home, name: slug, project: slug, mode: 'branch' })
setNow(Date.now())
again()
setRedo({ busy: false, error: '' })
@@ -239,9 +246,9 @@ export function Project({ at }: { at: Where }) {
// The site's runs, or the org's when the repository has no site yet.
const sited = !named || Boolean(project)
const listed = useRead(
signed ? () => list(t, sited ? { kind: 'coding', project: slug, limit: 50 } : { kind: 'coding', limit: 50 }) : null,
signed ? () => list(target, sited ? { kind: 'coding', project: slug, limit: 50 } : { kind: 'coding', limit: 50 }) : null,
[] as Session[],
[t, signed, slug, sited],
[target, signed, slug, sited],
)
// Only the runs on this project's own repository: a record can be moved into
// any project, and a run's branch is what Files, Code and Publish read.
@@ -250,7 +257,7 @@ export function Project({ at }: { at: Where }) {
const ordered = useMemo(() => [...runs.value].reverse(), [listed.value, project?.repo, named?.org, named?.name])
const [chosen, setChosen] = useState<string | null>(null)
const current = chosen ?? runs.value[0]?.id ?? null
const run = useRun(t, current)
const run = useRun(target, current)
const end = outcome(run.events)
const pr = pull(run.record?.pr ?? '', run.record?.repo ?? '')
const running = LIVE.has(run.status || end.status)
@@ -281,15 +288,15 @@ export function Project({ at }: { at: Where }) {
const [picked, setPicked] = useState<Attachment[]>([])
const [attached, setAttached] = useState<Attached[]>([])
// The model and effort Chat and New share, and dictation into the ask.
const mind = useMind(t)
const voice = useVoice({ speech: useEar(t), language: useSpoken(), ...useDictation(draft, setDraft) })
const mind = useMind(target)
const voice = useVoice({ speech: useEar(target), language: useSpoken(), ...useDictation(draft, setDraft) })
const [bridge, setBridge] = useState(false)
const [editing, setEditing] = useState(false)
const [pageLines, setPageLines] = useState<Line[]>([])
const [history, setHistory] = useState(false)
const [publish, setPublish] = useState<Source | null>(null)
const [switcher, setSwitcher] = useState(false)
const repos = useRead(switcher && signed ? () => codebases(t) : null, [] as Codebase[], [t, switcher, signed])
const repos = useRead(switcher && signed ? () => codebases(target) : null, [] as Codebase[], [target, switcher, signed])
const frame = useRef<PreviewHandle | null>(null)
// What was published may be new files at the same address: the project is read
@@ -319,9 +326,9 @@ export function Project({ at }: { at: Where }) {
// Where the open run's pull request stands on the forge: Merge is offered while it is open.
const proposal = useRead<{ run: string; pull: Pull | null } | null>(
signed && current && pr.href ? () => pushed(t, current).then((c) => ({ run: current, pull: c.pull })) : null,
signed && current && pr.href ? () => pushed(target, current).then((c) => ({ run: current, pull: c.pull })) : null,
null,
[t, signed, current, pr.href],
[target, signed, current, pr.href],
)
const proposing = proposal.value?.run === current && proposal.value.pull?.state === 'open'
// Files is drawn again after a merge, from what the forge holds now.
@@ -343,9 +350,9 @@ export function Project({ at }: { at: Where }) {
if (files.some((f) => f.path === path)) return
setFiles((was) => [...was, { path, readOnly: true }])
try {
const b = await blob(t, repo, ref, path)
const b = await blob(target, repo, ref, path)
const content = b.binary ? '' : b.truncated ? '' : b.text
const error = b.binary ? 'A binary file — nothing to show as text.' : b.truncated ? 'Past the 1 MiB view cap — clone the repository to read it.' : undefined
const error = b.binary ? t('A binary file — nothing to show as text.') : b.truncated ? t('Past the 1 MiB view cap — clone the repository to read it.') : undefined
setFiles((was) => was.map((f) => (f.path === path ? { ...f, content, error } : f)))
} catch (e) {
setFiles((was) => was.map((f) => (f.path === path ? { ...f, error: (e as Error).message } : f)))
@@ -353,25 +360,25 @@ export function Project({ at }: { at: Where }) {
}
// Pages: the HTML files at the repository's root, as the page picker's list.
const root = useRead(repo ? () => tree(t, repo, ref, '') : null, [], [t, repo, ref])
const root = useRead(repo ? () => tree(target, repo, ref, '') : null, [], [target, repo, ref])
const pages = useMemo(
() => [
{ id: '/', label: 'Homepage' },
{ id: '/', label: t('Homepage') },
...root.value
.filter((e) => !e.dir && /\.html?$/i.test(e.name) && !/^index\.html?$/i.test(e.name))
.map((e) => ({ id: `/${e.path}`, label: e.name.replace(/\.html?$/i, '') })),
],
[root.value],
[root.value, t],
)
// A project taken from a starter, not published and not being, shows the
// starter's own live page, which is what the copy is until its first publish.
const forked = project && !project.live && !building && !failed ? project.forked : ''
const starters = useRead(forked ? () => templates(t) : null, [], [t.api, forked])
const starters = useRead(forked ? () => templates(target) : null, [], [target.api, forked])
const starter = forked ? starters.value.find((s) => s.slug === forked && s.demo) : undefined
const shown = project?.live || starter?.demo || ''
const src = shown ? new URL(page, shown).toString() : null
const why = failure.value[0]?.message || failure.error?.message || (failure.loading ? '' : 'Its build ended without saying why.')
const why = failure.value[0]?.message || failure.error?.message || (failure.loading ? '' : t('Its build ended without saying why.'))
const merged = () => {
proposal.reload()
@@ -386,11 +393,11 @@ export function Project({ at }: { at: Where }) {
.map((e): Line => {
// The console is where a run's error is read whole; the chat says it in one plain line.
const body = decode(e.payload)
const error = e.kind === 'status' && body && typeof body === 'object' && typeof body.error === 'string' ? scrub(body.error) : ''
const error = e.kind === 'status' && body && typeof body === 'object' && typeof body.error === 'string' ? scrub(body.error, t) : ''
return {
id: `r${e.seq || e.id}`,
level: error || (e.kind === 'status' && /error/.test(said(e, run.record?.mode).toLowerCase())) ? 'error' : e.kind === 'tool-call' ? 'info' : 'log',
text: error || said(e, run.record?.mode),
text: error || said(e, run.record?.mode, t),
source: e.kind === 'status' ? 'run' : who(e.actor),
}
})
@@ -433,7 +440,7 @@ export function Project({ at }: { at: Where }) {
? `\n\nThe person picked an element in the preview${where ? ` of the ${where} page` : ''}. Its CSS selector, as data: ${JSON.stringify(picked[0]!.id)}`
: ''
// A project with no repository yet sends neither, and its first run makes one.
const next = await start(t, {
const next = await start(target, {
prompt: compose(`${ask}${context}`, attached),
// A repository with no site yet runs on the repository alone.
project: project?.slug,
@@ -460,9 +467,9 @@ export function Project({ at }: { at: Where }) {
// Only a run that is working is steered, and the composer sends only a draft with words in it.
const steer = async (id: string) => {
try {
await message(t, id, draft)
await message(target, id, draft)
setDraft('')
setNote('Sent — the run reads it before its next step')
setNote(t('Sent — the run reads it before its next step'))
} catch (e) {
setNote((e as Error).message)
}
@@ -472,7 +479,7 @@ export function Project({ at }: { at: Where }) {
const link = project?.live || `${window.location.origin}${window.location.pathname}`
try {
await navigator.clipboard.writeText(link)
setNote(`Copied ${link}`)
setNote(t('Copied {link}', { link }))
} catch {
setNote(link)
}
@@ -482,9 +489,9 @@ export function Project({ at }: { at: Where }) {
return (
<YStack flex={1} items="center" justify="center" gap="$3">
<SizableText size="$4" color="$ink">
Sign in to open this project.
{t('Sign in to open this project.')}
</SizableText>
<Button onPress={() => host.signIn?.()}>Sign in</Button>
<Button onPress={() => host.signIn?.()}>{t('Sign in')}</Button>
</YStack>
)
}
@@ -495,15 +502,15 @@ export function Project({ at }: { at: Where }) {
return (
<YStack flex={1} items="center" justify="center" gap="$3" px="$4">
<SizableText size="$4" color="$ink" text="center">
{`${named.org}/${named.name} is in ${named.org}, and you are working in ${host.org}.`}
{t('{repo} is in {org}, and you are working in {current}.', { repo: `${named.org}/${named.name}`, org: named.org, current: host.org })}
</SizableText>
{member ? (
<Button variant="outline" onPress={() => host.chooseOrg?.(named.org)}>
{`Switch to ${named.org}`}
{t('Switch to {org}', { org: named.org })}
</Button>
) : (
<Button variant="outline" onPress={() => host.go(path({ kind: 'screen', screen: 'projects' }))}>
See this organization’s projects
{t('See this organization’s projects')}
</Button>
)}
</YStack>
@@ -517,13 +524,13 @@ export function Project({ at }: { at: Where }) {
{named
? forge.error && !/not found|404/i.test(forge.error.message)
? forge.error.message
: `${named.org} has no repository named ${named.name}.`
: t('{org} has no repository named {name}.', { org: named.org, name: named.name })
: host.org
? `${host.org} has no project named ${slug}.`
: `There is no project named ${slug}.`}
? t('{org} has no project named {name}.', { org: host.org, name: slug })
: t('There is no project named {name}.', { name: slug })}
</SizableText>
<Button variant="outline" onPress={() => host.go(path({ kind: 'screen', screen: named ? 'projects' : 'artifacts' }))}>
{named ? 'See this organization’s projects' : 'See what this organization has built'}
{named ? t('See this organization’s projects') : t('See what this organization has built')}
</Button>
</YStack>
)
@@ -536,39 +543,39 @@ export function Project({ at }: { at: Where }) {
{ordered.length === 0 ? (
<SizableText size="$2" color="$ink">
{runs.loading
? 'Reading this project’s runs…'
? t('Reading this project’s runs…')
: project?.live
? `${project.name} is loaded — it is in the preview, and every run on it is under the clock above. Say what to change and it gets built.`
? t('{name} is loaded — it is in the preview, and every run on it is under the clock above. Say what to change and it gets built.', { name: project.name })
: stalled
? `${project?.name} has been publishing for ${Math.floor(ran / 60)} minutes without finishing. Publish it again from the preview, or say what to change and it gets built.`
? t('{name} has been publishing for {minutes} minutes without finishing. Publish it again from the preview, or say what to change and it gets built.', { name: project?.name ?? '', minutes: Math.floor(ran / 60) })
: failed
? `${project?.name} could not be published. Publish it again from the preview, or say what to change and it gets built.`
? t('{name} could not be published. Publish it again from the preview, or say what to change and it gets built.', { name: project?.name ?? '' })
: building
? `${project?.name} is being published — it appears in the preview when its build finishes. Say what to change and it gets built.`
? t('{name} is being published — it appears in the preview when its build finishes. Say what to change and it gets built.', { name: project?.name ?? '' })
: starter
? `${project?.name} is loaded — the preview shows the ${starter.title} starter until your copy is published. Say what to change and it gets built.`
? t('{name} is loaded — the preview shows the {starter} starter until your copy is published. Say what to change and it gets built.', { name: project?.name ?? '', starter: starter.title })
: project
? `${project.name} is loaded and nothing is published yet. Say what to change and it gets built.`
? t('{name} is loaded and nothing is published yet. Say what to change and it gets built.', { name: project.name })
: named
? `${named.org}/${named.name} is open on ${home}. Say what to change and it gets built there.`
: `${slug} is loaded. Say what to change and it gets built.`}
? t('{repo} is open on {branch}. Say what to change and it gets built there.', { repo: `${named.org}/${named.name}`, branch: home })
: t('{name} is loaded. Say what to change and it gets built.', { name: slug })}
</SizableText>
) : (
ordered.map((r) => (
<YStack key={r.id} gap="$2">
<Turn run={r} open={r.id === current} onOpen={() => setChosen(r.id)} project={slug} />
{r.id === current ? (
<YStack gap="$1" pl="$2" borderLeftWidth={1} borderColor="$borderColor">
{fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, run.record?.mode) })).filter((b) => b.text)).map((b) => (
<YStack gap="$1" paddingInlineStart="$2" borderInlineStartWidth={1} borderColor="$borderColor">
{fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, run.record?.mode, t) })).filter((b) => b.text)).map((b) => (
<SizableText key={b.key} size="$1" color="$soft">
{b.text}
</SizableText>
))}
{pr.href ? (
<XStack items="center" gap="$2" flexWrap="wrap">
<Out href={pr.href} label={`Open pull request ${pr.label}`}>
<Out href={pr.href} label={t('Open pull request {pr}', { pr: pr.label })}>
<SizableText size="$1" color="$ink" textDecorationLine="underline">
Pull request {pr.label}
{t('Pull request {pr}', { pr: pr.label })}
</SizableText>
<ExternalLink size={11} />
</Out>
@@ -603,16 +610,16 @@ export function Project({ at }: { at: Where }) {
onStop={
current
? () =>
void stop(t, current).then(
() => setNote('Stop requested — the run keeps its work on its branch'),
void stop(target, current).then(
() => setNote(t('Stop requested — the run keeps its work on its branch')),
(e: unknown) => setNote((e as Error).message),
)
: undefined
}
busy={busy || (running && !draft.trim())}
placeholder={running ? 'Steer this run' : 'Ask Hanzo for edits'}
label="Ask Hanzo for edits"
plus={{ upload: () => pick(attached, setAttached, setNote) }}
placeholder={running ? t('Steer this run') : t('Ask Hanzo for edits')}
label={t('Ask Hanzo for edits')}
plus={{ upload: () => pick(attached, setAttached, setNote, t) }}
mode={running ? null : { value: mode === 'plan' ? 'plan' : 'auto', set: (m) => setMode(m === 'plan' ? 'plan' : 'build'), items: ['auto', 'plan'] }}
mind={running ? null : mind}
voice={voice}
@@ -620,7 +627,7 @@ export function Project({ at }: { at: Where }) {
</YStack>
{/* In the gutter between the chat and the work. */}
{wide && !shut ? (
<Gutter side="left" span={across} floor={CHAT_FLOOR} ceil={most} reset={CHAT_WIDTH} onSpan={setSplit} label="Resize the chat" gap={GUTTER} />
<Gutter side="left" span={across} floor={CHAT_FLOOR} ceil={most} reset={CHAT_WIDTH} onSpan={setSplit} label={t('Resize the chat')} gap={GUTTER} />
) : null}
</YStack>
)
@@ -631,14 +638,14 @@ export function Project({ at }: { at: Where }) {
<YStack data-slot="preview" flex={1} minH={0} minW={0} gap="$2" display={view === 'preview' ? 'flex' : 'none'}>
{/* The framed page's own controls, beside it: reload, the page, the width, and the page in a tab of its own. */}
<XStack data-slot="preview-bar" items="center" gap="$1.5" minW={0} shrink={0} $max-md={{ px: '$2' }}>
<XStack render="button" aria-label="Reload the preview" onPress={() => frame.current?.reload()} p="$1.5" rounded="$3" hoverStyle={{ bg: '$hover' }}>
<XStack render="button" aria-label={t('Reload the preview')} onPress={() => frame.current?.reload()} p="$1.5" rounded="$3" hoverStyle={{ bg: '$hover' }}>
<RefreshCcw size={16} />
</XStack>
<PageSelect pages={pages} value={page} onChange={setPage} shrink={1} />
<XStack flex={1} />
<Views views={DEVICES} value={device} onChange={(d) => setDevice(d as 'desktop' | 'mobile')} label="Device" labels="none" />
<Views views={DEVICES} value={device} onChange={(d) => setDevice(d as 'desktop' | 'mobile')} label={t('Device')} labels="none" />
{src ? (
<Out href={src} label="Open in a new tab">
<Out href={src} label={t('Open in a new tab')}>
<XStack p="$1.5" rounded="$3" hoverStyle={{ bg: '$hover' }}>
<ExternalLink size={16} />
</XStack>
@@ -648,34 +655,34 @@ export function Project({ at }: { at: Where }) {
<PreviewFrame
ref={frame}
src={src}
title={`${title} preview`}
title={t('{title} preview', { title })}
device={device}
onBridge={onBridge}
empty={
<YStack items="center" gap="$2" p="$6">
<SizableText size="$3" color="$ink">
{stalled
? 'Publishing is taking too long'
? t('Publishing is taking too long')
: building
? project?.forked
? 'Publishing your copy…'
: 'Publishing…'
? t('Publishing your copy…')
: t('Publishing…')
: failed
? 'Publishing failed'
: 'Nothing deployed yet'}
? t('Publishing failed')
: t('Nothing deployed yet')}
</SizableText>
<SizableText size="$2" color="$soft" text="center">
{stalled
? `Its build started ${Math.floor(ran / 60)} minutes ago and has not finished.`
? t('Its build started {minutes} minutes ago and has not finished.', { minutes: Math.floor(ran / 60) })
: building
? 'Its page appears here when the build finishes.'
? t('Its page appears here when the build finishes.')
: failed
? why
: 'Publish this project and its page appears here.'}
: t('Publish this project and its page appears here.')}
</SizableText>
{(stalled || failed) && clone ? (
<Button variant="outline" disabled={redo.busy} onPress={() => void retry()}>
Publish again
{t('Publish again')}
</Button>
) : null}
{redo.error ? (
@@ -690,7 +697,7 @@ export function Project({ at }: { at: Where }) {
<XStack
render="button"
aria-pressed={editing}
aria-label={editing ? 'Stop picking elements' : 'Pick an element to edit'}
aria-label={editing ? t('Stop picking elements') : t('Pick an element to edit')}
onPress={() => {
const next = !editing
setEditing(next)
@@ -711,19 +718,19 @@ export function Project({ at }: { at: Where }) {
view === 'preview' ? null : view === 'files' ? (
repo && root.error ? (
<SizableText size="$2" color="$soft" p="$4" role="status">
{`Nothing to show for ${repo} at ${ref}: the forge answered “${root.error.message}”.`}
{t('Nothing to show for {repo} at {ref}: the forge answered “{reason}”.', { repo, ref, reason: root.error.message })}
</SizableText>
) : repo ? (
<FileTree
key={round}
load={async (dir) => (await tree(t, repo, ref, dir)).map((e) => ({ path: e.path, kind: e.dir ? ('dir' as const) : ('file' as const) }))}
load={async (dir) => (await tree(target, repo, ref, dir)).map((e) => ({ path: e.path, kind: e.dir ? ('dir' as const) : ('file' as const) }))}
value={file}
onSelect={(p) => void openFile(p)}
label={`${repo} at ${ref}`}
label={t('{repo} at {ref}', { repo, ref })}
/>
) : (
<SizableText size="$2" color="$soft" p="$4">
This project has no repository yet. Its first run creates one.
{t('This project has no repository yet. Its first run creates one.')}
</SizableText>
)
) : view === 'code' ? (
@@ -737,7 +744,7 @@ export function Project({ at }: { at: Where }) {
}}
empty={
<SizableText size="$2" color="$soft" p="$4">
Open a file from Files. Code here is read at {ref}; ask in the chat to change it.
{t('Open a file from Files. Code here is read at {ref}; ask in the chat to change it.', { ref })}
</SizableText>
}
/>
@@ -752,8 +759,8 @@ export function Project({ at }: { at: Where }) {
) : (
<SizableText size="$2" color="$soft">
{bridge
? 'Pick an element in the preview to see it here and attach it to your next ask.'
: 'Layers are read through the preview bridge, and this page does not run it. Its elements cannot be listed from here.'}
? t('Pick an element in the preview to see it here and attach it to your next ask.')
: t('Layers are read through the preview bridge, and this page does not run it. Its elements cannot be listed from here.')}
</SizableText>
)}
</YStack>
@@ -766,17 +773,17 @@ export function Project({ at }: { at: Where }) {
// Shrinks with the bar, so the project's name truncates rather than
// running under Publish on a phone.
<XStack items="center" gap="$2" minW={0} shrink={1}>
<XStack render="button" aria-label="All runs" onPress={() => host.go('')} p="$1" shrink={0}>
<XStack render="button" aria-label={t('All runs')} onPress={() => host.go('')} p="$1" shrink={0}>
<HanzoMark size={20} />
</XStack>
<ProjectChip name={title} onPress={() => setSwitcher(true)} />
<XStack render="button" aria-label="History" onPress={() => setHistory(true)} p="$1.5" rounded="$3" shrink={0} hoverStyle={{ bg: '$hover' }}>
<XStack render="button" aria-label={t('History')} onPress={() => setHistory(true)} p="$1.5" rounded="$3" shrink={0} hoverStyle={{ bg: '$hover' }}>
<Clock size={16} />
</XStack>
{/* The chat folds away beside the work; on a phone the views switch to it instead. */}
<XStack
render="button"
aria-label={shut ? 'Show the chat' : 'Hide the chat'}
aria-label={shut ? t('Show the chat') : t('Hide the chat')}
aria-pressed={!shut}
onPress={() => (cramped ? setAsked(!asked) : setCollapsed(!collapsed))}
p="$1.5"
@@ -798,7 +805,7 @@ export function Project({ at }: { at: Where }) {
setView(v as ViewId)
setPane('view')
}}
label="View"
label={t('View')}
/>
}
end={
@@ -806,19 +813,19 @@ export function Project({ at }: { at: Where }) {
{/* Share gives way on a phone, where Publish is the one that matters. */}
<XStack display="none" $md={{ display: 'flex' }}>
<Button variant="outline" size="sm" onPress={() => void share()}>
<Share2 size={14} /> Share
<Share2 size={14} /> {t('Share')}
</Button>
</XStack>
<Button
size="sm"
aria-label="Publish"
aria-label={t('Publish')}
disabled={!clone || !slug}
onPress={() => setPublish({ repo: clone, title, ref, name: slug, project: slug })}
>
<Upload size={14} />
{/* A glyph alone on a phone, where the bar's middle needs the room. */}
<SizableText size="$2" color="inherit" $max-md={{ display: 'none' }}>
Publish
{t('Publish')}
</SizableText>
</Button>
</XStack>
@@ -831,7 +838,7 @@ export function Project({ at }: { at: Where }) {
onHeight={setDock}
onClear={() => setPageLines([])}
status={run.status || end.status || undefined}
label="Console"
label={t('Console')}
/>
}
collapsed={shut}
@@ -845,11 +852,11 @@ export function Project({ at }: { at: Where }) {
<Dialog open={history} onOpenChange={setHistory}>
<DialogContent maxW={480}>
<DialogTitle>Runs on {title}</DialogTitle>
<DialogTitle>{t('Runs on {title}', { title })}</DialogTitle>
<YStack gap="$1">
{runs.value.length === 0 ? (
<SizableText size="$2" color="$soft">
No runs yet.
{t('No runs yet.')}
</SizableText>
) : (
runs.value.map((r) => (
@@ -867,7 +874,7 @@ export function Project({ at }: { at: Where }) {
hoverStyle={{ bg: '$hover' }}
>
<SizableText size="$2" color="$ink" numberOfLines={1} flex={1}>
{r.title || 'Untitled run'}
{r.title || t('Untitled run')}
</SizableText>
<SizableText size="$1" color="$soft">
{r.status}
@@ -881,7 +888,7 @@ export function Project({ at }: { at: Where }) {
<Dialog open={switcher} onOpenChange={setSwitcher}>
<DialogContent maxW={420}>
<DialogTitle>Projects</DialogTitle>
<DialogTitle>{t('Projects')}</DialogTitle>
<YStack gap="$1" maxH={420} overflow="scroll">
{repos.error ? (
<SizableText size="$2" color="$soft">
@@ -889,7 +896,7 @@ export function Project({ at }: { at: Where }) {
</SizableText>
) : repos.loading && repos.value.length === 0 ? (
<SizableText size="$2" color="$soft">
Reading the forge…
{t('Reading the forge…')}
</SizableText>
) : (
repos.value.map((c) => {
@@ -898,7 +905,7 @@ export function Project({ at }: { at: Where }) {
<XStack
key={`${c.org}/${c.name}`}
render="button"
aria-label={`Open ${c.org}/${c.name}`}
aria-label={t('Open {repo}', { repo: `${c.org}/${c.name}` })}
aria-current={here ? 'page' : undefined}
onPress={() => {
setSwitcher(false)
@@ -926,7 +933,7 @@ export function Project({ at }: { at: Where }) {
host.go(path({ kind: 'screen', screen: 'projects' }))
}}
>
All projects
{t('All projects')}
</Button>
</DialogContent>
</Dialog>
+64 -39
View File
@@ -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 } 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'
@@ -32,6 +40,7 @@ import { MODES, type Mode } from './gear.ts'
import { Dictation } from './mic.tsx'
import { EFFORTS, grants, nameOf, reasons, ROUTER_IDS, type Mind } from './mind.ts'
import { Plus, type PlusProps, type Shelf } from './plus.tsx'
import { english } from './say.ts'
/** The reading measure: @hanzo/design's prose width, which Width (appearance) retunes. */
export const MEASURE = 'var(--container-prose, 48rem)'
@@ -60,7 +69,7 @@ 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')
/**
* The frame as a pane of its own, floating on the pane it is drawn in: the
@@ -161,7 +170,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 +182,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 +207,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" text="start">
{t(c.label)}
</SizableText>
<SizableText fontSize={13} color="$hint" style={{ textAlign: 'left' }}>
{c.note}
<SizableText fontSize={13} color="$hint" text="start">
{t(c.note)}
</SizableText>
</YStack>
{c.id === value ? <Check size={16} color="var(--primary)" mt={2} aria-hidden /> : null}
@@ -232,6 +242,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 +266,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 +283,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 +313,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 +330,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 +341,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 +355,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 +378,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 +391,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 +411,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 +432,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 +457,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} text="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 +504,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 +536,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 +559,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 +588,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 +610,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 +641,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 +670,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. */
+24 -19
View File
@@ -15,9 +15,11 @@
* project's address again.
*
* Every state shown is one the platform reported; a refusal shows the
* platform's own sentence.
* platform's own sentence. The dialog's own words are the reader's language
* (@hanzo/i18n `t`).
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import { ExternalLink } from '@hanzogui/lucide-icons-2'
import { Button, Dialog, DialogContent, DialogDescription, DialogTitle, Input } from '@hanzo/ui'
import { useEffect, useRef, useState } from 'react'
@@ -41,8 +43,9 @@ export interface Source {
type Phase = 'ask' | 'sending' | 'declared' | 'shipping' | 'shipped' | 'failed'
export function Publish({ source, onClose, onLive }: { source: Source | null; onClose: () => void; onLive?: () => void }) {
const t = useTarget()
const target = useTarget()
const host = useHost()
const { t } = useI18n()
const [project, setProject] = useState('')
const [phase, setPhase] = useState<Phase>('ask')
const [out, setOut] = useState<Declared | null>(null)
@@ -66,7 +69,7 @@ export function Publish({ source, onClose, onLive }: { source: Source | null; on
let live = true
const tick = async () => {
try {
const row = (await builds(t)).find((b) => b.id === id)
const row = (await builds(target)).find((b) => b.id === id)
if (live && row) setStatus(row.status)
} catch {
/* the status stays as last read */
@@ -78,7 +81,7 @@ export function Publish({ source, onClose, onLive }: { source: Source | null; on
live = false
clearInterval(every)
}
}, [t, out, phase])
}, [target, out, phase])
const green = /^(succeeded|success|done|complete|completed|ok)$/i.test(status)
@@ -93,7 +96,7 @@ export function Publish({ source, onClose, onLive }: { source: Source | null; on
setPhase('sending')
setError('')
try {
const d = await declare(t, { repo: source.repo, ref: source.ref, name: source.name, project: project || source.name, mode: 'branch' })
const d = await declare(target, { repo: source.repo, ref: source.ref, name: source.name, project: project || source.name, mode: 'branch' })
setOut(d)
setStatus(d.build?.status ?? '')
setPhase('declared')
@@ -111,7 +114,7 @@ export function Publish({ source, onClose, onLive }: { source: Source | null; on
setPhase('shipping')
setError('')
try {
await declare(t, { repo: source.repo, ref: source.ref, name: source.name, project: project || source.name, mode: 'commit', tag: built })
await declare(target, { repo: source.repo, ref: source.ref, name: source.name, project: project || source.name, mode: 'commit', tag: built })
setPhase('shipped')
} catch (e) {
setError((e as Error).message)
@@ -122,16 +125,16 @@ export function Publish({ source, onClose, onLive }: { source: Source | null; on
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent maxW={480}>
<DialogTitle>Add to project</DialogTitle>
<DialogTitle>{t('Add to project')}</DialogTitle>
<DialogDescription>
{out?.live
? `Build ${source.title} at ${source.ref} and publish it at its project’s address.`
: `Build ${source.title} at ${source.ref} and declare it in a project. ${host.admin ? 'As an admin you can take a green build to main.' : 'It opens a review; merging it is what deploys.'}`}
? t('Build {repo} at {ref} and publish it at its project’s address.', { repo: source.title, ref: source.ref })
: `${t('Build {repo} at {ref} and declare it in a project.', { repo: source.title, ref: source.ref })} ${host.admin ? t('As an admin you can take a green build to main.') : t('It opens a review; merging it is what deploys.')}`}
</DialogDescription>
<YStack gap="$1.5">
<SizableText render="label" size="$2" color="$soft" htmlFor="publish-project">
Project
{t('Project')}
</SizableText>
<Input
id="publish-project"
@@ -139,10 +142,10 @@ export function Publish({ source, onClose, onLive }: { source: Source | null; on
onChangeText={(v: string) => setProject(v)}
disabled={phase !== 'ask' && phase !== 'failed'}
placeholder={label(source.name)}
aria-label="Project"
aria-label={t('Project')}
/>
<SizableText size="$1" color="$soft">
Saved as {label(project || source.name)}
{t('Saved as {name}', { name: label(project || source.name) })}
</SizableText>
</YStack>
@@ -155,15 +158,17 @@ export function Publish({ source, onClose, onLive }: { source: Source | null; on
) : null}
<SizableText size="$2" color="$ink">
{phase === 'shipped'
? 'Declared on main — CD applies it on its next pass.'
? t('Declared on main — CD applies it on its next pass.')
: out.live && green
? `Build ${built} succeeded — it is live at the project’s address.`
: `Build ${built}: ${status || 'building'}`}
? t('Build {id} succeeded — it is live at the project’s address.', { id: built })
: status
? t('Build {id}: {status}', { id: built, status })
: t('Build {id}: building', { id: built })}
</SizableText>
{out.review ? (
<Out href={out.review}>
<SizableText size="$2" color="$ink" textDecorationLine="underline">
Open the review
{t('Open the review')}
</SizableText>
<ExternalLink size={12} opacity={0.6} />
</Out>
@@ -179,15 +184,15 @@ export function Publish({ source, onClose, onLive }: { source: Source | null; on
<XStack gap="$2" justify="flex-end">
<Button variant="outline" size="sm" onPress={onClose}>
{phase === 'shipped' || phase === 'declared' ? 'Done' : 'Cancel'}
{phase === 'shipped' || phase === 'declared' ? t('Done') : t('Cancel')}
</Button>
{phase === 'ask' || phase === 'failed' || phase === 'sending' ? (
<Button size="sm" disabled={phase === 'sending'} onPress={() => void send()}>
{phase === 'sending' ? 'Adding…' : 'Add to project'}
{phase === 'sending' ? t('Adding…') : t('Add to project')}
</Button>
) : host.admin && !out?.live && (phase === 'declared' || phase === 'shipping') ? (
<Button size="sm" disabled={!green || phase === 'shipping'} onPress={() => void ship()}>
{phase === 'shipping' ? 'Shipping…' : green ? 'Ship to main' : 'Waiting for the build'}
{phase === 'shipping' ? t('Shipping…') : green ? t('Ship to main') : t('Waiting for the build')}
</Button>
) : null}
</XStack>
+9 -6
View File
@@ -8,6 +8,7 @@
* from its own bytes (`blob`).
*/
import { SizableText, XStack } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import { Check, Copy, PanelRight, Square, Volume2 } from '@hanzogui/lucide-icons-2'
import { mouth, speech } from '@hanzo/voice'
import { useEffect, useRef, useState, type ReactNode } from 'react'
@@ -139,6 +140,7 @@ function Act({ label, onPress, children, lit = false }: { label: string; onPress
/** Copy, Listen and Open under an answer; `children` follow them. */
export function Reply({ text, listen, onOpen, children }: { text: string; listen?: Listen; onOpen?: () => void; children?: ReactNode }) {
const { t: say } = useI18n()
const [took, setTook] = useState(false)
const [speaking, setSpeaking] = useState(false)
const stop = useRef<(() => void) | null>(null)
@@ -150,12 +152,12 @@ export function Reply({ text, listen, onOpen, children }: { text: string; listen
useEffect(() => () => stop.current?.(), [])
return (
<XStack data-slot="reply" ml="$-1" items="center" gap="$1.5" flexWrap="wrap">
<Act label={took ? 'Copied' : 'Copy this answer'} lit={took} onPress={() => void navigator.clipboard?.writeText(text).then(() => setTook(true), () => {})}>
<Act label={took ? say('Copied') : say('Copy this answer')} lit={took} onPress={() => void navigator.clipboard?.writeText(text).then(() => setTook(true), () => {})}>
{took ? <Check size={13} /> : <Copy size={13} />}
</Act>
{listen ? (
<Act
label={speaking ? 'Stop reading' : 'Listen to this answer'}
label={speaking ? say('Stop reading') : say('Listen to this answer')}
lit={speaking}
onPress={() => {
if (speaking) {
@@ -169,15 +171,15 @@ export function Reply({ text, listen, onOpen, children }: { text: string; listen
>
{speaking ? <Square size={12} /> : <Volume2 size={13} />}
<SizableText size="$1" color="$ink">
{speaking ? 'Stop' : 'Listen'}
{speaking ? say('Stop') : say('Listen')}
</SizableText>
</Act>
) : null}
{onOpen ? (
<Act label="Open in the side panel" onPress={onOpen}>
<Act label={say('Open in the side panel')} onPress={onOpen}>
<PanelRight size={13} />
<SizableText size="$1" color="$ink">
Open
{say('Open')}
</SizableText>
</Act>
) : null}
@@ -188,9 +190,10 @@ export function Reply({ text, listen, onOpen, children }: { text: string; listen
/** Which model answered, where the gateway says it was not the one asked for or a router. */
export function Served({ served, asked }: { served: string; asked?: string }) {
const { t } = useI18n()
return (
<SizableText size="$1" color="$soft" data-slot="served">
{`answered by ${served}${asked && asked !== served ? ` · ${asked} was unavailable` : ''}`}
{asked && asked !== served ? t('answered by {model} · {asked} was unavailable', { model: served, asked }) : t('answered by {model}', { model: served })}
</SizableText>
)
}
+93 -75
View File
@@ -23,6 +23,8 @@
* the run. Share opens its story to the public build route.
*/
import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { ChevronDown, ChevronRight, Clock, Copy, ExternalLink, GitPullRequest, Globe, Pencil, Share } from '@hanzogui/lucide-icons-2'
import { Button, Dialog, DialogContent, DialogTitle, DropdownMenu, Input, type DropdownMenuProps } from '@hanzo/ui'
import { Steer, type Command } from '@hanzo/ui/agents'
@@ -55,9 +57,19 @@ import { Transcript } from './transcript.tsx'
const LIVE = new Set(['running', 'paused', ''])
/** A run's status as the line under its title says it; one the service adds later is drawn as it comes. */
const STATUS: Record<string, string> = {
running: phrase('running'),
paused: phrase('paused'),
done: phrase('done'),
error: phrase('error'),
stopped: phrase('stopped'),
}
type MenuItems = NonNullable<DropdownMenuProps['items']>
export function Run({ id }: { id: string }) {
const { t: say } = useI18n()
const host = useHost()
const t = useTarget()
// Signed out, there is nothing to read: the gateway answers a bare run with a refusal.
@@ -90,11 +102,11 @@ export function Run({ id }: { id: string }) {
const mode = record?.mode ?? ''
// A run that narrated no ask of its own opens with the one its title records, once it has said anything.
const shown = useMemo(() => {
const drawn = cards(events, mode)
const drawn = cards(events, mode, say)
const ask = record ? headline(record.title, record.repo) : ''
const opens = drawn[0]?.kind === 'said' && drawn[0].who === 'person'
return ask && drawn.length && !opens ? [{ kind: 'said' as const, key: 'ask', who: 'person' as const, text: ask }, ...drawn] : drawn
}, [events, mode, record])
}, [events, mode, record, say])
const end = outcome(events)
const plan = useMemo(() => steps(events), [events])
const kept = useMemo(() => settled(events), [events])
@@ -109,7 +121,7 @@ export function Run({ id }: { id: string }) {
// What a run publishes opens in the panel: the Preview, chosen and shown.
useEffect(() => {
if (!end.published) return
side.open({ kind: 'preview', title: 'Preview' })
side.open({ kind: 'preview', title: say('Preview') })
setDesk(true)
// On each publish, not on each render.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -127,7 +139,7 @@ export function Run({ id }: { id: string }) {
const steering = running && !(paused && held)
// A follow-up clones what the run kept, so it waits until the run has said where that is.
const waiting = (paused && held && !kept.settled) || (finished && stopping && !kept.settled)
const title = record?.title || (detail.loading ? '' : 'Untitled run')
const title = record?.title || (detail.loading ? '' : say('Untitled run'))
// A setup run explores and installs before it answers, which takes minutes.
const setup = mode === 'setup'
const [seen, setSeen] = useKept('hanzo.build.setup.seen', false)
@@ -160,18 +172,18 @@ export function Run({ id }: { id: string }) {
// where Notification exists; the permission can be taken back since.
useEffect(() => {
if (!notify || running) return
if (Notification.permission === 'granted') new Notification(record?.title || 'Run finished', { body: 'This run has finished.' })
if (Notification.permission === 'granted') new Notification(record?.title || say('Run finished'), { body: say('This run has finished.') })
setNotify(false)
}, [notify, running, record?.title])
const ask = async () => {
if (typeof Notification === 'undefined') {
setNote('This browser cannot send a notification.')
setNote(say('This browser cannot send a notification.'))
return
}
const perm = Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission()
if (perm !== 'granted') {
setNote('Notifications stay off until this browser allows them.')
setNote(say('Notifications stay off until this browser allows them.'))
return
}
setNotify(true)
@@ -201,7 +213,7 @@ export function Run({ id }: { id: string }) {
// A setup run's work IS its environment: the panel opens on it.
useEffect(() => {
if (mode === 'setup') side.open({ kind: 'environment', title: 'Environment' })
if (mode === 'setup') side.open({ kind: 'environment', title: say('Environment') })
// Once the run says what it is.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode])
@@ -217,7 +229,7 @@ export function Run({ id }: { id: string }) {
* first: the new run works in the same sandbox, where nothing else may be at work.
*/
const carry = async (said: string, how: 'follow' | 'approve' | 'retry' = 'follow'): Promise<string> => {
if (!earlier) throw new Error('This run is still being read')
if (!earlier) throw new Error(say('This run is still being read'))
if (paused) await stop(t, id, 'Continued in a follow-up run').catch(() => undefined)
const ask = how === 'approve' ? approve(earlier, said) : how === 'retry' ? retry(earlier, said) : followUp(earlier, said)
const run = await start(t, tuned(ask))
@@ -235,7 +247,7 @@ export function Run({ id }: { id: string }) {
await message(t, id, words)
setDraft('')
setFiles([])
return 'Sent — recorded on this run'
return say('Sent — recorded on this run')
})
return
}
@@ -248,13 +260,13 @@ export function Run({ id }: { id: string }) {
void act(async () => {
await stop(t, id)
setStopping(true)
return 'Stop requested — the run keeps its work on its branch'
return say('Stop requested — the run keeps its work on its branch')
})
return
case 'pause':
void act(async () => {
await pause(t, id)
return held ? 'Pause requested — the run keeps its work on its branch and waits' : 'Pause asked — the machine pauses when it reads it'
return held ? say('Pause requested — the run keeps its work on its branch and waits') : say('Pause asked — the machine pauses when it reads it')
})
return
case 'resume':
@@ -262,7 +274,7 @@ export function Run({ id }: { id: string }) {
else
void act(async () => {
await resume(t, id)
return 'Resume asked — the machine goes on when it reads it'
return say('Resume asked — the machine goes on when it reads it')
})
return
}
@@ -273,7 +285,7 @@ export function Run({ id }: { id: string }) {
await navigator.clipboard.writeText(value)
setCopied(what)
} catch {
setNote('This browser would not copy.')
setNote(say('This browser would not copy.'))
}
}
@@ -281,7 +293,7 @@ export function Run({ id }: { id: string }) {
act(async () => {
await publish(t, id, on)
detail.reload()
return on ? 'Shared — anyone with the link can read this run’s story' : 'No longer shared'
return on ? say('Shared — anyone with the link can read this run’s story') : say('No longer shared')
})
// What the title's menu does to the run.
@@ -290,7 +302,7 @@ export function Run({ id }: { id: string }) {
? [
{
key: 'rename',
label: 'Rename',
label: say('Rename'),
icon: <Pencil size={16} />,
onSelect: () => {
setName(title)
@@ -299,15 +311,15 @@ export function Run({ id }: { id: string }) {
},
]
: []),
{ key: 'copy', label: copied === 'id' ? 'Copied' : 'Copy run id', icon: <Copy size={16} />, description: id, onSelect: () => void copy('id', id) },
{ key: 'copy', label: copied === 'id' ? say('Copied') : say('Copy run id'), icon: <Copy size={16} />, description: id, onSelect: () => void copy('id', id) },
// What this run was asked, as a task that runs again on a schedule: the ask it opened with.
...(signed && record
? [
{
key: 'schedule',
label: 'Schedule this',
label: say('Schedule this'),
icon: <Clock size={16} />,
description: 'Run it again on a schedule',
description: say('Run it again on a schedule'),
onSelect: () => {
const first = shown.find((c) => c.kind === 'said' && c.who === 'person')
schedule(first && 'text' in first ? first.text : headline(record.title, record.repo), host.go)
@@ -322,9 +334,9 @@ export function Run({ id }: { id: string }) {
signed && record?.project
? [
record.published
? { key: 'unshare', label: 'Stop sharing', icon: <Globe size={16} />, onSelect: () => void share(false) }
: { key: 'share', label: 'Share publicly', icon: <Globe size={16} />, description: 'Anyone with the link can read it', onSelect: () => void share(true) },
...(link ? [{ key: 'link', label: copied === 'link' ? 'Copied' : 'Copy public link', icon: <Copy size={16} />, description: link, onSelect: () => void copy('link', link) }] : []),
? { key: 'unshare', label: say('Stop sharing'), icon: <Globe size={16} />, onSelect: () => void share(false) }
: { key: 'share', label: say('Share publicly'), icon: <Globe size={16} />, description: say('Anyone with the link can read it'), onSelect: () => void share(true) },
...(link ? [{ key: 'link', label: copied === 'link' ? say('Copied') : say('Copy public link'), icon: <Copy size={16} />, description: link, onSelect: () => void copy('link', link) }] : []),
]
: []
@@ -333,24 +345,24 @@ export function Run({ id }: { id: string }) {
await rename(t, id, name)
setNaming(false)
detail.reload()
return 'Renamed'
return say('Renamed')
})
const placeholder = !signed
? 'Sign in to follow up'
? say('Sign in to follow up')
: waiting
? paused
? 'Pausing — the run is keeping its work…'
: 'Stopping — the run is keeping its work…'
? say('Pausing — the run is keeping its work…')
: say('Stopping — the run is keeping its work…')
: paused && held
? 'Continue this run with a follow up'
? say('Continue this run with a follow up')
: running
? setup
? 'Add a follow up for the setup agent'
: 'Add a follow up'
? say('Add a follow up for the setup agent')
: say('Add a follow up')
: finished
? 'Follow up — continues in a new run'
: 'Reading this run…'
? say('Follow up — continues in a new run')
: say('Reading this run…')
const desk_ = (sheet: boolean) => (
<Desk
@@ -369,8 +381,8 @@ export function Run({ id }: { id: string }) {
events={events}
live={running}
menu={[...manage, ...sharing]}
refused={signed ? refused || (detail.error ? detail.error.message : '') : 'Sign in to follow this run.'}
retry={signed ? 'Retry' : 'Sign in'}
refused={signed ? refused || (detail.error ? say(detail.error.message) : '') : say('Sign in to follow this run.')}
retry={signed ? say('Retry') : say('Sign in')}
onRetry={signed ? detail.reload : () => host.signIn?.()}
side={side}
sheet={sheet}
@@ -385,7 +397,7 @@ export function Run({ id }: { id: string }) {
<YStack flex={1} minW={0} items="flex-start">
<DropdownMenu
trigger={
<XStack render="button" aria-label="Manage this run" items="center" gap="$1.5" maxW="100%" px="$1.5" mx="$-1.5" py="$0.5" rounded="$2" hoverStyle={{ bg: '$hover' }}>
<XStack render="button" aria-label={say('Manage this run')} items="center" gap="$1.5" maxW="100%" px="$1.5" mx="$-1.5" py="$0.5" rounded="$2" hoverStyle={{ bg: '$hover' }}>
<SizableText render="h1" size="$5" color="$ink" numberOfLines={1} minW={0}>
{title}
</SizableText>
@@ -395,12 +407,12 @@ export function Run({ id }: { id: string }) {
items={manage}
/>
<SizableText size="$1" color="$soft" numberOfLines={1} maxW="100%">
{[state || (detail.loading ? 'reading' : ''), record?.repo, record?.branch, record?.published ? 'shared' : ''].filter(Boolean).join(' · ')}
{[state ? say(STATUS[state] ?? state) : detail.loading ? say('reading') : '', record?.repo, record?.branch, record?.published ? say('shared') : ''].filter(Boolean).join(' · ')}
</SizableText>
</YStack>
{pr.href ? (
// From the run's record, which the coding service writes; `pull` draws only a pull request address.
<Out href={pr.href} label={`Open pull request ${pr.label}`}>
<Out href={pr.href} label={say('Open pull request {pr}', { pr: pr.label })}>
<XStack items="center" gap="$1.5" px="$2.5" py="$1.5" rounded="$3" borderWidth={1} borderColor="$borderColor" hoverStyle={{ bg: '$hover' }}>
<GitPullRequest size={14} />
<SizableText size="$2" color="$ink">
@@ -413,10 +425,10 @@ export function Run({ id }: { id: string }) {
{sharing.length ? (
<DropdownMenu
trigger={
<XStack render="button" aria-label="Share" items="center" gap="$1.5" px="$2.5" py="$1.5" rounded="$3" borderWidth={1} borderColor="$borderColor" hoverStyle={{ bg: '$hover' }}>
<XStack render="button" aria-label={say('Share')} items="center" gap="$1.5" px="$2.5" py="$1.5" rounded="$3" borderWidth={1} borderColor="$borderColor" hoverStyle={{ bg: '$hover' }}>
<Share size={14} />
<SizableText size="$2" color="$ink">
Share
{say('Share')}
</SizableText>
</XStack>
}
@@ -430,14 +442,16 @@ export function Run({ id }: { id: string }) {
cards={shown}
onOpen={look}
onDiff={() => {
side.open({ kind: 'diff', title: 'Diff' })
side.open({ kind: 'diff', title: say('Diff') })
setDesk(true)
}}
listen={signed ? listen : undefined}
end={
failed ? (
<Failure onRetry={busy ? undefined : () => void act(() => carry(again, 'retry'))}>
{end.problem ? 'This run stopped before it finished.' : 'This run stopped with an error. Its work so far is on its branch.'}
{end.problem
? say('This run stopped before it finished.')
: `${say('This run stopped with an error.')} ${say('Its work so far is on its branch.')}`}
</Failure>
) : null
}
@@ -446,7 +460,7 @@ export function Run({ id }: { id: string }) {
header={
hidden ? (
<SizableText size="$1" color="$soft">
{`${hidden} earlier ${hidden === 1 ? 'turn is' : 'turns are'} not shown. The run’s Git tab has everything it pushed.`}
{`${hidden === 1 ? say('{n} earlier turn is not shown.', { n: hidden }) : say('{n} earlier turns are not shown.', { n: hidden })} ${say('The run’s Git tab has everything it pushed.')}`}
</SizableText>
) : null
}
@@ -457,22 +471,22 @@ export function Run({ id }: { id: string }) {
signed ? (
<SizableText size="$2" color="$soft">
{detail.error
? detail.error.message
? say(detail.error.message)
: detail.loading
? 'Reading this run…'
? say('Reading this run…')
: running
? setup
? 'Setting up environment'
: 'Starting…'
: 'Waiting for the run’s first step…'}
? say('Setting up environment')
: say('Starting…')
: say('Waiting for the run’s first step…')}
</SizableText>
) : (
<YStack gap="$3" items="flex-start">
<SizableText size="$2" color="$soft">
Sign in to follow this run.
{say('Sign in to follow this run.')}
</SizableText>
<Button size="sm" onPress={() => host.signIn?.()}>
Sign in
{say('Sign in')}
</Button>
</YStack>
)
@@ -484,7 +498,7 @@ export function Run({ id }: { id: string }) {
<YStack {...measure} gap="$2">
{end.problem ? (
<SizableText size="$1" color="$soft">
The branch is pushed, and the pull request could not be opened.
{say('The branch is pushed, and the pull request could not be opened.')}
</SizableText>
) : null}
{note || (signed && refused) ? (
@@ -497,7 +511,7 @@ export function Run({ id }: { id: string }) {
<YStack borderWidth={1} borderColor="$borderColor" rounded="$3" overflow="hidden">
<XStack
render="button"
aria-label={`Steps · ${plan.length}`}
aria-label={say('Steps · {n}', { n: plan.length })}
aria-expanded={unfolded}
onPress={() => setUnfolded(!unfolded)}
px="$3"
@@ -508,10 +522,10 @@ export function Run({ id }: { id: string }) {
>
{unfolded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<SizableText size="$2" color="$ink">
{`Steps · ${plan.length}`}
{say('Steps · {n}', { n: plan.length })}
</SizableText>
{current && !unfolded ? (
<SizableText flex={1} minW={0} size="$1" color="$soft" numberOfLines={1} style={{ textAlign: 'right' }}>
<SizableText flex={1} minW={0} size="$1" color="$soft" numberOfLines={1} text="end">
{current.name}
</SizableText>
) : null}
@@ -539,13 +553,13 @@ export function Run({ id }: { id: string }) {
<SizableText size="$2" color="$ink" flex={1} minW={160}>
{paused
? kept.pushed
? 'Paused — its work so far is on its branch.'
: 'Paused.'
? say('Paused — its work so far is on its branch.')
: say('Paused.')
: setup
? span
? `Environment setup takes ~${span[0] === span[1] ? span[0] : `${span[0]}–${span[1]}`} minutes.`
: 'Environment setup takes several minutes.'
: 'This run is still working.'}
? say('Environment setup takes ~{minutes} minutes.', { minutes: span[0] === span[1] ? span[0] : `${span[0]}–${span[1]}` })
: say('Environment setup takes several minutes.')
: say('This run is still working.')}
</SizableText>
<Steer
onCommand={command}
@@ -555,7 +569,7 @@ export function Run({ id }: { id: string }) {
]}
/>
<Button size="sm" disabled={notify} onPress={() => void ask()}>
{notify ? 'You will be notified' : 'Notify me'}
{notify ? say('You will be notified') : say('Notify me')}
</Button>
</XStack>
) : null}
@@ -568,9 +582,9 @@ export function Run({ id }: { id: string }) {
ready={Boolean(draft.trim())}
disabled={!signed || busy || waiting || !(running || finished)}
placeholder={placeholder}
label={steering ? 'Steer this run' : 'Follow up on this run'}
label={steering ? say('Steer this run') : say('Follow up on this run')}
head={files.length ? <XStack gap="$1.5" flexWrap="wrap"><Files files={files} onFiles={setFiles} /></XStack> : undefined}
plus={{ upload: () => pick(files, setFiles, setNote) }}
plus={{ upload: () => pick(files, setFiles, setNote, say) }}
// What a new run from here is made with; words that steer this run use neither.
mind={signed && !steering ? mind : null}
limits={limits}
@@ -584,8 +598,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}
@@ -593,19 +607,19 @@ export function Run({ id }: { id: string }) {
>
{split.grip ? (
// In the gutter between the two panes, measured inside the pane's edge.
<Gutter {...split.grip} label="Resize the side panel" inset={pane.borderWidth} />
<Gutter {...split.grip} label={say('Resize the side panel')} inset={pane.borderWidth} />
) : null}
<SideProvider value={split.side}>{desk_(false)}</SideProvider>
</YStack>
) : null}
<Dialog open={naming} onOpenChange={setNaming}>
<DialogContent maxW={480}>
<DialogTitle>Rename this run</DialogTitle>
<DialogTitle>{say('Rename this run')}</DialogTitle>
<Input
autoFocus
value={name}
onChangeText={setName}
aria-label="The run’s name"
aria-label={say('The run’s name')}
maxLength={512}
onKeyDown={(e: { key?: string }) => {
if (e.key === 'Enter') save()
@@ -613,10 +627,10 @@ export function Run({ id }: { id: string }) {
/>
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="outline" onPress={() => setNaming(false)}>
Cancel
{say('Cancel')}
</Button>
<Button size="sm" disabled={busy || !name.trim()} onPress={save}>
Save
{say('Save')}
</Button>
</XStack>
</DialogContent>
@@ -627,19 +641,23 @@ export function Run({ id }: { id: string }) {
/** What a setup run does, said once per browser the first time one is watched. */
function Onboarding({ onDone }: { onDone: () => void }) {
const { t } = useI18n()
const steps = [
'Explore, install and check the codebase',
'Name the secrets it needs — you set their values',
'Propose the environment for you to review and save',
t('Explore, install and check the codebase'),
t('Name the secrets it needs — you set their values'),
t('Propose the environment for you to review and save'),
]
return (
<YStack gap="$3" p="$4" rounded="$4" borderWidth={1} borderColor="$borderColor" bg="$panel">
<SizableText size="$4" color="$ink">
Set up a cloud environment
{t('Set up a cloud environment')}
</SizableText>
<SizableText size="$2" color="$soft">
An environment lets every later run install, start, test and check its changes the way an engineer does. Setup
is agent-led and takes several minutes. The agent will:
{[
t('An environment lets every later run install, start, test and check its changes the way an engineer does.'),
t('Setup is agent-led and takes several minutes.'),
t('The agent will:'),
].join(' ')}
</SizableText>
<YStack gap="$1.5">
{steps.map((s, i) => (
@@ -654,10 +672,10 @@ function Onboarding({ onDone }: { onDone: () => void }) {
))}
</YStack>
<SizableText size="$2" color="$soft">
Interrupt anytime to steer the agent or ask it questions.
{t('Interrupt anytime to steer the agent or ask it questions.')}
</SizableText>
<Button size="sm" onPress={onDone}>
Got it
{t('Got it')}
</Button>
</YStack>
)
+9
View File
@@ -0,0 +1,9 @@
/**
* `t` where no component is asking: the English, its `{name}` arguments filled.
* A helper that builds words outside a render (a rail's rows, a formatter)
* takes the caller's @hanzo/i18n `t` and falls back to this.
*/
import { translate } from '@hanzo/i18n'
import type { Say } from '@hanzo/i18n/react'
export const english = ((source: string, values?: Readonly<Record<string, string | number>>) => translate({}, source, values)) as Say
+94 -84
View File
@@ -11,9 +11,14 @@
* a read every half minute keeps them right when the stream is down. A new task
* is said in plain words — "every Monday at 9am" — and the platform says back
* what it understood before anything is created.
*
* Its words are the reader's language (@hanzo/i18n `t`); a task's name and
* prompt, a run's title, summary and error, and what the platform says back
* are drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { Clock, ExternalLink, MoreHorizontal, Pencil, Play, Plus, ScrollText, Trash2 } from '@hanzogui/lucide-icons-2'
import { useI18n } from '@hanzo/i18n/react'
import { Button, DropdownMenu, Input, Switch, Textarea } from '@hanzo/ui'
import { StatusDot } from '@hanzo/ui/chat'
import { ModelPicker } from '@hanzo/ui/models'
@@ -21,7 +26,7 @@ import { useLimits } from '@hanzo/ui/product/useLimits'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { automations, create, cron, events, jobs, remove, save, start, words, type Automation, type Draft, type Job, type Parsed, type Schedule } from './api/auto.ts'
import { Refusal } from './api/call.ts'
import { Refusal, told } from './api/call.ts'
import { ENSO, limits as readLimits, models } from './api/models.ts'
import { Confirm, Field, Line, Sheet } from './customize/ui.tsx'
import { local, PLAN, shaped, tidy, unplanned, unready, When, zone } from './automations.tsx'
@@ -95,12 +100,13 @@ function useLive(on: (h: { job?: Job; automation?: Automation }) => void) {
}
export function Scheduled() {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const signed = Boolean(host.person)
const [tick, setTick] = useState(0)
const tasks = useRead(signed ? () => automations(t) : null, [] as Automation[], [t, signed, tick])
const runs = useRead(signed ? () => jobs(t, { limit: 50 }) : null, [] as Job[], [t, signed, tick])
const tasks = useRead(signed ? () => automations(target) : null, [] as Automation[], [target, signed, tick])
const runs = useRead(signed ? () => jobs(target, { limit: 50 }) : null, [] as Job[], [target, signed, tick])
const [shownTasks, setTasks] = useState<Automation[]>([])
const [shownRuns, setRuns] = useState<Job[]>([])
useEffect(() => setTasks(tasks.value), [tasks.value])
@@ -136,18 +142,18 @@ export function Scheduled() {
try {
await work()
} catch (e) {
setProblem(unplanned(e) ? PLAN : `${what}: ${(e as Error).message}`)
setProblem(unplanned(e) ? t(PLAN) : t('{name}: {error}', { name: what, error: told(e, t) }))
}
}
const flip = (a: Automation, on: boolean) =>
act(a.name, async () => {
const next = await save(t, a.id, { enabled: on })
const next = await save(target, a.id, { enabled: on })
setTasks((was) => was.map((x) => (x.id === next.id ? next : x)))
})
const run = (a: Automation) =>
act(a.name, async () => {
const r = await start(t, a.id)
if (r.status === 'skipped') setProblem(`${a.name}: not started, the previous run is still going.`)
const r = await start(target, a.id)
if (r.status === 'skipped') setProblem(t('{name}: not started, the previous run is still going.', { name: a.name }))
setTick((n) => n + 1)
})
const edit = (id: string) => host.go(path({ kind: 'automation', id }))
@@ -158,41 +164,41 @@ export function Scheduled() {
<XStack justify="space-between" items="flex-start" gap="$4">
<YStack gap="$1" flex={1} minW={0}>
<SizableText render="h1" size="$7" fontWeight="600" color="$ink">
Scheduled tasks
{t('Scheduled tasks')}
</SizableText>
<SizableText size="$2" color="$soft">
What runs without you watching: prompts on a schedule, background runs, Studio renders and workflows.
{t('What runs without you watching: prompts on a schedule, background runs, Studio renders and workflows.')}
</SizableText>
</YStack>
<Button size="sm" shrink={0} disabled={!signed} onPress={() => setMaking('')}>
<Plus size={14} />
New task
{t('New task')}
</Button>
</XStack>
<Line>{problem}</Line>
<YStack gap="$2">
<SizableText render="h2" size="$3" color="$ink" fontWeight="500">
Tasks
{t('Tasks')}
</SizableText>
{!signed ? (
<Said says="Sign in to see your scheduled tasks.">
<Said says={t('Sign in to see your scheduled tasks.')}>
<Button size="sm" onPress={() => host.signIn?.()}>
Sign in
{t('Sign in')}
</Button>
</Said>
) : tasks.error && !shownTasks.length ? (
<Said says={tasks.error.message} />
<Said says={told(tasks.error, t)} />
) : tasks.loading && !shownTasks.length ? (
<Said says="Reading your tasks…" />
<Said says={t('Reading your tasks…')} />
) : !shownTasks.length ? (
<Said says="No scheduled tasks yet. Ask Chat or Dev to do something on a schedule, or start one here.">
<Said says={t('No scheduled tasks yet. Ask Chat or Dev to do something on a schedule, or start one here.')}>
<Button size="sm" onPress={() => setMaking('')}>
New task
{t('New task')}
</Button>
</Said>
) : (
<YStack role="list" aria-label="Your tasks" borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
<YStack role="list" aria-label={t('Your tasks')} borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
{shownTasks.map((a, i) => (
<Task key={a.id} a={a} first={i === 0} onOpen={() => edit(a.id)} onSwitch={(on) => void flip(a, on)} onRun={() => void run(a)} onDelete={() => setDeleting(a)} />
))}
@@ -204,20 +210,20 @@ export function Scheduled() {
<YStack gap="$2">
<XStack items="center" gap="$2">
<SizableText render="h2" size="$3" color="$ink" fontWeight="500" flex={1}>
Activity
{t('Activity')}
</SizableText>
<SizableText size="$1" color="$soft">
{live ? 'Live' : ''}
{live ? t('Live') : ''}
</SizableText>
</XStack>
{runs.error && !shownRuns.length ? (
<Said says={runs.error.message} />
<Said says={told(runs.error, t)} />
) : runs.loading && !shownRuns.length ? (
<Said says="Reading what ran…" />
<Said says={t('Reading what ran…')} />
) : !shownRuns.length ? (
<Said says="Nothing has run yet. A task's runs, Dev's background runs and Studio's renders show here as they go." />
<Said says={t("Nothing has run yet. A task's runs, Dev's background runs and Studio's renders show here as they go.")} />
) : (
<YStack role="list" aria-label="Activity" borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
<YStack role="list" aria-label={t('Activity')} borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
{shownRuns.map((j, i) => (
<Ran key={j.id} j={j} first={i === 0} now={now} onOpen={() => setLooking(j)} />
))}
@@ -232,18 +238,18 @@ export function Scheduled() {
onClose={() => setMaking(null)}
onMade={(a) => {
setTasks((was) => [a, ...was.filter((x) => x.id !== a.id)])
setProblem(`Scheduled: ${a.name}.`)
setProblem(t('Scheduled: {name}.', { name: a.name }))
}}
/>
<Detail j={looking} onClose={() => setLooking(null)} now={now} />
{deleting ? (
<Confirm
what={deleting.name}
says="It stops running, and its schedule and its runs go with it. A run going now is stopped."
says={t('It stops running, and its schedule and its runs go with it. A run going now is stopped.')}
open
onOpenChange={(o) => !o && setDeleting(null)}
onYes={async () => {
await remove(t, deleting.id)
await remove(target, deleting.id)
setTasks((was) => was.filter((x) => x.id !== deleting.id))
setDeleting(null)
}}
@@ -255,12 +261,13 @@ export function Scheduled() {
/** One task: what it is and when it runs next, its switch, and its menu. */
function Task({ a, first, onOpen, onSwitch, onRun, onDelete }: { a: Automation; first: boolean; onOpen: () => void; onSwitch: (on: boolean) => void; onRun: () => void; onDelete: () => void }) {
const { t } = useI18n()
const when = a.draft
? 'Needs instructions'
: [words(a.schedule), a.enabled && a.next ? `next ${local(a.next)}` : a.schedule.kind !== 'manual' && !a.enabled ? 'paused' : ''].filter(Boolean).join(' · ')
? t('Needs instructions')
: [words(a.schedule, t), a.enabled && a.next ? t('next {when}', { when: local(a.next) }) : a.schedule.kind !== 'manual' && !a.enabled ? t('paused') : ''].filter(Boolean).join(' · ')
return (
<XStack role="listitem" items="center" gap="$3" px="$3" py="$3" borderTopWidth={first ? 0 : 1} borderColor="$borderColor" hoverStyle={{ bg: '$hover' }}>
<XStack render="button" aria-label={`Open ${a.name}`} onPress={onOpen} flex={1} minW={0} items="center" gap="$3" cursor="pointer" style={{ textAlign: 'left' }}>
<XStack render="button" aria-label={t('Open {name}', { name: a.name })} onPress={onOpen} flex={1} minW={0} items="center" gap="$3" cursor="pointer" $platform-web={{ textAlign: 'start' }}>
<YStack width={32} height={32} rounded="$3" bg="$raised" items="center" justify="center" shrink={0} aria-hidden>
<Clock size={16} />
</YStack>
@@ -269,25 +276,25 @@ function Task({ a, first, onOpen, onSwitch, onRun, onDelete }: { a: Automation;
<SizableText size="$2" color="$ink" numberOfLines={1} shrink={1}>
{a.name}
</SizableText>
<Badge>{SOURCE[a.source]}</Badge>
{a.draft ? <Badge>Draft</Badge> : null}
<Badge>{t(SOURCE[a.source])}</Badge>
{a.draft ? <Badge>{t('Draft')}</Badge> : null}
</XStack>
<SizableText size="$1" color="$soft" numberOfLines={1}>
{when}
</SizableText>
</YStack>
</XStack>
<Switch checked={a.enabled} disabled={a.draft} onCheckedChange={onSwitch} aria-label={`${a.name} on`} />
<Switch checked={a.enabled} disabled={a.draft} onCheckedChange={onSwitch} aria-label={t('{name} on', { name: a.name })} />
<DropdownMenu
trigger={
<XStack render="button" aria-label={`Actions for ${a.name}`} width={30} height={30} items="center" justify="center" rounded="$2" hoverStyle={{ bg: '$hover' }} cursor="pointer">
<XStack render="button" aria-label={t('Actions for {name}', { name: a.name })} width={30} height={30} items="center" justify="center" rounded="$2" hoverStyle={{ bg: '$hover' }} cursor="pointer">
<MoreHorizontal size={16} />
</XStack>
}
items={[
...(a.draft ? [] : [{ key: 'run', label: 'Run now', icon: <Play size={16} />, onSelect: onRun }]),
{ key: 'edit', label: 'Edit', icon: <Pencil size={16} />, onSelect: onOpen },
{ key: 'delete', label: 'Delete', icon: <Trash2 size={16} />, onSelect: onDelete },
...(a.draft ? [] : [{ key: 'run', label: t('Run now'), icon: <Play size={16} />, onSelect: onRun }]),
{ key: 'edit', label: t('Edit'), icon: <Pencil size={16} />, onSelect: onOpen },
{ key: 'delete', label: t('Delete'), icon: <Trash2 size={16} />, onSelect: onDelete },
]}
/>
</XStack>
@@ -296,20 +303,21 @@ function Task({ a, first, onOpen, onSwitch, onRun, onDelete }: { a: Automation;
/** One run in Activity. */
function Ran({ j, first, now, onOpen }: { j: Job; first: boolean; now: number; onOpen: () => void }) {
const { t } = useI18n()
return (
<XStack role="listitem" borderTopWidth={first ? 0 : 1} borderColor="$borderColor" hoverStyle={{ bg: '$hover' }}>
<XStack render="button" aria-label={`${j.title}: ${SAID[j.state]}`} onPress={onOpen} flex={1} minW={0} items="center" gap="$3" px="$3" py="$2.5" cursor="pointer" style={{ textAlign: 'left' }}>
<XStack render="button" aria-label={t('{title}: {state}', { title: j.title, state: t(SAID[j.state]) })} onPress={onOpen} flex={1} minW={0} items="center" gap="$3" px="$3" py="$2.5" cursor="pointer" $platform-web={{ textAlign: 'start' }}>
<StatusDot status={DOTS[j.state]} />
<YStack flex={1} minW={0} gap="$0.5">
<SizableText size="$2" color="$ink" numberOfLines={1}>
{j.title}
</SizableText>
<SizableText size="$1" color="$soft" numberOfLines={1}>
{about(j, now)}
{about(j, now, t)}
</SizableText>
</YStack>
<SizableText size="$1" color="$soft" shrink={0}>
{j.state === 'running' && j.progress !== null ? `${Math.round(j.progress * 100)}%` : SAID[j.state]}
{j.state === 'running' && j.progress !== null ? `${Math.round(j.progress * 100)}%` : t(SAID[j.state])}
</SizableText>
</XStack>
</XStack>
@@ -318,24 +326,24 @@ function Ran({ j, first, now, onOpen }: { j: Job; first: boolean; now: number; o
/** One run, opened: how it went, its log, what it made and what it cost. */
function Detail({ j, onClose, now }: { j: Job | null; onClose: () => void; now: number }) {
const { t } = useI18n()
const host = useHost()
return (
<Sheet title={j?.title ?? 'Run'} open={Boolean(j)} onOpenChange={(o) => !o && onClose()} width={520}>
<Sheet title={j?.title ?? t('Run')} open={Boolean(j)} onOpenChange={(o) => !o && onClose()} width={520}>
{j ? (
<YStack gap="$3">
<XStack items="center" gap="$2">
<StatusDot status={DOTS[j.state]} />
<SizableText size="$2" color="$ink">
{SAID[j.state]}
{j.status && j.status !== j.state ? ` (${j.status})` : ''}
{j.status && j.status !== j.state ? t('{state} ({status})', { state: t(SAID[j.state]), status: j.status }) : t(SAID[j.state])}
</SizableText>
</XStack>
<Facts
rows={[
['From', SOURCE[j.product]],
['Started', j.started ? local(j.started) : '—'],
['Took', lasted(j.started, j.finished, now) || '—'],
['Cost', j.cost !== null ? dollars(j.cost) : 'Not known yet'],
[t('From'), t(SOURCE[j.product])],
[t('Started'), j.started ? local(j.started) : '—'],
[t('Took'), lasted(j.started, j.finished, now) || '—'],
[t('Cost'), j.cost !== null ? dollars(j.cost) : t('Not known yet')],
]}
/>
{j.error ? (
@@ -351,7 +359,7 @@ function Detail({ j, onClose, now }: { j: Job | null; onClose: () => void; now:
{j.outputs.length ? (
<YStack gap="$1">
<SizableText size="$2" color="$soft">
What it made
{t('What it made')}
</SizableText>
<Outputs ids={j.outputs} />
</YStack>
@@ -360,19 +368,19 @@ function Detail({ j, onClose, now }: { j: Job | null; onClose: () => void; now:
{j.session ? (
<Button size="sm" variant="outline" onPress={() => host.go(j.session!)}>
<ScrollText size={14} />
Open its log
{t('Open its log')}
</Button>
) : null}
{j.origin ? (
<Button size="sm" variant="outline" onPress={() => host.open(j.origin)}>
<ExternalLink size={14} />
Open where it runs
{t('Open where it runs')}
</Button>
) : null}
{j.automation ? (
<Button size="sm" variant="ghost" onPress={() => host.go(path({ kind: 'automation', id: j.automation! }))}>
<Pencil size={14} />
Edit the task
{t('Edit the task')}
</Button>
) : null}
</XStack>
@@ -384,8 +392,9 @@ function Detail({ j, onClose, now }: { j: Job | null; onClose: () => void; now:
/** A new task: what it does, when in plain words, and the model it thinks with. */
function Make({ prompt, onClose, onMade }: { prompt: string | null; onClose: () => void; onMade: (a: Automation) => void }) {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const signed = Boolean(host.person)
const open = prompt !== null
const [name, setName] = useState('')
@@ -393,14 +402,15 @@ function Make({ prompt, onClose, onMade }: { prompt: string | null; onClose: ()
const [said, setSaid] = useState('')
const [read, setRead] = useState<Parsed | null>(null)
const [reading, setReading] = useState(false)
const [unread, setUnread] = useState('')
// Why the platform could not read the words: its refusal, said when drawn.
const [unread, setUnread] = useState<unknown>(null)
const [picking, setPicking] = useState(false)
const [picked, setPicked] = useState<Schedule>(() => shaped('daily', { kind: 'manual' }))
const [model, setModel] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [note, setNote] = useState('')
const catalog = useRead(signed && open ? () => models(t) : null, [], [t, signed, open])
const { limits } = useLimits(signed && open ? (signal) => readLimits(t, signal) : null, t)
const catalog = useRead(signed && open ? () => models(target) : null, [], [target, signed, open])
const { limits } = useLimits(signed && open ? (signal) => readLimits(target, signal) : null, target)
// Each opening starts from what it was handed.
useEffect(() => {
@@ -409,7 +419,7 @@ function Make({ prompt, onClose, onMade }: { prompt: string | null; onClose: ()
setName(titled(prompt))
setSaid('')
setRead(null)
setUnread('')
setUnread(null)
setPicking(false)
setNote('')
}, [prompt])
@@ -419,21 +429,21 @@ function Make({ prompt, onClose, onMade }: { prompt: string | null; onClose: ()
const text = said.trim()
if (!open || !text || picking) {
setRead(null)
setUnread('')
setUnread(null)
return
}
const ctl = new AbortController()
const id = setTimeout(() => {
setReading(true)
cron(t, text, zone(), { signal: ctl.signal })
cron(target, text, zone(), { signal: ctl.signal })
.then((p) => {
setRead(p)
setUnread('')
setUnread(null)
})
.catch((e: unknown) => {
if (ctl.signal.aborted) return
setRead(null)
setUnread((e as Error).message)
setUnread(e)
})
.finally(() => !ctl.signal.aborted && setReading(false))
}, 450)
@@ -441,86 +451,86 @@ function Make({ prompt, onClose, onMade }: { prompt: string | null; onClose: ()
clearTimeout(id)
ctl.abort()
}
}, [said, open, picking, t])
}, [said, open, picking, target])
const when: Schedule | null = picking ? picked : (read?.schedule ?? null)
const wrong = when ? unready(when) : ''
const wrong = when ? t(unready(when)) : ''
const make = async () => {
if (!name.trim()) return setNote('Name it first.')
if (!asks.trim()) return setNote('Say what it should do.')
if (!when) return setNote(said.trim() ? 'Say when in other words, or choose instead.' : 'Say when it runs.')
if (!name.trim()) return setNote(t('Name it first.'))
if (!asks.trim()) return setNote(t('Say what it should do.'))
if (!when) return setNote(said.trim() ? t('Say when in other words, or choose instead.') : t('Say when it runs.'))
if (wrong) return setNote(wrong)
setBusy(true)
setNote('')
try {
const d: Draft = { name: name.trim(), instructions: asks.trim(), model, schedule: when, permissions: 'ask', notify: true, postTo: [], enabled: true, source: 'dev' }
onMade(await create(t, tidy(d)))
onMade(await create(target, tidy(d)))
onClose()
} catch (e) {
setNote(unplanned(e) ? PLAN : (e as Error).message)
setNote(unplanned(e) ? t(PLAN) : told(e, t))
} finally {
setBusy(false)
}
}
const heard = useMemo(() => (read ? [read.says || words(read.schedule), read.next ? `next ${local(read.next)}` : ''].filter(Boolean).join(' · ') : ''), [read])
const heard = useMemo(() => (read ? [read.says || words(read.schedule, t), read.next ? t('next {when}', { when: local(read.next) }) : ''].filter(Boolean).join(' · ') : ''), [read, t])
return (
<Sheet title="New task" open={open} onOpenChange={(o) => !o && !busy && onClose()} width={560}>
<Sheet title={t('New task')} open={open} onOpenChange={(o) => !o && !busy && onClose()} width={560}>
<YStack gap="$4">
<Field label="Name">
<Input value={name} onChangeText={setName} placeholder="Monday summary" aria-label="Task name" />
<Field label={t('Name')}>
<Input value={name} onChangeText={setName} placeholder={t('Monday summary')} aria-label={t('Task name')} />
</Field>
<Field label="What it does" hint="Each run is one Dev run in this organization, as you. It reads and proposes; it changes nothing on its own.">
<Textarea value={asks} onChangeText={setAsks} placeholder="Summarize what changed in my repositories this week, and what needs me." aria-label="What it does" rows={5} />
<Field label={t('What it does')} hint={t('Each run is one Dev run in this organization, as you. It reads and proposes; it changes nothing on its own.')}>
<Textarea value={asks} onChangeText={setAsks} placeholder={t('Summarize what changed in my repositories this week, and what needs me.')} aria-label={t('What it does')} rows={5} />
</Field>
<Field label="When" hint={picking ? undefined : 'In your own words: every weekday at 8am, every 5 minutes, first of the month.'}>
<Field label={t('When')} hint={picking ? undefined : t('In your own words: every weekday at 8am, every 5 minutes, first of the month.')}>
{picking ? (
<YStack gap="$2">
<When s={picked} onChange={setPicked} />
<XStack>
<Button size="sm" variant="ghost" onPress={() => setPicking(false)}>
Say it instead
{t('Say it instead')}
</Button>
</XStack>
</YStack>
) : (
<YStack gap="$2">
<Input value={said} onChangeText={setSaid} placeholder="Every Monday at 9am" aria-label="When it runs" />
<Input value={said} onChangeText={setSaid} placeholder={t('Every Monday at 9am')} aria-label={t('When it runs')} />
<SizableText size="$1" color={unread ? '$soft' : '$ink'} role="status" minH={18}>
{reading ? 'Reading…' : unread ? `${unread} Choose instead, or say it another way.` : heard}
{reading ? t('Reading…') : unread ? `${told(unread, t)} ${t('Choose instead, or say it another way.')}` : heard}
</SizableText>
<XStack>
<Button size="sm" variant="ghost" onPress={() => setPicking(true)}>
Choose instead
{t('Choose instead')}
</Button>
</XStack>
</YStack>
)}
</Field>
<Field label="Model">
<Field label={t('Model')}>
<XStack>
<ModelPicker
size="sm"
name="Model"
name={t('Model')}
models={catalog.value}
scope="chat"
limits={limits}
value={model ?? ENSO}
onChange={(id) => setModel(id === ENSO ? null : id)}
loading={catalog.loading}
error={catalog.error?.message ?? null}
error={catalog.error ? told(catalog.error, t) : null}
/>
</XStack>
</Field>
<Line>{note}</Line>
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="ghost" disabled={busy} onPress={onClose}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" disabled={busy || !signed} onPress={() => void make()}>
{busy ? 'Creating…' : 'Create'}
{busy ? t('Creating…') : t('Create')}
</Button>
</XStack>
</YStack>
+15 -6
View File
@@ -10,6 +10,7 @@
* host's own `go`.
*/
import { SizableText } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import { SidebarItem, SidebarSection, StatusDot, type SessionStatus } from '@hanzo/ui/chat'
import { useMemo, type ReactNode } from 'react'
@@ -49,14 +50,16 @@ const Note = ({ children }: { children: string }) => (
export function DevSection({
host,
onPick,
label = 'Runs',
label,
children,
}: {
host: Host
onPick?: () => void
/** The runs' heading; absent, "Runs" in the reader's language. */
label?: string
children?: ReactNode
}) {
const { t } = useI18n()
const sessions = useSessions(host)
const r = route(host.path)
// Every row, a place or a run, closes a drawer behind it.
@@ -66,7 +69,7 @@ export function DevSection({
}
return (
<>
{nav(host, host.go).map((g) => (
{nav(host, host.go, t).map((g) => (
<SidebarSection key={g.id} label={g.label}>
{g.links.map((l) => (
<SidebarItem key={l.id} icon={l.icon} active={l.active} onPress={then(l.onPress)}>
@@ -76,9 +79,9 @@ export function DevSection({
</SidebarSection>
))}
{children}
<SidebarSection label={label}>
<SidebarSection label={label ?? t('Runs')}>
{!host.person ? (
<Note>Sign in to see your runs.</Note>
<Note>{t('Sign in to see your runs.')}</Note>
) : sessions.value.length ? (
sessions.value.map((s) => (
<SidebarItem
@@ -87,11 +90,17 @@ export function DevSection({
active={r.kind === 'run' && r.id === s.id}
onPress={then(() => host.go(s.id))}
>
{s.title || 'Untitled run'}
{s.title || t('Untitled run')}
</SidebarItem>
))
) : (
<Note>{sessions.error ? sessions.error.message : sessions.loading ? 'Reading…' : 'No runs yet. Say what to build and it lands here.'}</Note>
<Note>
{sessions.error
? t(sessions.error.message)
: sessions.loading
? t('Reading…')
: `${t('No runs yet.')} ${t('Say what to build and it lands here.')}`}
</Note>
)}
</SidebarSection>
</>
+59 -49
View File
@@ -5,38 +5,46 @@
* to, the one this page acts in, a way to act in another, and logging out.
*
* The email is how the person signs in, so it is shown and not edited here.
*
* Its words are the reader's language (@hanzo/i18n `t`); a name, an email and
* an organization's id are drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { Check, Copy, LogOut } from '@hanzogui/lucide-icons-2'
import { Button, Input, Textarea } from '@hanzo/ui'
import { ChipSelect } from '@hanzo/ui/product'
import { useEffect, useRef, useState } from 'react'
import { told } from '../api/call.ts'
import type { Patch } from '../api/pref.ts'
import { PHOTO, photo, rename } from '../api/profile.ts'
import { useHost, useTarget } from '../host.tsx'
import { usePrefs } from '../prefs.tsx'
import { Card, Field, Group, Heading, Note, Row, Soft } from './ui.tsx'
/** What a person may say they work on, each label English and marked (`phrase`), drawn through `t`. */
export const WORK = [
{ id: 'engineering', label: 'Software engineering' },
{ id: 'data', label: 'Data science' },
{ id: 'design', label: 'Design' },
{ id: 'product', label: 'Product management' },
{ id: 'research', label: 'Research' },
{ id: 'writing', label: 'Writing' },
{ id: 'education', label: 'Education' },
{ id: 'operations', label: 'Operations' },
{ id: 'marketing', label: 'Marketing and sales' },
{ id: 'other', label: 'Something else' },
{ id: 'engineering', label: phrase('Software engineering') },
{ id: 'data', label: phrase('Data science') },
{ id: 'design', label: phrase('Design') },
{ id: 'product', label: phrase('Product management') },
{ id: 'research', label: phrase('Research') },
{ id: 'writing', label: phrase('Writing') },
{ id: 'education', label: phrase('Education') },
{ id: 'operations', label: phrase('Operations') },
{ id: 'marketing', label: phrase('Marketing and sales') },
{ id: 'other', label: phrase('Something else') },
]
/** Long enough for real guidance, short enough to sit in the settings document beside everything else. */
const INSTRUCTIONS = 3000
export function Account() {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const { prefs, save } = usePrefs()
const who = host.person
const [name, setName] = useState(who?.name ?? '')
@@ -67,12 +75,12 @@ export function Account() {
if (!who) {
return (
<YStack gap="$5">
<Heading title="Account" />
<Soft>Sign in to see your account.</Soft>
<Heading title={t('Account')} />
<Soft>{t('Sign in to see your account.')}</Soft>
{host.signIn ? (
<XStack justify="center">
<Button size="sm" onPress={() => host.signIn?.()}>
Sign in
{t('Sign in')}
</Button>
</XStack>
) : null}
@@ -92,11 +100,11 @@ export function Account() {
setBusy(true)
setNote('')
try {
if (renamed) setShownName((await rename(t, name)).displayName || name.trim())
if (renamed) setShownName((await rename(target, name)).displayName || name.trim())
if (Object.keys(patch).length) await save(patch)
setNote('Saved')
setNote(t('Saved'))
} catch (e) {
setNote((e as Error).message)
setNote(told(e, t))
} finally {
setBusy(false)
}
@@ -106,10 +114,10 @@ export function Account() {
setBusy(true)
setNote('')
try {
setFace(await photo(t, file))
setNote('Your photo is saved')
setFace(await photo(target, file))
setNote(t('Your photo is saved'))
} catch (e) {
setNote((e as Error).message)
setNote(told(e, t))
} finally {
setBusy(false)
}
@@ -117,23 +125,25 @@ export function Account() {
const org = host.org
const orgs = host.memberships ?? (org ? [org] : [])
const chosenWork = WORK.find((w) => w.id === work) ?? (work ? { id: work, label: work } : null)
// The list's words in the reader's language; a work the list does not hold is the person's own, as given.
const works = WORK.map((w) => ({ ...w, label: t(w.label) }))
const chosenWork = works.find((w) => w.id === work) ?? (work ? { id: work, label: work } : null)
return (
<YStack gap="$6">
<Heading
title="Account"
detail="Your Hanzo identity, and what Hanzo should know about you."
title={t('Account')}
detail={t('Your Hanzo identity, and what Hanzo should know about you.')}
action={
host.signOut ? (
<Button size="sm" variant="outline" onPress={() => host.signOut?.()}>
<LogOut size={14} /> Log out
<LogOut size={14} /> {t('Log out')}
</Button>
) : undefined
}
/>
<Group title="Profile">
<Group title={t('Profile')}>
<YStack gap="$4">
<XStack items="center" gap="$3">
{face ? (
@@ -154,7 +164,7 @@ export function Account() {
</SizableText>
</YStack>
<Button size="sm" variant="outline" disabled={busy} onPress={() => picker.current?.click()}>
Change photo
{t('Change photo')}
</Button>
<input
ref={picker}
@@ -163,7 +173,7 @@ export function Account() {
hidden
style={{ display: 'none' }}
tabIndex={-1}
aria-label="Photo"
aria-label={t('Photo')}
onChange={(e) => {
const input = e.currentTarget
const file = input.files?.[0]
@@ -172,24 +182,24 @@ export function Account() {
}}
/>
</XStack>
<Field label="Full name">
<Input value={name} onChangeText={setName} aria-label="Full name" placeholder="Your name" />
<Field label={t('Full name')}>
<Input value={name} onChangeText={setName} aria-label={t('Full name')} placeholder={t('Your name')} />
</Field>
<Field label="Email" hint="You sign in with this address, so it is not changed here.">
<Field label={t('Email')} hint={t('You sign in with this address, so it is not changed here.')}>
<SizableText size="$2" color="$soft" numberOfLines={1}>
{who.email || 'No email on this account'}
{who.email || t('No email on this account')}
</SizableText>
</Field>
<Field label="What should Hanzo call you?">
<Input value={callName} onChangeText={setCallName} aria-label="What should Hanzo call you?" placeholder={shownName.split(' ')[0] || 'A first name'} />
<Field label={t('What should Hanzo call you?')}>
<Input value={callName} onChangeText={setCallName} aria-label={t('What should Hanzo call you?')} placeholder={shownName.split(' ')[0] || t('A first name')} />
</Field>
<Field label="What best describes your work?">
<Field label={t('What best describes your work?')}>
<XStack>
<ChipSelect
name="Work"
label={chosenWork?.label ?? 'Choose one'}
name={t('Work')}
label={chosenWork?.label ?? t('Choose one')}
chosen={chosenWork}
items={WORK}
items={works}
onChange={(w) => setWork(w.id)}
placement="bottom-start"
width={260}
@@ -197,20 +207,20 @@ export function Account() {
</XStack>
</Field>
<Field
label="Instructions for Hanzo"
hint={`Every coding run reads these before it starts. ${instructions.length}/${INSTRUCTIONS}`}
label={t('Instructions for Hanzo')}
hint={t('Every coding run reads these before it starts. {used}/{most}', { used: instructions.length, most: INSTRUCTIONS })}
>
<Textarea
value={instructions}
onChangeText={(v) => setInstructions(v.slice(0, INSTRUCTIONS))}
aria-label="Instructions for Hanzo"
placeholder="How you like to work: the languages and tools you prefer, the style to follow, what to avoid."
aria-label={t('Instructions for Hanzo')}
placeholder={t('How you like to work: the languages and tools you prefer, the style to follow, what to avoid.')}
rows={5}
/>
</Field>
<XStack items="center" gap="$3">
<Button size="sm" disabled={busy || !changed} onPress={() => void submit()}>
Save
{t('Save')}
</Button>
<Note>{note}</Note>
</XStack>
@@ -218,18 +228,18 @@ export function Account() {
</Group>
{org ? (
<Group title="Organization" detail="The organization this page acts in. Runs, codebases and settings belong to it.">
<Group title={t('Organization')} detail={t('The organization this page acts in. Runs, codebases and settings belong to it.')}>
<Card>
<Row
first
mono
title={org}
detail={`Organization ID${host.admin ? ' · you are an admin' : ''}`}
detail={host.admin ? t('Organization ID · you are an admin') : t('Organization ID')}
trailing={
<Button
size="icon-sm"
variant="ghost"
aria-label={copied ? 'Organization ID copied' : 'Copy organization ID'}
aria-label={copied ? t('Organization ID copied') : t('Copy organization ID')}
onPress={() => void navigator.clipboard?.writeText(org).then(() => setCopied(true))}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
@@ -240,7 +250,7 @@ export function Account() {
</Group>
) : null}
<Group title="Organizations" detail="Every organization you belong to. Switch to act in another.">
<Group title={t('Organizations')} detail={t('Every organization you belong to. Switch to act in another.')}>
{orgs.length ? (
<Card>
{orgs.map((o, i) => (
@@ -248,13 +258,13 @@ export function Account() {
key={o}
first={i === 0}
title={o}
detail={o === host.org ? `Acting in ${o}${host.admin ? ' as an admin' : ''}` : undefined}
detail={o === host.org ? (host.admin ? t('Acting in {org} as an admin', { org: o }) : t('Acting in {org}', { org: o })) : undefined}
trailing={
o === host.org ? (
<Check size={16} />
) : host.chooseOrg ? (
<Button size="sm" variant="ghost" onPress={() => host.chooseOrg?.(o)}>
Switch
{t('Switch')}
</Button>
) : null
}
@@ -262,7 +272,7 @@ export function Account() {
))}
</Card>
) : (
<Soft>You belong to no organization yet.</Soft>
<Soft>{t('You belong to no organization yet.')}</Soft>
)}
</Group>
</YStack>
+135 -104
View File
@@ -3,8 +3,13 @@
* credits (`Credits`: the balance, grants, top-ups, auto-reload, this month's
* spend and its limit), the cards it pays with, its invoices, and ending the
* plan. Changing plan is the Plans screen; this page says what is true now.
*
* Its words are the reader's language (@hanzo/i18n `t`); a plan's name, a
* card's, an invoice's lines and its status are billing's, drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n, type Say } from '@hanzo/i18n/react'
import { CreditCard, Download, Plus, X } from '@hanzogui/lucide-icons-2'
import { Button, Dialog, DialogContent, DialogTitle, Input, toast } from '@hanzo/ui'
import { useEffect, useState } from 'react'
@@ -30,6 +35,7 @@ import {
type Method,
type Subscription,
} from '../api/billing.ts'
import { told } from '../api/call.ts'
import { useRead } from '../data.ts'
import { useHost, useTarget } from '../host.tsx'
import { Title } from '../meter.tsx'
@@ -40,13 +46,14 @@ import { Credits } from './credits.tsx'
import { Card, day, Field, Group, Heading, Note, Row, Soft } from './ui.tsx'
export function Billing() {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const signed = Boolean(host.person)
const subs = useRead(signed ? () => subscriptions(t) : null, [] as Subscription[], [t, signed])
const cards = useRead(signed ? () => methods(t) : null, [] as Method[], [t, signed])
const bills = useRead(signed ? () => invoices(t) : null, [] as Invoice[], [t, signed])
const billed = useRead(signed ? () => tier(t) : null, null, [t, signed])
const subs = useRead(signed ? () => subscriptions(target) : null, [] as Subscription[], [target, signed])
const cards = useRead(signed ? () => methods(target) : null, [] as Method[], [target, signed])
const bills = useRead(signed ? () => invoices(target) : null, [] as Invoice[], [target, signed])
const billed = useRead(signed ? () => tier(target) : null, null, [target, signed])
const [adding, setAdding] = useState(false)
const [ending, setEnding] = useState(false)
const [editingBilling, setEditingBilling] = useState<Method | null>(null)
@@ -57,8 +64,8 @@ export function Billing() {
if (!signed) {
return (
<YStack gap="$5">
<Heading title="Billing" />
<Soft>Sign in to see this organization’s billing.</Soft>
<Heading title={t('Billing')} />
<Soft>{t('Sign in to see this organization’s billing.')}</Soft>
</YStack>
)
}
@@ -82,7 +89,7 @@ export function Billing() {
after()
toast.success(done)
} catch (e) {
setNote((e as Error).message)
setNote(told(e, t))
} finally {
setWorking(false)
}
@@ -90,57 +97,60 @@ export function Billing() {
return (
<YStack gap="$6">
<Heading title="Billing" detail={`The plan ${host.org ?? 'this organization'} is on, the cards it pays with, and its invoices.`} />
<Heading
title={t('Billing')}
detail={host.org ? t('The plan {org} is on, the cards it pays with, and its invoices.', { org: host.org }) : t('The plan this organization is on, the cards it pays with, and its invoices.')}
/>
<XStack items="center" gap="$3" px="$4" py="$4" borderWidth={1} borderColor="$borderColor" rounded="$3" flexWrap="wrap">
<YStack flex={1} minW={200} gap="$1" data-slot="billing-plan">
{reading ? (
<SizableText size="$5" color="$ink">
Reading your plan…
{t('Reading your plan…')}
</SizableText>
) : (
<Title label={title ?? { name: 'Free', tag: '' }} size="$5" />
<Title label={title ?? { name: phrase('Free'), tag: '' }} size="$5" />
)}
<SizableText size="$2" color="$soft">
{subs.error ? subs.error.message : plan ? terms(plan) : title ? 'Your plan.' : 'No paid plan. Usage is paid from credits.'}
{subs.error ? told(subs.error, t) : plan ? terms(plan, t) : title ? t('Your plan.') : t('No paid plan. Usage is paid from credits.')}
</SizableText>
</YStack>
<Button size="sm" variant="outline" onPress={() => host.go(path({ kind: 'screen', screen: 'plans' }))}>
Adjust plan
{t('Adjust plan')}
</Button>
</XStack>
<Credits target={t} admin={host.admin} onCard={() => setAdding(true)} />
<Credits target={target} admin={host.admin} onCard={() => setAdding(true)} />
<Group
title="Payment"
detail="The card a plan renews on and a top-up charges."
title={t('Payment')}
detail={t('The card a plan renews on and a top-up charges.')}
action={
host.admin && (cards.error || cards.value.length > 0) ? (
<Button size="sm" variant="outline" onPress={() => setAdding(true)}>
<Plus size={14} /> Add card
<Plus size={14} /> {t('Add card')}
</Button>
) : undefined
}
>
{cards.error ? (
<Soft>{cards.error.message}</Soft>
<Soft>{told(cards.error, t)}</Soft>
) : cards.loading && !cards.value.length ? (
<Soft>Reading cards…</Soft>
<Soft>{t('Reading cards…')}</Soft>
) : empty ? (
<XStack items="center" gap="$3" px="$4" py="$4" borderWidth={1} borderColor="$borderColor" rounded="$3" flexWrap="wrap">
<CreditCard size={20} />
<YStack flex={1} minW={180} gap="$1">
<SizableText size="$3" color="$ink">
No card on file.
{t('No card on file.')}
</SizableText>
<SizableText size="$2" color="$soft">
{host.admin ? 'Add one to pay for a plan or a top-up. Adding it charges nothing.' : 'An org admin adds cards.'}
{host.admin ? t('Add one to pay for a plan or a top-up. Adding it charges nothing.') : t('An org admin adds cards.')}
</SizableText>
</YStack>
{host.admin ? (
<Button variant="primary" onPress={() => setAdding(true)}>
<Plus size={16} /> Add card
<Plus size={16} /> {t('Add card')}
</Button>
) : null}
</XStack>
@@ -151,11 +161,11 @@ export function Billing() {
key={m.id}
first={i === 0}
leading={<CreditCard size={16} />}
title={label(m)}
title={method(m, t)}
detail={
[
m.expires ? `Expires ${m.expires}` : '',
m.default ? 'Default' : '',
m.expires ? t('Expires {date}', { date: m.expires }) : '',
m.default ? t('Default') : '',
m.billingAddress ? formatAddress(m.billingAddress) : '',
]
.filter(Boolean)
@@ -168,30 +178,30 @@ export function Billing() {
<Button
size="sm"
variant="ghost"
aria-label={`Make ${label(m)} default`}
aria-label={t('Make {card} default', { card: method(m, t) })}
disabled={working}
onPress={() => void act(() => makeDefault(t, m.id), `${label(m)} is now the default`, cards.reload)}
onPress={() => void act(() => makeDefault(target, m.id), t('{card} is now the default', { card: method(m, t) }), cards.reload)}
>
Make default
{t('Make default')}
</Button>
) : null}
<Button
size="sm"
variant="ghost"
aria-label={`Edit billing info for ${label(m)}`}
aria-label={t('Edit billing info for {card}', { card: method(m, t) })}
disabled={working}
onPress={() => setEditingBilling(m)}
>
Edit info
{t('Edit info')}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Remove ${label(m)}`}
aria-label={t('Remove {card}', { card: method(m, t) })}
disabled={working}
onPress={() => void act(() => detach(t, m.id), `${label(m)} is removed`, cards.reload)}
onPress={() => void act(() => detach(target, m.id), t('{card} is removed', { card: method(m, t) }), cards.reload)}
>
Remove
{t('Remove')}
</Button>
</XStack>
) : undefined
@@ -202,18 +212,18 @@ export function Billing() {
)}
{!host.admin && cards.value.length ? (
<SizableText size="$1" color="$soft">
An org admin adds and removes cards.
{t('An org admin adds and removes cards.')}
</SizableText>
) : null}
</Group>
<Group
title="Billing details"
detail="Information printed on invoices and payment receipts."
title={t('Billing details')}
detail={t('Information printed on invoices and payment receipts.')}
action={
host.admin && defaultCard ? (
<Button size="sm" variant="outline" onPress={() => setEditingBilling(defaultCard)}>
{defaultCard.billingAddress ? 'Edit details' : 'Add billing info'}
{defaultCard.billingAddress ? t('Edit details') : t('Add billing info')}
</Button>
) : undefined
}
@@ -250,10 +260,10 @@ export function Billing() {
<XStack items="center" justify="space-between" gap="$3" px="$4" py="$3" borderWidth={1} borderColor="$borderColor" rounded="$3" flexWrap="wrap">
<YStack flex={1} minW={180} gap="$1">
<SizableText size="$2" color="$ink">
No billing address on file.
{t('No billing address on file.')}
</SizableText>
<SizableText size="$1" color="$soft">
Add your company name and address so they appear on monthly invoices and receipts.
{t('Add your company name and address so they appear on monthly invoices and receipts.')}
</SizableText>
</YStack>
{host.admin ? (
@@ -262,33 +272,33 @@ export function Billing() {
variant="outline"
onPress={() => (defaultCard ? setEditingBilling(defaultCard) : setAdding(true))}
>
<Plus size={14} /> {defaultCard ? 'Add billing info' : 'Set billing info'}
<Plus size={14} /> {defaultCard ? t('Add billing info') : t('Set billing info')}
</Button>
) : null}
</XStack>
)}
</Group>
<Group title="Invoices">
<Group title={t('Invoices')}>
{bills.error ? (
<Soft>{bills.error.message}</Soft>
<Soft>{told(bills.error, t)}</Soft>
) : bills.loading && !bills.value.length ? (
<Soft>Reading invoices…</Soft>
<Soft>{t('Reading invoices…')}</Soft>
) : bills.value.length === 0 ? (
<Card>
<Soft>No invoices yet.</Soft>
<Soft>{t('No invoices yet.')}</Soft>
</Card>
) : (
<Card>
<XStack px="$3" py="$2" gap="$3">
<SizableText flex={1} size="$1" color="$soft">
Date
{t('Date')}
</SizableText>
<SizableText width={90} size="$1" color="$soft" style={{ textAlign: 'right' }}>
Total
<SizableText width={90} size="$1" color="$soft" text="end">
{t('Total')}
</SizableText>
<SizableText width={70} size="$1" color="$soft" $max-sm={{ display: 'none' }}>
Status
{t('Status')}
</SizableText>
<YStack width={64} />
</XStack>
@@ -297,15 +307,15 @@ export function Billing() {
<SizableText flex={1} size="$2" color="$ink" numberOfLines={1}>
{day(b.date) || b.number || b.id}
</SizableText>
<SizableText width={90} size="$2" color="$ink" style={{ textAlign: 'right' }}>
<SizableText width={90} size="$2" color="$ink" text="end">
{money(b.total, b.currency)}
</SizableText>
<SizableText width={70} size="$1" color="$soft" $max-sm={{ display: 'none' }}>
{word(b.status)}
</SizableText>
<XStack width={64} justify="flex-end">
<Button size="sm" variant="ghost" aria-label={`View invoice ${b.number || day(b.date)}`} onPress={() => setOpen(b)}>
View
<Button size="sm" variant="ghost" aria-label={t('View invoice {invoice}', { invoice: b.number || day(b.date) })} onPress={() => setOpen(b)}>
{t('View')}
</Button>
</XStack>
</XStack>
@@ -315,20 +325,20 @@ export function Billing() {
</Group>
{plan ? (
<Group title="Cancellation">
<Group title={t('Cancellation')}>
<Card>
<Row
first
title={plan.ending ? `Your plan ends on ${day(plan.ends)}` : 'Cancel plan'}
detail={plan.ending ? 'Keep it, and it renews as before.' : 'It runs to the end of the period you paid for, then ends.'}
title={plan.ending ? t('Your plan ends on {date}', { date: day(plan.ends) }) : t('Cancel plan')}
detail={plan.ending ? t('Keep it, and it renews as before.') : t('It runs to the end of the period you paid for, then ends.')}
trailing={
!host.admin ? undefined : plan.ending ? (
<Button size="sm" variant="outline" disabled={working} onPress={() => void act(() => reactivate(t, plan.id), 'Your plan renews as before', subs.reload)}>
Keep plan
<Button size="sm" variant="outline" disabled={working} onPress={() => void act(() => reactivate(target, plan.id), t('Your plan renews as before'), subs.reload)}>
{t('Keep plan')}
</Button>
) : (
<Button size="sm" variant="destructive" disabled={working} onPress={() => setEnding(true)}>
Cancel
{t('Cancel')}
</Button>
)
}
@@ -344,7 +354,7 @@ export function Billing() {
onOpenChange={setAdding}
onSaved={(m) => {
cards.reload()
toast.success(`${label(m)} is saved`)
toast.success(t('{card} is saved', { card: method(m, t) }))
}}
/>
<EditBilling
@@ -362,7 +372,7 @@ export function Billing() {
working={working}
onOpenChange={setEnding}
onConfirm={() =>
void act(() => cancel(t, plan.id), `Your plan ends on ${day(plan.ends)}`, () => {
void act(() => cancel(target, plan.id), t('Your plan ends on {date}', { date: day(plan.ends) }), () => {
setEnding(false)
subs.reload()
})
@@ -385,7 +395,8 @@ function EditBilling({
card: Method | null
onSaved: () => void
}) {
const t = useTarget()
const { t } = useI18n()
const target = useTarget()
const [name, setName] = useState('')
const [line1, setLine1] = useState('')
const [line2, setLine2] = useState('')
@@ -431,12 +442,12 @@ function EditBilling({
postalCode: postalCode.trim() || undefined,
country: country.trim() || undefined,
}
await updateMethod(t, card.id, { billingAddress: address })
await updateMethod(target, card.id, { billingAddress: address })
onSaved()
onOpenChange(false)
toast.success('Billing details updated')
toast.success(t('Billing details updated'))
} catch (e) {
setNote((e as Error).message)
setNote(told(e, t))
} finally {
setWorking(false)
}
@@ -446,45 +457,45 @@ function EditBilling({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent maxW={460} showCloseButton={false}>
<XStack items="center" justify="space-between" gap="$2">
<DialogTitle>Billing details</DialogTitle>
<XStack render="button" aria-label="Close" p="$1" onPress={() => onOpenChange(false)}>
<DialogTitle>{t('Billing details')}</DialogTitle>
<XStack render="button" aria-label={t('Close')} p="$1" onPress={() => onOpenChange(false)}>
<X size={16} />
</XStack>
</XStack>
<YStack gap="$3">
<SizableText size="$2" color="$soft">
These details appear on your invoices and receipts.
{t('These details appear on your invoices and receipts.')}
</SizableText>
<Field label="Business or personal name">
<Input value={name} onChangeText={setName} placeholder="e.g. Webby AI, Inc. or Joshua Lynch" />
<Field label={t('Business or personal name')}>
<Input value={name} onChangeText={setName} placeholder={t('e.g. Webby AI, Inc. or Joshua Lynch')} />
</Field>
<Field label="Address line 1">
<Input value={line1} onChangeText={setLine1} placeholder="Street address or P.O. Box" />
<Field label={t('Address line 1')}>
<Input value={line1} onChangeText={setLine1} placeholder={t('Street address or P.O. Box')} />
</Field>
<Field label="Address line 2" hint="Optional">
<Input value={line2} onChangeText={setLine2} placeholder="Suite, unit, building, floor" />
<Field label={t('Address line 2')} hint={t('Optional')}>
<Input value={line2} onChangeText={setLine2} placeholder={t('Suite, unit, building, floor')} />
</Field>
<XStack gap="$2">
<YStack flex={1}>
<Field label="City">
<Input value={city} onChangeText={setCity} placeholder="City" />
<Field label={t('City')}>
<Input value={city} onChangeText={setCity} placeholder={t('City')} />
</Field>
</YStack>
<YStack width={100}>
<Field label="State">
<Input value={stateVal} onChangeText={setStateVal} placeholder="State / Prov" />
<Field label={t('State')}>
<Input value={stateVal} onChangeText={setStateVal} placeholder={t('State / Prov')} />
</Field>
</YStack>
</XStack>
<XStack gap="$2">
<YStack flex={1}>
<Field label="Postal / ZIP code">
<Input value={postalCode} onChangeText={setPostalCode} placeholder="ZIP or Postal Code" />
<Field label={t('Postal / ZIP code')}>
<Input value={postalCode} onChangeText={setPostalCode} placeholder={t('ZIP or Postal Code')} />
</Field>
</YStack>
<YStack width={110}>
<Field label="Country">
<Input value={country} onChangeText={setCountry} placeholder="Country (US)" />
<Field label={t('Country')}>
<Input value={country} onChangeText={setCountry} placeholder={t('Country (US)')} />
</Field>
</YStack>
</XStack>
@@ -495,10 +506,10 @@ function EditBilling({
) : null}
<XStack gap="$2" justify="flex-end" pt="$2">
<Button size="sm" variant="ghost" disabled={working} onPress={() => onOpenChange(false)}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" disabled={working} onPress={() => void save()}>
Save details
{t('Save details')}
</Button>
</XStack>
</YStack>
@@ -507,12 +518,28 @@ function EditBilling({
)
}
/** What the plan costs, and when it renews or ends. */
function terms(p: Subscription): string {
const cost = `${money(p.price * p.seats, p.currency)} a ${p.interval}${p.seats > 1 ? ` for ${p.seats} seats` : ''}.`
return p.ends ? `${cost} ${p.ending ? 'Ends' : 'Renews'} on ${day(p.ends)}.` : cost
/** What a plan costs by its interval, alone and for its seats: one sentence each, so a language says the interval its own way. */
const COST: Record<string, { one: string; seats: string }> = {
month: { one: phrase('{amount} a month.'), seats: phrase('{amount} a month for {seats} seats.') },
year: { one: phrase('{amount} a year.'), seats: phrase('{amount} a year for {seats} seats.') },
}
/** What the plan costs, and when it renews or ends, in the words of `t`. */
function terms(p: Subscription, t: Say): string {
const amount = money(p.price * p.seats, p.currency)
const by = COST[p.interval]
const cost = by
? t(p.seats > 1 ? by.seats : by.one, { amount, seats: p.seats })
: p.seats > 1
? t('{amount} a {interval} for {seats} seats.', { amount, interval: p.interval, seats: p.seats })
: t('{amount} a {interval}.', { amount, interval: p.interval })
if (!p.ends) return cost
return `${cost} ${p.ending ? t('Ends on {date}.', { date: day(p.ends) }) : t('Renews on {date}.', { date: day(p.ends) })}`
}
/** A saved method as a row names it (api/billing.ts `label`); a card with no brand is said in the words of `t`. */
const method = (m: Method, t: Say): string => (m.last4 && !m.brand ? t('Card •••• {last4}', { last4: m.last4 }) : label(m))
const word = (s: string): string => (s ? s.charAt(0).toUpperCase() + s.slice(1).replace(/_/g, ' ') : '')
function Ending({
@@ -528,20 +555,23 @@ function Ending({
onOpenChange: (o: boolean) => void
onConfirm: () => void
}) {
const { t } = useI18n()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent maxW={420} showCloseButton={false}>
<DialogTitle>Cancel your plan?</DialogTitle>
<DialogTitle>{t('Cancel your plan?')}</DialogTitle>
<YStack gap="$3">
<SizableText size="$2" color="$soft">
{`${plan.name} stays on until ${day(plan.ends) || 'the end of this period'}, and nothing is charged after that. You can keep it any time before then.`}
{day(plan.ends)
? t('{plan} stays on until {date}, and nothing is charged after that. You can keep it any time before then.', { plan: plan.name, date: day(plan.ends) })
: t('{plan} stays on until the end of this period, and nothing is charged after that. You can keep it any time before then.', { plan: plan.name })}
</SizableText>
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="ghost" onPress={() => onOpenChange(false)}>
Keep plan
{t('Keep plan')}
</Button>
<Button size="sm" variant="destructive" disabled={working} onPress={onConfirm}>
Cancel plan
{t('Cancel plan')}
</Button>
</XStack>
</YStack>
@@ -552,7 +582,8 @@ function Ending({
/** One invoice, read in the page, with its PDF to keep. Drawn while it is open, so each opens anew. */
function Bill({ invoice: b, onClose }: { invoice: Invoice; onClose: () => void }) {
const t = useTarget()
const { t } = useI18n()
const target = useTarget()
const [note, setNote] = useState('')
const [working, setWorking] = useState(false)
@@ -560,7 +591,7 @@ function Bill({ invoice: b, onClose }: { invoice: Invoice; onClose: () => void }
setWorking(true)
setNote('')
try {
const blob = await pdf(t, b.id)
const blob = await pdf(target, b.id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@@ -568,7 +599,7 @@ function Bill({ invoice: b, onClose }: { invoice: Invoice; onClose: () => void }
a.click()
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (e) {
setNote((e as Error).message)
setNote(told(e, t))
} finally {
setWorking(false)
}
@@ -589,30 +620,30 @@ function Bill({ invoice: b, onClose }: { invoice: Invoice; onClose: () => void }
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent maxW={480} showCloseButton={false}>
<XStack items="center" justify="space-between" gap="$2">
<DialogTitle>{b.number ? `Invoice ${b.number}` : 'Invoice'}</DialogTitle>
<XStack render="button" aria-label="Close" p="$1" onPress={onClose}>
<DialogTitle>{b.number ? t('Invoice {number}', { number: b.number }) : t('Invoice')}</DialogTitle>
<XStack render="button" aria-label={t('Close')} p="$1" onPress={onClose}>
<X size={16} />
</XStack>
</XStack>
<YStack gap="$3">
<SizableText size="$2" color="$soft">
{[day(b.date), word(b.status), b.start && b.end ? `for ${day(b.start)} – ${day(b.end)}` : ''].filter(Boolean).join(' · ')}
{[day(b.date), word(b.status), b.start && b.end ? t('for {start} – {end}', { start: day(b.start), end: day(b.end) }) : ''].filter(Boolean).join(' · ')}
</SizableText>
<YStack gap="$1.5" borderTopWidth={1} borderColor="$borderColor" pt="$3">
{b.lines.length ? b.lines.map((l, i) => line(l.description || `Line ${i + 1}`, l.amount)) : line('Subtotal', b.subtotal)}
{b.lines.length ? b.lines.map((l, i) => line(l.description || t('Line {n}', { n: i + 1 }), l.amount)) : line(t('Subtotal'), b.subtotal)}
</YStack>
<YStack gap="$1.5" borderTopWidth={1} borderColor="$borderColor" pt="$3">
{b.discount ? line('Discount', -b.discount) : null}
{b.tax ? line('Tax', b.tax) : null}
{line('Total', b.total, true)}
{b.credit ? line('Credit applied', -b.credit) : null}
{line('Paid', b.paid)}
{b.due ? line('Due', b.due, true) : null}
{b.discount ? line(t('Discount'), -b.discount) : null}
{b.tax ? line(t('Tax'), b.tax) : null}
{line(t('Total'), b.total, true)}
{b.credit ? line(t('Credit applied'), -b.credit) : null}
{line(t('Paid'), b.paid)}
{b.due ? line(t('Due'), b.due, true) : null}
</YStack>
<Note>{note}</Note>
<XStack justify="flex-end">
<Button size="sm" variant="outline" disabled={working} onPress={() => void keep()}>
<Download size={14} /> Download PDF
<Download size={14} /> {t('Download PDF')}
</Button>
</XStack>
</YStack>
+37 -24
View File
@@ -4,38 +4,45 @@
* servers. A tool that is off is still listed to the agent's discovery but is
* refused when called. Switching a kind on turns on every tool of that kind;
* off turns them all off. It is the organization's setting, not only yours.
*
* Its words are the reader's language (@hanzo/i18n `t`).
*/
import { SizableText, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { Switch } from '@hanzo/ui'
import { useState } from 'react'
import { told } from '../api/call.ts'
import { activate, SOURCES, tools, type Source, type Tool } from '../api/capabilities.ts'
import { useRead } from '../data.ts'
import { useHost, useTarget } from '../host.tsx'
import { Card, Group, Heading, Note, Row, Soft } from './ui.tsx'
/** Each kind's name and the sentence that says what it is, English and marked (`phrase`), drawn through `t`. */
const KINDS: Record<Source, { label: string; about: string }> = {
connector: { label: 'Connector actions', about: 'Actions in the apps your organization connected' },
function: { label: 'Functions', about: 'Functions your organization wrote' },
'zap-service': { label: 'Services', about: 'Routes of the services your organization runs' },
agent: { label: 'Agents', about: 'Your organization’s agents, called as tools' },
skill: { label: 'Skills', about: 'Skills attached to agents' },
mcp: { label: 'MCP servers', about: 'Tools on the MCP servers your organization added' },
connector: { label: phrase('Connector actions'), about: phrase('Actions in the apps your organization connected.') },
function: { label: phrase('Functions'), about: phrase('Functions your organization wrote.') },
'zap-service': { label: phrase('Services'), about: phrase('Routes of the services your organization runs.') },
agent: { label: phrase('Agents'), about: phrase('Your organization’s agents, called as tools.') },
skill: { label: phrase('Skills'), about: phrase('Skills attached to agents.') },
mcp: { label: phrase('MCP servers'), about: phrase('Tools on the MCP servers your organization added.') },
}
export function Capabilities() {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const signed = Boolean(host.person)
const list = useRead(signed ? () => tools(t) : null, [] as Tool[], [t, signed])
const list = useRead(signed ? () => tools(target) : null, [] as Tool[], [target, signed])
const [busy, setBusy] = useState<Source | ''>('')
const [note, setNote] = useState('')
if (!signed) {
return (
<YStack gap="$5">
<Heading title="Capabilities" />
<Soft>Sign in to see what the agent may use.</Soft>
<Heading title={t('Capabilities')} />
<Soft>{t('Sign in to see what the agent may use.')}</Soft>
</YStack>
)
}
@@ -46,10 +53,10 @@ export function Capabilities() {
setBusy(source)
setNote('')
try {
await activate(t, all.map((x) => x.name), on)
await activate(target, all.map((x) => x.name), on)
list.reload()
} catch (e) {
setNote((e as Error).message)
setNote(told(e, t))
} finally {
setBusy('')
}
@@ -58,44 +65,50 @@ export function Capabilities() {
return (
<YStack gap="$6">
<Heading
title="Capabilities"
detail={`What the agent may use in ${host.org ?? 'this organization'}. A tool that is off is refused when the agent calls it.`}
title={t('Capabilities')}
detail={
host.org
? t('What the agent may use in {org}. A tool that is off is refused when the agent calls it.', { org: host.org })
: t('What the agent may use in this organization. A tool that is off is refused when the agent calls it.')
}
/>
<Group
title="Tools"
title={t('Tools')}
detail={
host.admin
? 'Switching a kind on or off applies to every tool of that kind, for everyone in the organization.'
: 'An org admin switches these, for everyone in the organization.'
? t('Switching a kind on or off applies to every tool of that kind, for everyone in the organization.')
: t('An org admin switches these, for everyone in the organization.')
}
>
{list.error ? (
<Soft>{list.error.message}</Soft>
<Soft>{told(list.error, t)}</Soft>
) : list.loading && !list.value.length ? (
<Soft>Reading tools…</Soft>
<Soft>{t('Reading tools…')}</Soft>
) : !kinds.length ? (
<Soft>No tools are available to this organization yet.</Soft>
<Soft>{t('No tools are available to this organization yet.')}</Soft>
) : (
<Card>
{kinds.map(({ source, all }, i) => {
const on = all.filter((x) => x.activated).length
// What the kind is, then how much of it is on: two sentences.
const count = on === all.length ? t('All {n} on.', { n: all.length }) : on ? t('{on} of {n} on.', { on, n: all.length }) : t('{n} available, all off.', { n: all.length })
return (
<Row
key={source}
first={i === 0}
title={KINDS[source].label}
detail={`${KINDS[source].about}. ${on === all.length ? `All ${all.length} on` : on ? `${on} of ${all.length} on` : `${all.length} available, all off`}.`}
title={t(KINDS[source].label)}
detail={`${t(KINDS[source].about)} ${count}`}
trailing={
host.admin ? (
<Switch
aria-label={KINDS[source].label}
aria-label={t(KINDS[source].label)}
checked={on === all.length}
disabled={busy !== ''}
onCheckedChange={(v: boolean) => void flip(source, all, v)}
/>
) : (
<SizableText size="$1" color="$soft">
{on === all.length ? 'On' : on ? 'Partly on' : 'Off'}
{on === all.length ? t('On') : on ? t('Partly on') : t('Off')}
</SizableText>
)
}
+37 -30
View File
@@ -5,13 +5,19 @@
* the card from that. Which processor, and its sandbox or live account, is the
* platform's answer at /v1/billing/settings, so the card is saved to the account
* it will be charged on.
*
* Its words are the reader's language (@hanzo/i18n `t`); what the processor's
* field says of a card is the processor's, drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { ChevronDown, ChevronUp, X } from '@hanzogui/lucide-icons-2'
import { Button, Dialog, DialogContent, DialogTitle, Input } from '@hanzo/ui'
import { useEffect, useRef, useState } from 'react'
import { processor, save, type Address, type Method, type Processor } from '../api/billing.ts'
import { told } from '../api/call.ts'
import { useRead } from '../data.ts'
import { useTarget } from '../host.tsx'
import { Field as FormField } from './ui.tsx'
@@ -47,10 +53,10 @@ function load(environment: string): Promise<Sdk> {
const s = document.createElement('script')
s.src = src
s.async = true
s.onload = () => (window.Square ? resolve(window.Square) : reject(new Error('The card form did not load')))
s.onload = () => (window.Square ? resolve(window.Square) : reject(new Error(phrase('The card form did not load'))))
s.onerror = () => {
loads.delete(src)
reject(new Error('The card form did not load'))
reject(new Error(phrase('The card form did not load')))
}
document.head.appendChild(s)
})
@@ -61,8 +67,9 @@ function load(environment: string): Promise<Sdk> {
const ready = (p: Processor | null): p is Processor => Boolean(p && p.provider === 'square' && p.application && p.location)
export function AddCard({ open, onOpenChange, onSaved }: { open: boolean; onOpenChange: (o: boolean) => void; onSaved: (m: Method) => void }) {
const t = useTarget()
const cfg = useRead(open ? () => processor(t) : null, null as Processor | null, [t, open])
const { t } = useI18n()
const target = useTarget()
const cfg = useRead(open ? () => processor(target) : null, null as Processor | null, [target, open])
const [box, setBox] = useState<HTMLDivElement | null>(null)
const field = useRef<Field | null>(null)
const [mounted, setMounted] = useState(false)
@@ -94,7 +101,7 @@ export function AddCard({ open, onOpenChange, onSaved }: { open: boolean; onOpen
setMounted(true)
})
.catch((e: unknown) => {
if (!gone) setNote(e instanceof Error ? e.message : 'The card form did not load')
if (!gone) setNote(e instanceof Error ? told(e, t) : t('The card form did not load'))
})
return () => {
gone = true
@@ -112,7 +119,7 @@ export function AddCard({ open, onOpenChange, onSaved }: { open: boolean; onOpen
setNote('')
try {
const r = await f.tokenize()
if (r.status !== 'OK' || !r.token) throw new Error(r.errors?.map((e) => e.message).filter(Boolean).join('; ') || 'The card details are incomplete')
if (r.status !== 'OK' || !r.token) throw new Error(r.errors?.map((e) => e.message).filter(Boolean).join('; ') || phrase('The card details are incomplete'))
const billingAddress: Address | undefined =
name.trim() || postalCode.trim() || line1.trim() || city.trim() || stateVal.trim()
? {
@@ -124,24 +131,24 @@ export function AddCard({ open, onOpenChange, onSaved }: { open: boolean; onOpen
country: country.trim() || 'US',
}
: undefined
const m = await save(t, r.token, billingAddress)
const m = await save(target, r.token, billingAddress)
onSaved(m)
onOpenChange(false)
} catch (e) {
setNote(e instanceof Error ? e.message : 'The card was not saved')
setNote(e instanceof Error ? told(e, t) : t('The card was not saved'))
} finally {
setWorking(false)
}
}
const why = cfg.error ? cfg.error.message : !cfg.loading && !ready(p) ? 'This deployment takes no cards yet.' : ''
const why = cfg.error ? told(cfg.error, t) : !cfg.loading && !ready(p) ? t('This deployment takes no cards yet.') : ''
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent maxW={460} showCloseButton={false}>
<XStack items="center" justify="space-between" gap="$2">
<DialogTitle>Add a card</DialogTitle>
<XStack render="button" aria-label="Close" p="$1" onPress={() => onOpenChange(false)}>
<DialogTitle>{t('Add a card')}</DialogTitle>
<XStack render="button" aria-label={t('Close')} p="$1" onPress={() => onOpenChange(false)}>
<X size={16} />
</XStack>
</XStack>
@@ -152,24 +159,24 @@ export function AddCard({ open, onOpenChange, onSaved }: { open: boolean; onOpen
</SizableText>
) : (
<>
<YStack minH={96} aria-label="Card details">
<YStack minH={96} aria-label={t('Card details')}>
<div ref={setBox} style={{ minHeight: 96 }} />
{!mounted && !note ? (
<SizableText size="$1" color="$soft">
Loading the card form…
{t('Loading the card form…')}
</SizableText>
) : null}
</YStack>
<XStack gap="$2">
<YStack flex={1}>
<FormField label="Name on card" hint="Optional">
<Input value={name} onChangeText={setName} placeholder="Name or business" />
<FormField label={t('Name on card')} hint={t('Optional')}>
<Input value={name} onChangeText={setName} placeholder={t('Name or business')} />
</FormField>
</YStack>
<YStack width={120}>
<FormField label="Postal code" hint="Optional">
<Input value={postalCode} onChangeText={setPostalCode} placeholder="ZIP / Postal" />
<FormField label={t('Postal code')} hint={t('Optional')}>
<Input value={postalCode} onChangeText={setPostalCode} placeholder={t('ZIP / Postal')} />
</FormField>
</YStack>
</XStack>
@@ -177,29 +184,29 @@ export function AddCard({ open, onOpenChange, onSaved }: { open: boolean; onOpen
<XStack>
<Button size="sm" variant="ghost" onPress={() => setMoreAddress(!moreAddress)}>
{moreAddress ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
{moreAddress ? 'Hide address' : 'Add billing address'}
{moreAddress ? t('Hide address') : t('Add billing address')}
</Button>
</XStack>
{moreAddress ? (
<YStack gap="$2" pt="$1">
<FormField label="Street address">
<Input value={line1} onChangeText={setLine1} placeholder="Address line 1" />
<FormField label={t('Street address')}>
<Input value={line1} onChangeText={setLine1} placeholder={t('Address line 1')} />
</FormField>
<XStack gap="$2">
<YStack flex={1}>
<FormField label="City">
<Input value={city} onChangeText={setCity} placeholder="City" />
<FormField label={t('City')}>
<Input value={city} onChangeText={setCity} placeholder={t('City')} />
</FormField>
</YStack>
<YStack width={90}>
<FormField label="State">
<Input value={stateVal} onChangeText={setStateVal} placeholder="State" />
<FormField label={t('State')}>
<Input value={stateVal} onChangeText={setStateVal} placeholder={t('State')} />
</FormField>
</YStack>
<YStack width={90}>
<FormField label="Country">
<Input value={country} onChangeText={setCountry} placeholder="Country (US)" />
<FormField label={t('Country')}>
<Input value={country} onChangeText={setCountry} placeholder={t('Country (US)')} />
</FormField>
</YStack>
</XStack>
@@ -208,8 +215,8 @@ export function AddCard({ open, onOpenChange, onSaved }: { open: boolean; onOpen
<SizableText size="$1" color="$soft">
{p && p.environment !== 'production'
? 'Sandbox: use a test card. Nothing is charged.'
: 'The card number goes to the payment processor, never to Hanzo. Saving it charges nothing.'}
? t('Sandbox: use a test card. Nothing is charged.')
: t('The card number goes to the payment processor, never to Hanzo. Saving it charges nothing.')}
</SizableText>
</>
)}
@@ -220,10 +227,10 @@ export function AddCard({ open, onOpenChange, onSaved }: { open: boolean; onOpen
) : null}
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="ghost" onPress={() => onOpenChange(false)}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" disabled={!mounted || working} onPress={() => void add()}>
Save card
{t('Save card')}
</Button>
</XStack>
</YStack>
+48 -37
View File
@@ -4,13 +4,18 @@
* — and the model and effort, which are the one choice Chat and Dev share
* (mind.tsx): changed here, changed in both composers. Beside them, the
* codebases' environments and the organization's machines, each on its own page.
*
* Its words are the reader's language (@hanzo/i18n `t`); a model's and a
* machine's names are drawn as given.
*/
import { YStack } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import { ChevronRight } from '@hanzogui/lucide-icons-2'
import { ChipSelect } from '@hanzo/ui/product'
import { ModelPicker } from '@hanzo/ui/models'
import { useState } from 'react'
import { told } from '../api/call.ts'
import { unhonoured } from '../api/coding.ts'
import { ENSO } from '../api/models.ts'
import { SANDBOX } from '../api/places.ts'
@@ -21,23 +26,25 @@ import { MODES } from '../landing.tsx'
import { EFFORTS, useCatalog, useChoice } from '../mind.ts'
import { usePrefs } from '../prefs.tsx'
import { path } from '../route.ts'
import { said } from './machines.tsx'
import { Card, Group, Heading, Note, Row, Soft } from './ui.tsx'
export function Code() {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const signed = Boolean(host.person)
const { prefs, save } = usePrefs()
const catalog = useCatalog(t)
const catalog = useCatalog(target)
const [choice, choose] = useChoice()
const places = usePlaces(t, signed)
const places = usePlaces(target, signed)
const [note, setNote] = useState('')
if (!signed) {
return (
<YStack gap="$5">
<Heading title="Code" />
<Soft>Sign in to set how your runs start.</Soft>
<Heading title={t('Code')} />
<Soft>{t('Sign in to set how your runs start.')}</Soft>
</YStack>
)
}
@@ -45,14 +52,14 @@ export function Code() {
const now = { mode: 'build' as const, place: '', ...prefs.code }
const keep = (change: Defaults) => {
setNote('')
save({ code: { ...prefs.code, ...change } }).catch((e: unknown) => setNote((e as Error).message))
save({ code: { ...prefs.code, ...change } }).catch((e: unknown) => setNote(told(e, t)))
}
const where = (places.value.length ? places.value : [SANDBOX]).map((p) => ({
id: p.id || 'sandbox',
label: p.id ? p.label : 'Default',
label: p.id ? p.label : t('Default'),
// A machine offline now can still be the default; its status shows beside it.
hint: p.id ? [p.status, p.capacity].filter(Boolean).join(' · ') : 'The platform’s own sandbox',
hint: p.id ? [said(p.status, t), p.capacity].filter(Boolean).join(' · ') : t('The platform’s own sandbox'),
place: p.id,
}))
// The sandbox is always listed, so a place not found is a machine that is not: it reads as its id.
@@ -60,22 +67,25 @@ export function Code() {
// A default is never premium: a premium model is picked for one run, in New.
const offered = catalog.models.filter((m) => m.class !== 'premium')
const go = (section: 'environments' | 'machines') => host.go(path({ kind: 'settings', section }))
const effort = EFFORTS.find((e) => e.id === choice.effort)!
const mode = MODES.find((m) => m.id === now.mode)!
// The choices in the reader's words, kept by id.
const efforts = EFFORTS.map((e) => ({ ...e, label: t(e.label) }))
const modes = MODES.map((m) => ({ ...m, label: t(m.label), hint: t(m.hint) }))
const effort = efforts.find((e) => e.id === choice.effort)!
const mode = modes.find((m) => m.id === now.mode)!
return (
<YStack gap="$6">
<Heading title="Code" detail="How a new run starts. The model and effort are the ones Chat uses too." />
<Group title="Defaults">
<Heading title={t('Code')} detail={t('How a new run starts. The model and effort are the ones Chat uses too.')} />
<Group title={t('Defaults')}>
<Card>
<Row
first
title="Model"
detail="Enso picks one for each step; any other model runs them all."
title={t('Model')}
detail={t('Enso picks one for each step; any other model runs them all.')}
trailing={
<ModelPicker
size="sm"
name="Default model"
name={t('Default model')}
models={offered}
scope="chat"
value={choice.model || ENSO}
@@ -86,14 +96,14 @@ export function Code() {
}
/>
<Row
title="Effort"
detail="How long the agent thinks before it acts."
title={t('Effort')}
detail={t('How long the agent thinks before it acts.')}
trailing={
<ChipSelect
name="Default effort"
name={t('Default effort')}
label={effort.label}
chosen={effort}
items={EFFORTS}
items={efforts}
onChange={(e) => choose({ effort: e.id })}
placement="bottom-end"
width={180}
@@ -101,14 +111,14 @@ export function Code() {
}
/>
<Row
title="Mode"
title={t('Mode')}
detail={mode.hint}
trailing={
<ChipSelect
name="Default mode"
name={t('Default mode')}
label={mode.label}
chosen={mode}
items={MODES}
items={modes}
onChange={(m) => keep({ mode: m.id })}
placement="bottom-end"
width={260}
@@ -116,37 +126,37 @@ export function Code() {
}
/>
<Row
title="Where it runs"
detail="The Hanzo sandbox, or one of your organization’s machines."
title={t('Where it runs')}
detail={t('The Hanzo sandbox, or one of your organization’s machines.')}
trailing={
<ChipSelect
name="Default place"
name={t('Default place')}
label={place.label}
chosen={place}
items={where}
onChange={(w) => keep({ place: w.place })}
placeholder="Search machines…"
placeholder={t('Search machines…')}
placement="bottom-end"
loading={places.loading}
error={places.error?.message ?? null}
error={places.error ? told(places.error, t) : null}
/>
}
/>
</Card>
<Note>{unhonoured(now.mode, now.place)}</Note>
<Note>{unhonoured(now.mode, now.place, t)}</Note>
</Group>
<Group title="Workspace">
<Group title={t('Workspace')}>
<Card>
<Row
first
title="Environments"
detail="What a run installs and starts in each codebase, and the secrets it exports."
trailing={<Link label="Environments" onPress={() => go('environments')} />}
title={t('Environments')}
detail={t('What a run installs and starts in each codebase, and the secrets it exports.')}
trailing={<Link label={t('Open Environments')} onPress={() => go('environments')} />}
/>
<Row
title="Machines"
detail="Your organization’s own computers that take runs."
trailing={<Link label="Machines" onPress={() => go('machines')} />}
title={t('Machines')}
detail={t('Your organization’s own computers that take runs.')}
trailing={<Link label={t('Open Machines')} onPress={() => go('machines')} />}
/>
</Card>
</Group>
@@ -155,10 +165,11 @@ export function Code() {
)
}
/** A row's way to its own page; `label` is what it opens, already said. */
function Link({ label, onPress }: { label: string; onPress: () => void }) {
return (
<YStack render="button" aria-label={`Open ${label}`} p="$1" rounded="$2" hoverStyle={{ bg: '$hover' }} onPress={onPress}>
<ChevronRight size={16} />
<YStack render="button" aria-label={label} p="$1" rounded="$2" hoverStyle={{ bg: '$hover' }} onPress={onPress}>
<ChevronRight size={16} {...{ 'data-i18n-mirror': '' }} />
</YStack>
)
}
+76 -63
View File
@@ -12,8 +12,13 @@
* Money moves two ways, the host's choice: `add` names a page that takes it (the
* pay site); without one a saved card is charged here (POST /v1/billing/topup)
* and a person with no card is sent to add one (`onCard`).
*
* Its words are the reader's language (@hanzo/i18n `t`); a grant's, a limit's
* and a kind of spend's names are the ledger's, drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n, type Say } from '@hanzo/i18n/react'
import { Button, Input, Switch } from '@hanzo/ui'
import { useEffect, useState } from 'react'
@@ -40,19 +45,21 @@ import {
type Recharge,
type Spend,
} from '../api/billing.ts'
import type { Target } from '../api/call.ts'
import { told, type Target } from '../api/call.ts'
import { useRead } from '../data.ts'
import { Card, day, Group, Note, Row, Soft } from './ui.tsx'
/** What the credits section says first, on every surface. */
export const SEPARATE =
'Credits are separate from your plan’s usage allowance. They pay for usage past it when you choose to continue with credits, and for all usage when there is no plan.'
/** What the credits section says first, on every surface: English, marked (`phrase`), drawn through `t`. */
export const SEPARATE = phrase(
'Credits are separate from your plan’s usage allowance. They pay for usage past it when you choose to continue with credits, and for all usage when there is no plan.',
)
/** A money figure as read: the amount, or why there is none. */
function Figure({ read }: { read: { value: number; error: Error | null; loading: boolean } }) {
const { t } = useI18n()
return (
<SizableText size="$2" color={read.error ? '$soft' : '$ink'} numberOfLines={1} maxW={200}>
{read.error ? 'Unavailable' : read.loading ? '…' : money(read.value)}
{read.error ? t('Unavailable') : read.loading ? '…' : money(read.value)}
</SizableText>
)
}
@@ -84,16 +91,17 @@ function Meter({ title, used, of, says, first, limit = true }: { title: string;
}
/** `Resets Oct 1, 2026`, or '' for none. */
const resets = (iso: string): string => (day(iso) ? `Resets ${day(iso)}` : '')
const resets = (iso: string, t: Say): string => (day(iso) ? t('Resets {date}', { date: day(iso) }) : '')
export function Credits({ target: t, admin, add, onCard }: { target: Target; admin: boolean; add?: string; onCard?: () => void }) {
const left = useRead(() => balance(t), 0, [t])
const granted = useRead(() => credit(t), 0, [t])
const rows = useRead(() => grants(t), [] as Grant[], [t])
const spent = useRead(() => spend(t), null as Spend | null, [t])
const rule = useRead(() => recharge(t), null as Recharge | null, [t])
const ceilings = useRead(() => caps(t), [] as Cap[], [t])
const cards = useRead(add ? null : () => methods(t), [] as Method[], [t, add])
export function Credits({ target, admin, add, onCard }: { target: Target; admin: boolean; add?: string; onCard?: () => void }) {
const { t } = useI18n()
const left = useRead(() => balance(target), 0, [target])
const granted = useRead(() => credit(target), 0, [target])
const rows = useRead(() => grants(target), [] as Grant[], [target])
const spent = useRead(() => spend(target), null as Spend | null, [target])
const rule = useRead(() => recharge(target), null as Recharge | null, [target])
const ceilings = useRead(() => caps(target), [] as Cap[], [target])
const cards = useRead(add ? null : () => methods(target), [] as Method[], [target, add])
const [buying, setBuying] = useState(false)
const [amount, setAmount] = useState('25')
const [ceiling, setCeiling] = useState('')
@@ -117,7 +125,7 @@ export function Credits({ target: t, admin, add, onCard }: { target: Target; adm
after()
setNote(done)
} catch (e) {
setNote((e as Error).message)
setNote(told(e, t))
} finally {
setWorking(false)
}
@@ -132,8 +140,8 @@ export function Credits({ target: t, admin, add, onCard }: { target: Target; adm
const live = rows.value.filter((g) => g.active && g.remaining > 0)
const buy = () => {
if (!adding) return setNote('Enter an amount in dollars')
void act(() => topup(t, adding, card!.id), `${money(adding)} is added to the balance`, () => {
if (!adding) return setNote(t('Enter an amount in dollars'))
void act(() => topup(target, adding, card!.id), t('{amount} is added to the balance', { amount: money(adding) }), () => {
setBuying(false)
left.reload()
})
@@ -141,8 +149,8 @@ export function Credits({ target: t, admin, add, onCard }: { target: Target; adm
const setLimit = () => {
const c = cents(ceiling)
if (!c) return setNote('Enter a monthly limit in dollars')
void act(() => limit(t, ceilings.value, c), `Spend stops at ${money(c)} a month`, () => {
if (!c) return setNote(t('Enter a monthly limit in dollars'))
void act(() => limit(target, ceilings.value, c), t('Spend stops at {amount} a month', { amount: money(c) }), () => {
setCeiling('')
ceilings.reload()
})
@@ -151,10 +159,10 @@ export function Credits({ target: t, admin, add, onCard }: { target: Target; adm
const saveRule = (enabled: boolean) => {
const threshold = below.trim() === '' || below.trim() === '0' ? 0 : cents(below)
const each = cents(reload)
if (enabled && (threshold === null || !each)) return setNote('Enter the balance to reload below and the amount to add, in dollars')
if (enabled && (threshold === null || !each)) return setNote(t('Enter the balance to reload below and the amount to add, in dollars'))
void act(
() => arm(t, { enabled, threshold: threshold ?? 0, amount: each ?? rule.value?.amount ?? 0 }),
enabled ? `Adds ${money(each ?? 0)} whenever the balance falls below ${money(threshold ?? 0)}` : 'Auto-reload is off',
() => arm(target, { enabled, threshold: threshold ?? 0, amount: each ?? rule.value?.amount ?? 0 }),
enabled ? t('Adds {amount} whenever the balance falls below {floor}', { amount: money(each ?? 0), floor: money(threshold ?? 0) }) : t('Auto-reload is off'),
rule.reload,
)
}
@@ -162,26 +170,26 @@ export function Credits({ target: t, admin, add, onCard }: { target: Target; adm
return (
<YStack gap="$6" data-slot="credits">
<Group
title="Credits"
detail={SEPARATE}
title={t('Credits')}
detail={t(SEPARATE)}
action={
add ? (
<Button size="sm" variant="outline" onPress={() => window.open(add, '_blank', 'noopener')}>
Add funds
{t('Add funds')}
</Button>
) : buying || !admin ? undefined : (
<Button size="sm" variant="outline" onPress={() => setBuying(true)}>
Buy more
{t('Buy more')}
</Button>
)
}
>
<Card>
<Row first title="Prepaid balance" detail="Bought with a card" trailing={<Figure read={left} />} />
<Row title="Granted credit" detail="Given by Hanzo; some models take only prepaid" trailing={<Figure read={granted} />} />
<Row first title={t('Prepaid balance')} detail={t('Bought with a card')} trailing={<Figure read={left} />} />
<Row title={t('Granted credit')} detail={t('Given by Hanzo; some models take only prepaid')} trailing={<Figure read={granted} />} />
<Row
title="Spent this month"
detail={s && !s.known ? 'The ledger did not answer, so this is not a measurement' : undefined}
title={t('Spent this month')}
detail={s && !s.known ? t('The ledger did not answer, so this is not a measurement') : undefined}
trailing={<Figure read={{ ...spent, value: s?.total ?? 0 }} />}
/>
{buying ? (
@@ -190,29 +198,29 @@ export function Credits({ target: t, admin, add, onCard }: { target: Target; adm
<>
<XStack gap="$2" items="center" flexWrap="wrap">
<YStack width={140}>
<Input value={amount} onChangeText={setAmount} aria-label="Amount in dollars" placeholder="25" inputMode="decimal" />
<Input value={amount} onChangeText={setAmount} aria-label={t('Amount in dollars')} placeholder="25" inputMode="decimal" />
</YStack>
<SizableText flex={1} minW={160} size="$1" color="$soft">
Charged to {label(card)}
{t('Charged to {card}', { card: label(card) })}
</SizableText>
</XStack>
<XStack gap="$2" justify="flex-end">
<Button size="sm" variant="ghost" onPress={() => setBuying(false)}>
Cancel
{t('Cancel')}
</Button>
<Button size="sm" disabled={working} onPress={buy}>
{adding ? `Add ${money(adding)}` : 'Add funds'}
{adding ? t('Add {amount}', { amount: money(adding) }) : t('Add funds')}
</Button>
</XStack>
</>
) : (
<XStack gap="$2" items="center" flexWrap="wrap">
<SizableText flex={1} minW={160} size="$2" color="$soft">
{cards.loading ? 'Reading cards…' : 'Add a card to buy more.'}
{cards.loading ? t('Reading cards…') : t('Add a card to buy more.')}
</SizableText>
{onCard ? (
<Button size="sm" variant="outline" onPress={onCard}>
Add card
{t('Add card')}
</Button>
) : null}
</XStack>
@@ -226,42 +234,47 @@ export function Credits({ target: t, admin, add, onCard }: { target: Target; adm
<Row
key={g.id}
first={i === 0}
title={g.name || 'Credit grant'}
detail={[`${money(g.remaining, g.currency)} left of ${money(g.amount, g.currency)}`, g.expires ? `expires ${day(g.expires)}` : ''].filter(Boolean).join(' · ')}
title={g.name || t('Credit grant')}
detail={[
t('{left} left of {amount}', { left: money(g.remaining, g.currency), amount: money(g.amount, g.currency) }),
g.expires ? t('expires {date}', { date: day(g.expires) }) : '',
]
.filter(Boolean)
.join(' · ')}
/>
))}
</Card>
) : null}
</Group>
<Group title="Auto-reload" detail="Tops the balance up from the card on file whenever it falls below a floor.">
<Group title={t('Auto-reload')} detail={t('Tops the balance up from the card on file whenever it falls below a floor.')}>
{rule.error ? (
<Soft>{rule.error.message}</Soft>
<Soft>{told(rule.error, t)}</Soft>
) : !rule.value ? (
<Soft>Reading auto-reload…</Soft>
<Soft>{t('Reading auto-reload…')}</Soft>
) : (
<Card>
<Row
first
title={rule.value.enabled ? `Adds ${money(rule.value.amount)} below ${money(rule.value.threshold)}` : 'Off'}
detail={rule.value.last ? `Last reloaded ${day(rule.value.last)}` : admin ? undefined : 'An org admin sets auto-reload.'}
title={rule.value.enabled ? t('Adds {amount} below {floor}', { amount: money(rule.value.amount), floor: money(rule.value.threshold) }) : t('Off')}
detail={rule.value.last ? t('Last reloaded {date}', { date: day(rule.value.last) }) : admin ? undefined : t('An org admin sets auto-reload.')}
trailing={
admin ? (
<Switch aria-label="Auto-reload" checked={rule.value.enabled} disabled={working} onCheckedChange={(on: boolean) => saveRule(on)} />
<Switch aria-label={t('Auto-reload')} checked={rule.value.enabled} disabled={working} onCheckedChange={(on: boolean) => saveRule(on)} />
) : undefined
}
/>
{admin ? (
<XStack gap="$2" items="center" px="$3" py="$3" borderTopWidth={1} borderColor="$borderColor" flexWrap="wrap">
<YStack width={150}>
<Input value={below} onChangeText={setBelow} aria-label="Reload below, in dollars" placeholder="Below $5" inputMode="decimal" />
<Input value={below} onChangeText={setBelow} aria-label={t('Reload below, in dollars')} placeholder={t('Below {amount}', { amount: '$5' })} inputMode="decimal" />
</YStack>
<YStack width={150}>
<Input value={reload} onChangeText={setReload} aria-label="Amount to add, in dollars" placeholder="Add $25" inputMode="decimal" />
<Input value={reload} onChangeText={setReload} aria-label={t('Amount to add, in dollars')} placeholder={t('Add {amount}', { amount: '$25' })} inputMode="decimal" />
</YStack>
<XStack flex={1} justify="flex-end">
<Button size="sm" variant="outline" disabled={working} onPress={() => saveRule(true)}>
{rule.value.enabled ? 'Save' : 'Turn on'}
{rule.value.enabled ? t('Save') : t('Turn on')}
</Button>
</XStack>
</XStack>
@@ -270,14 +283,14 @@ export function Credits({ target: t, admin, add, onCard }: { target: Target; adm
)}
</Group>
<Group title="Spend this month" detail="What credits paid for, by kind.">
<Group title={t('Spend this month')} detail={t('What credits paid for, by kind.')}>
{spent.error ? (
<Soft>{spent.error.message}</Soft>
<Soft>{told(spent.error, t)}</Soft>
) : !s ? (
<Soft>Reading spend…</Soft>
<Soft>{t('Reading spend…')}</Soft>
) : s.categories.length === 0 ? (
<Card>
<Soft>Nothing spent this month.</Soft>
<Soft>{t('Nothing spent this month.')}</Soft>
</Card>
) : (
<Card>
@@ -288,27 +301,27 @@ export function Credits({ target: t, admin, add, onCard }: { target: Target; adm
)}
</Group>
<Group title="Monthly limit" detail="Spend from credits past it is refused until the month starts over.">
<Group title={t('Monthly limit')} detail={t('Spend from credits past it is refused until the month starts over.')}>
{ceilings.error ? (
<Soft>{ceilings.error.message}</Soft>
<Soft>{told(ceilings.error, t)}</Soft>
) : (
<Card>
{cap ? (
<Meter
first
title={`${money(cap.threshold)} a month`}
title={t('{amount} a month', { amount: money(cap.threshold) })}
used={cap.spent ?? 0}
of={cap.threshold}
says={[cap.spent === null ? 'Spend unknown' : `${money(cap.spent)} spent`, resets(cap.resets)].filter(Boolean).join(' · ')}
says={[cap.spent === null ? t('Spend unknown') : t('{amount} spent', { amount: money(cap.spent) }), resets(cap.resets, t)].filter(Boolean).join(' · ')}
/>
) : (
<Row first title="No monthly limit" detail="Spend is bounded by the balance alone." />
<Row first title={t('No monthly limit')} detail={t('Spend is bounded by the balance alone.')} />
)}
{others.map((c) => (
<Row
key={c.id}
title={c.title || 'Limit'}
detail={[c.project && `project ${c.project}`, c.service && `service ${c.service}`, c.enforce ? 'refuses spend past it' : 'warns only']
title={c.title || t('Limit')}
detail={[c.project && t('project {name}', { name: c.project }), c.service && t('service {name}', { name: c.service }), c.enforce ? t('refuses spend past it') : t('warns only')]
.filter(Boolean)
.join(' · ')}
trailing={
@@ -321,16 +334,16 @@ export function Credits({ target: t, admin, add, onCard }: { target: Target; adm
{admin ? (
<XStack gap="$2" items="center" px="$3" py="$3" borderTopWidth={1} borderColor="$borderColor" flexWrap="wrap">
<YStack width={160}>
<Input value={ceiling} onChangeText={setCeiling} aria-label="Monthly limit in dollars" placeholder={cap ? String(cap.threshold / 100) : '500'} inputMode="decimal" />
<Input value={ceiling} onChangeText={setCeiling} aria-label={t('Monthly limit in dollars')} placeholder={cap ? String(cap.threshold / 100) : '500'} inputMode="decimal" />
</YStack>
<XStack flex={1} gap="$2" justify="flex-end">
{cap ? (
<Button size="sm" variant="ghost" disabled={working} onPress={() => void act(() => lift(t, cap.id), 'The monthly limit is lifted', ceilings.reload)}>
Remove
<Button size="sm" variant="ghost" disabled={working} onPress={() => void act(() => lift(target, cap.id), t('The monthly limit is lifted'), ceilings.reload)}>
{t('Remove')}
</Button>
) : null}
<Button size="sm" variant="outline" disabled={working || !ceiling.trim()} onPress={setLimit}>
{cap ? 'Change limit' : 'Set limit'}
{cap ? t('Change limit') : t('Set limit')}
</Button>
</XStack>
</XStack>
@@ -339,7 +352,7 @@ export function Credits({ target: t, admin, add, onCard }: { target: Target; adm
)}
{!admin ? (
<SizableText size="$1" color="$soft">
An org admin sets the monthly limit.
{t('An org admin sets the monthly limit.')}
</SizableText>
) : null}
</Group>
+33 -26
View File
@@ -4,12 +4,18 @@
* One opens in the same editor a run shows beside it. A member reads; an org
* admin saves, sets secrets and forgets an environment, which is the platform's
* rule and not a choice made here.
*
* Its words are the reader's language (@hanzo/i18n `t`); a codebase's name is
* its own, drawn as given.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { ChevronLeft, ChevronRight } from '@hanzogui/lucide-icons-2'
import { phrase } from '@hanzo/i18n'
import { useI18n, type Say } from '@hanzo/i18n/react'
import { Button } from '@hanzo/ui'
import { useState } from 'react'
import { told } from '../api/call.ts'
import { environments, forget, type Environment as Env } from '../api/environment.ts'
import { Confirm } from '../ask.tsx'
import { useRead } from '../data.ts'
@@ -17,22 +23,23 @@ import { Environment } from '../environment.tsx'
import { useHost, useTarget } from '../host.tsx'
import { Card, Heading, Note, Row, Soft } from './ui.tsx'
const STATE: Record<Env['state'], string> = { none: 'Not set up', proposed: 'Proposed, waiting for review', ready: 'Ready' }
const STATE: Record<Env['state'], string> = { none: phrase('Not set up'), proposed: phrase('Proposed, waiting for review'), ready: phrase('Ready') }
/** The line under a codebase: where it stands, its secrets, when it was saved. */
function about(e: Env): string {
const parts = [STATE[e.state]]
if (e.secrets.length) parts.push(e.secrets.length === 1 ? '1 secret' : `${e.secrets.length} secrets`)
/** The line under a codebase: where it stands, its secrets, when it was saved, each fact in the reader's words. */
function about(e: Env, t: Say): string {
const parts = [t(STATE[e.state])]
if (e.secrets.length) parts.push(e.secrets.length === 1 ? t('1 secret') : t('{n} secrets', { n: e.secrets.length }))
const when = e.updated ? new Date(e.updated) : null
if (when && !Number.isNaN(when.getTime())) parts.push(`Updated ${when.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}`)
if (when && !Number.isNaN(when.getTime())) parts.push(t('Updated {date}', { date: when.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) }))
return parts.join(' · ')
}
export function Environments() {
const { t } = useI18n()
const host = useHost()
const t = useTarget()
const target = useTarget()
const signed = Boolean(host.person)
const list = useRead(signed ? () => environments(t) : null, [] as Env[], [t, signed])
const list = useRead(signed ? () => environments(target) : null, [] as Env[], [target, signed])
const [open, setOpen] = useState('')
const [forgetting, setForgetting] = useState(false)
const [note, setNote] = useState('')
@@ -40,8 +47,8 @@ export function Environments() {
if (!signed) {
return (
<YStack gap="$5">
<Heading title="Environments" />
<Soft>Sign in to see your organization’s environments.</Soft>
<Heading title={t('Environments')} />
<Soft>{t('Sign in to see your organization’s environments.')}</Soft>
</YStack>
)
}
@@ -51,12 +58,12 @@ export function Environments() {
<YStack gap="$5">
<XStack items="center" gap="$2">
<Button size="sm" variant="ghost" onPress={() => setOpen('')}>
<ChevronLeft size={14} /> All environments
<ChevronLeft size={14} {...{ 'data-i18n-mirror': '' }} /> {t('All environments')}
</Button>
<XStack flex={1} />
{host.admin ? (
<Button size="sm" variant="outline" onPress={() => setForgetting(true)}>
Forget
{t('Forget')}
</Button>
) : null}
</XStack>
@@ -64,12 +71,12 @@ export function Environments() {
<Confirm
open={forgetting}
onOpenChange={setForgetting}
title={`Forget ${open}’s environment?`}
says="Later runs on this codebase start bare: its scripts are dropped and every secret it holds is removed from KMS. This cannot be undone."
act="Forget"
title={t('Forget {repo}’s environment?', { repo: open })}
says={t('Later runs on this codebase start bare: its scripts are dropped and every secret it holds is removed from KMS. This cannot be undone.')}
act={t('Forget')}
run={async () => {
await forget(t, open)
setNote(`${open}’s environment is forgotten`)
await forget(target, open)
setNote(t('{repo}’s environment is forgotten', { repo: open }))
setOpen('')
list.reload()
}}
@@ -81,38 +88,38 @@ export function Environments() {
return (
<YStack gap="$5">
<Heading
title="Environments"
detail="What a sandbox run does to a codebase before its agent starts: the install script, the start command, and the secrets it exports."
title={t('Environments')}
detail={t('What a sandbox run does to a codebase before its agent starts: the install script, the start command, and the secrets it exports.')}
/>
{list.error ? (
<Soft>{list.error.message}</Soft>
<Soft>{told(list.error, t)}</Soft>
) : list.loading && list.value.length === 0 ? (
<Soft>Reading environments…</Soft>
<Soft>{t('Reading environments…')}</Soft>
) : list.value.length === 0 ? (
<Soft>No codebase has an environment yet. Set one up from New, or from a run’s Environment tab.</Soft>
<Soft>{t('No codebase has an environment yet. Set one up from New, or from a run’s Environment tab.')}</Soft>
) : (
<Card>
{list.value.map((e, i) => (
<YStack
key={e.repo}
render="button"
aria-label={`Open ${e.repo}`}
aria-label={t('Open {repo}', { repo: e.repo })}
onPress={() => {
setNote('')
setOpen(e.repo)
}}
cursor="pointer"
hoverStyle={{ bg: '$hover' }}
style={{ textAlign: 'left' }}
$platform-web={{ textAlign: 'start' }}
>
<Row first={i === 0} title={e.repo} detail={about(e)} trailing={<ChevronRight size={16} opacity={0.6} />} />
<Row first={i === 0} title={e.repo} detail={about(e, t)} trailing={<ChevronRight size={16} opacity={0.6} {...{ 'data-i18n-mirror': '' }} />} />
</YStack>
))}
</Card>
)}
{!host.admin ? (
<SizableText size="$1" color="$soft">
An org admin saves an environment and sets its secrets.
{t('An org admin saves an environment and sets its secrets.')}
</SizableText>
) : null}
<Note>{note}</Note>
+45 -33
View File
@@ -2,35 +2,43 @@
* General: how the builder looks and listens — the theme, the text size, the
* motion, and the language dictation hears. Each choice is saved to the
* person's own settings as it is made, so it follows them to every device.
*
* Its words are the reader's language (@hanzo/i18n `t`); a language is named
* in its own words, as given.
*/
import { YStack } from '@hanzo/gui'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { ChipSelect } from '@hanzo/ui/product'
import { useState } from 'react'
import { told } from '../api/call.ts'
import type { Motion, Patch, Text, Theme } from '../api/pref.ts'
import { useHost } from '../host.tsx'
import { usePrefs } from '../prefs.tsx'
import { LANGUAGES, spoken } from '../voice.ts'
import { Card, Group, Heading, Note, Row, Soft } from './ui.tsx'
/** The choices, each word English and marked (`phrase`): drawn through `t`. */
const THEMES: { id: Theme; label: string }[] = [
{ id: 'system', label: 'System' },
{ id: 'light', label: 'Light' },
{ id: 'dark', label: 'Dark' },
{ id: 'system', label: phrase('System') },
{ id: 'light', label: phrase('Light') },
{ id: 'dark', label: phrase('Dark') },
]
const TEXTS: { id: Text; label: string }[] = [
{ id: 'small', label: 'Small' },
{ id: 'medium', label: 'Medium' },
{ id: 'large', label: 'Large' },
{ id: 'small', label: phrase('Small') },
{ id: 'medium', label: phrase('Medium') },
{ id: 'large', label: phrase('Large') },
]
const MOTIONS: { id: Motion; label: string; hint: string }[] = [
{ id: 'system', label: 'System', hint: 'As this device is set' },
{ id: 'reduced', label: 'Reduced', hint: 'No animation anywhere' },
{ id: 'system', label: phrase('System'), hint: phrase('As this device is set') },
{ id: 'reduced', label: phrase('Reduced'), hint: phrase('No animation anywhere') },
]
export function General() {
const { t } = useI18n()
const host = useHost()
const { prefs, save } = usePrefs()
const [note, setNote] = useState('')
@@ -38,39 +46,43 @@ export function General() {
if (!host.person) {
return (
<YStack gap="$5">
<Heading title="General" />
<Soft>Sign in to see your settings.</Soft>
<Heading title={t('General')} />
<Soft>{t('Sign in to see your settings.')}</Soft>
</YStack>
)
}
const keep = (patch: Patch) => {
setNote('')
save(patch).catch((e: unknown) => setNote((e as Error).message))
save(patch).catch((e: unknown) => setNote(told(e, t)))
}
// The choices in the reader's words, kept by id.
const themes = THEMES.map((x) => ({ ...x, label: t(x.label) }))
const texts = TEXTS.map((x) => ({ ...x, label: t(x.label) }))
const motions = MOTIONS.map((x) => ({ ...x, label: t(x.label), hint: t(x.hint) }))
// Each choice is one of its list: a saved one was read against it, and a host's theme is one of THEMES.
const theme = THEMES.find((x) => x.id === (prefs.theme ?? host.theme ?? 'dark'))!
const text = TEXTS.find((x) => x.id === (prefs.text ?? 'medium'))!
const motion = MOTIONS.find((x) => x.id === (prefs.motion ?? 'system'))!
const theme = themes.find((x) => x.id === (prefs.theme ?? host.theme ?? 'dark'))!
const text = texts.find((x) => x.id === (prefs.text ?? 'medium'))!
const motion = motions.find((x) => x.id === (prefs.motion ?? 'system'))!
const language = LANGUAGES.find((l) => l.id === prefs.language) ?? LANGUAGES.find((l) => l.id === spoken())!
return (
<YStack gap="$6">
<Heading title="General" detail="How Hanzo looks and listens. Saved to your account, so it follows you." />
<Group title="Appearance">
<Heading title={t('General')} detail={t('How Hanzo looks and listens. Saved to your account, so it follows you.')} />
<Group title={t('Appearance')}>
<Card>
{host.chooseTheme ? (
<Row
first
title="Theme"
detail="System follows this device’s light or dark setting."
title={t('Theme')}
detail={t('System follows this device’s light or dark setting.')}
trailing={
<ChipSelect
name="Theme"
name={t('Theme')}
label={theme.label}
chosen={theme}
items={THEMES}
items={themes}
onChange={(x) => keep({ theme: x.id })}
placement="bottom-end"
width={200}
@@ -80,14 +92,14 @@ export function General() {
) : null}
<Row
first={!host.chooseTheme}
title="Text size"
detail="The transcript, the composer and every screen."
title={t('Text size')}
detail={t('The transcript, the composer and every screen.')}
trailing={
<ChipSelect
name="Text size"
name={t('Text size')}
label={text.label}
chosen={text}
items={TEXTS}
items={texts}
onChange={(x) => keep({ text: x.id === 'medium' ? null : x.id })}
placement="bottom-end"
width={200}
@@ -95,14 +107,14 @@ export function General() {
}
/>
<Row
title="Motion"
detail="Reduced stops animations and transitions on this page."
title={t('Motion')}
detail={t('Reduced stops animations and transitions on this page.')}
trailing={
<ChipSelect
name="Motion"
name={t('Motion')}
label={motion.label}
chosen={motion}
items={MOTIONS}
items={motions}
onChange={(x) => keep({ motion: x.id === 'system' ? null : x.id })}
placement="bottom-end"
width={220}
@@ -111,20 +123,20 @@ export function General() {
/>
</Card>
</Group>
<Group title="Voice">
<Group title={t('Voice')}>
<Card>
<Row
first
title="Dictation language"
detail="What the microphone in the composer listens for."
title={t('Dictation language')}
detail={t('What the microphone in the composer listens for.')}
trailing={
<ChipSelect
name="Dictation language"
name={t('Dictation language')}
label={language.label}
chosen={language}
items={LANGUAGES}
onChange={(l) => keep({ language: l.id })}
placeholder="Search languages…"
placeholder={t('Search languages…')}
placement="bottom-end"
width={220}
/>
+11 -8
View File
@@ -3,8 +3,10 @@
* workspace's environments, machines and keys, what the agent brings to a run,
* and the organization's integrations, members, usage and notifications. A
* section is its own address, so it can be linked to and comes back on reload.
* Its words are the reader's language (@hanzo/i18n `t`).
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { useI18n } from '@hanzo/i18n/react'
import { useHost } from '../host.tsx'
import { path, type Section } from '../route.ts'
@@ -12,6 +14,7 @@ import { heading } from './list.ts'
import { ENTRIES, GROUPS } from './sections.tsx'
export function Settings({ section }: { section: Section }) {
const { t } = useI18n()
const host = useHost()
// Every section the route grammar names has an entry.
const entry = ENTRIES.find((e) => e.id === section)!
@@ -23,10 +26,10 @@ export function Settings({ section }: { section: Section }) {
{host.sections ? null : (
<YStack
role="navigation"
aria-label="Settings"
aria-label={t('Settings')}
width={220}
shrink={0}
borderRightWidth={1}
borderInlineEndWidth={1}
borderColor="$borderColor"
px="$2"
py="$4"
@@ -35,7 +38,7 @@ export function Settings({ section }: { section: Section }) {
$max-md={{ display: 'none' }}
>
<SizableText size="$4" color="$ink" px="$2">
Settings
{t('Settings')}
</SizableText>
{GROUPS.map((g) => {
const list = ENTRIES.filter((e) => e.group === g)
@@ -44,14 +47,14 @@ export function Settings({ section }: { section: Section }) {
{/* The first group sits under the page's own "Settings" heading, and Code leads its own part: neither is labelled twice. */}
{heading(g) ? (
<SizableText size="$1" color="$soft" px="$2" pb="$1">
{g}
{t(heading(g))}
</SizableText>
) : null}
{list.map((e) => (
<XStack
key={e.id}
render="button"
aria-label={e.label}
aria-label={t(e.label)}
aria-current={e.id === entry.id ? 'page' : undefined}
onPress={() => go(e.id)}
px="$2"
@@ -61,7 +64,7 @@ export function Settings({ section }: { section: Section }) {
hoverStyle={{ bg: '$hover' }}
>
<SizableText size="$2" color={e.id === entry.id ? '$ink' : '$soft'}>
{e.label}
{t(e.label)}
</SizableText>
</XStack>
))}
@@ -76,7 +79,7 @@ export function Settings({ section }: { section: Section }) {
<XStack
key={e.id}
render="button"
aria-label={e.label}
aria-label={t(e.label)}
onPress={() => go(e.id)}
px="$2.5"
py="$1"
@@ -85,7 +88,7 @@ export function Settings({ section }: { section: Section }) {
borderColor={e.id === entry.id ? '$ink' : '$borderColor'}
>
<SizableText size="$1" color={e.id === entry.id ? '$ink' : '$soft'}>
{e.label}
{t(e.label)}
</SizableText>
</XStack>
))}

Some files were not shown because too many files have changed in this diff Show More