mirror of
https://github.com/hanzoai/build.git
synced 2026-10-23 02:18:08 +00:00
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:
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/build",
|
||||
"version": "0.2.80",
|
||||
"version": "0.2.81",
|
||||
"type": "module",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@hanzo/composer": "catalog:",
|
||||
"@hanzo/font": "catalog:",
|
||||
"@hanzo/gui": "catalog:",
|
||||
"@hanzo/i18n": "catalog:",
|
||||
"@hanzo/iam": "catalog:",
|
||||
"@hanzo/ui": "catalog:",
|
||||
"@hanzo/vite": "catalog:",
|
||||
@@ -89,6 +90,7 @@
|
||||
"@hanzogui/lucide-icons-2": ">=8.3.5",
|
||||
"react": ">=19.2.8",
|
||||
"@hanzo/voice": ">=0.1.19",
|
||||
"@hanzo/composer": ">=0.2.0"
|
||||
"@hanzo/composer": ">=0.2.0",
|
||||
"@hanzo/i18n": ">=0.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+18
@@ -12,6 +12,9 @@ catalogs:
|
||||
'@hanzo/font':
|
||||
specifier: 1.9.3
|
||||
version: 1.9.3
|
||||
'@hanzo/i18n':
|
||||
specifier: 0.4.0
|
||||
version: 0.4.0
|
||||
'@hanzo/iam':
|
||||
specifier: 0.21.28
|
||||
version: 0.21.28
|
||||
@@ -96,6 +99,9 @@ importers:
|
||||
'@hanzo/gui':
|
||||
specifier: 8.3.5
|
||||
version: 8.3.5([email protected]([email protected]))([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected])
|
||||
'@hanzo/i18n':
|
||||
specifier: 'catalog:'
|
||||
version: 0.4.0([email protected])
|
||||
'@hanzo/iam':
|
||||
specifier: 'catalog:'
|
||||
version: 0.21.28([email protected]([email protected]))([email protected])
|
||||
@@ -760,6 +766,14 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=19.2.8'
|
||||
|
||||
'@hanzo/[email protected]':
|
||||
resolution: {integrity: sha512-NMG+UTRvH3Gef+W8cSFrXpOLrQbe55lDoUbnUcxOt5o/KrgJ9mpo/nMzxm6GlZrDirSEiB5ky/+ZRtMuBVlnfw==}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@hanzo/[email protected]':
|
||||
resolution: {integrity: sha512-cDCSLTfcE14dROiDp7wEAONy+ZRN78UXO/XycltXUeM49dtBnk7Gp41r+b0kTb0fc84yVjApLPK7TzufTxc76Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4131,6 +4145,10 @@ snapshots:
|
||||
- react-dom
|
||||
- react-native
|
||||
|
||||
'@hanzo/[email protected]([email protected])':
|
||||
optionalDependencies:
|
||||
react: 19.2.8
|
||||
|
||||
'@hanzo/[email protected]([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
jose: 6.2.12
|
||||
|
||||
@@ -31,6 +31,7 @@ catalog:
|
||||
'@hanzo/composer': 0.2.0
|
||||
'@hanzo/font': 1.9.3
|
||||
'@hanzo/gui': 8.3.5
|
||||
'@hanzo/i18n': 0.4.0
|
||||
'@hanzo/iam': 0.21.28
|
||||
'@hanzo/ui': 8.29.0
|
||||
'@hanzo/vite': 0.1.4
|
||||
|
||||
+14
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ''
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* An empty Chat's composer in the reader's language (language.tsx), as a host
|
||||
* that mounts @hanzo/i18n draws it: the panel toggle, the composer's
|
||||
* placeholder, `+` and its menu, dictation and voice mode, the row under the
|
||||
* frame (model, effort, mode) and its menus, and the starters — each word the
|
||||
* catalog's, a model's name its own, and a starter's prompt sent in the words
|
||||
* pressed. Right to left, the frame mirrors: `+` at the right, the row's
|
||||
* choices at the left, nothing past the edge.
|
||||
*/
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from './fixture.ts'
|
||||
import type { Lang } from './language.tsx'
|
||||
|
||||
/** The model list the row reads. */
|
||||
const CATALOG = { data: [{ id: 'enso-auto', name: 'Enso', family: 'enso', class: 'ours', supports_reasoning: true }] }
|
||||
|
||||
/** A French catalog this spec writes, so what is drawn is checked against what was written. */
|
||||
const FR: Record<string, string> = {
|
||||
'Show tabs': 'Afficher les onglets',
|
||||
'Show tabs ({keys})': 'Afficher les onglets ({keys})',
|
||||
'Type / for skills': 'Tapez / pour les compétences',
|
||||
'Add files, agents, skills and more': 'Ajouter des fichiers, des agents, des compétences et plus',
|
||||
'Upload or add files': 'Importer ou ajouter des fichiers',
|
||||
Skills: 'Compétences',
|
||||
'No skills yet': 'Aucune compétence pour l’instant',
|
||||
Research: 'Recherche',
|
||||
Memory: 'Mémoire',
|
||||
'Dictation settings': 'Réglages de la dictée',
|
||||
'Voice mode': 'Mode vocal',
|
||||
Send: 'Envoyer',
|
||||
'Model: {name}': 'Modèle : {name}',
|
||||
'Effort: {level}': 'Réflexion : {level}',
|
||||
Effort: 'Réflexion',
|
||||
Medium: 'Moyenne',
|
||||
'Thinks before it answers': 'Réfléchit avant de répondre',
|
||||
'Mode: {mode}': 'Mode : {mode}',
|
||||
Auto: 'Automatique',
|
||||
Manual: 'Manuel',
|
||||
Suggestions: 'Idées',
|
||||
'Ask what Hanzo can do for you': 'Demandez ce que Hanzo peut faire pour vous',
|
||||
'What can you do for me? Give me three concrete things to try today.': 'Que peux-tu faire pour moi ? Donne-moi trois choses concrètes à essayer aujourd’hui.',
|
||||
'Dismiss {suggestion}': 'Ignorer {suggestion}',
|
||||
}
|
||||
|
||||
const AR: Record<string, string> = {
|
||||
'Type / for skills': 'اكتب / للمهارات',
|
||||
'Add files, agents, skills and more': 'أضف ملفات ووكلاء ومهارات والمزيد',
|
||||
'Voice mode': 'الوضع الصوتي',
|
||||
Medium: 'متوسط',
|
||||
Auto: 'تلقائي',
|
||||
}
|
||||
|
||||
async function open(p: Page, lang: Lang): Promise<void> {
|
||||
await p.route(
|
||||
(u) => u.pathname.startsWith('/v1/'),
|
||||
(r) => {
|
||||
const path = new URL(r.request().url()).pathname
|
||||
if (path === '/v1/models') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(CATALOG) })
|
||||
return r.fulfill({ status: 404, contentType: 'application/json', body: '{"detail":"not here"}' })
|
||||
},
|
||||
)
|
||||
await p.addInitScript((l) => {
|
||||
;(window as unknown as { lang: Lang }).lang = l
|
||||
}, lang)
|
||||
await p.route('**/-/language', async (r) => {
|
||||
const res = await r.fetch({ url: new URL('/', r.request().url()).href })
|
||||
const html = (await res.text()).replace('/src/app/main.tsx', '/src/e2e/language.tsx')
|
||||
await r.fulfill({ response: res, body: html })
|
||||
})
|
||||
await p.goto('/-/language')
|
||||
await expect(p.getByRole('textbox', { name: 'Message' })).toBeVisible({ timeout: 45_000 })
|
||||
await expect(p.locator('html')).toHaveAttribute('lang', lang.locale)
|
||||
}
|
||||
|
||||
const went = (p: Page) => p.evaluate(() => window.went.map((w) => JSON.parse(w) as unknown))
|
||||
const shot = (p: Page, name: string) => p.screenshot({ path: test.info().outputPath(name) })
|
||||
|
||||
test('in French, every word of an empty Chat’s composer is the catalog’s', async ({ page }) => {
|
||||
await open(page, { locale: 'fr-FR', catalog: FR })
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Message' })).toHaveAttribute('placeholder', FR['Type / for skills']!)
|
||||
const tabs = page.getByRole('button', { name: FR['Show tabs'], exact: true })
|
||||
await expect(tabs).toHaveAttribute('title', /^Afficher les onglets \((Ctrl\+Shift\+B|⌘⇧B)\)$/)
|
||||
await expect(page.getByRole('button', { name: FR['Voice mode'] })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: FR['Dictation settings'] })).toBeVisible()
|
||||
|
||||
// The row under the frame: the model by its own name, the effort and the mode in French.
|
||||
await expect(page.getByLabel('Modèle : Enso', { exact: true })).toHaveText('Enso')
|
||||
const effort = page.getByLabel('Réflexion : Moyenne', { exact: true })
|
||||
await expect(effort).toHaveText('Moyenne')
|
||||
await expect(page.getByLabel('Mode : Automatique', { exact: true })).toHaveText('Automatique')
|
||||
await effort.click()
|
||||
const efforts = page.getByRole('listbox', { name: 'Réflexion' })
|
||||
await expect(efforts.getByRole('option', { name: /Moyenne/ })).toContainText('Réfléchit avant de répondre')
|
||||
// A word the catalog lacks is its English, never a key.
|
||||
await expect(efforts.getByRole('option', { name: /High/ })).toContainText('Slow and careful, for hard problems')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// `+`, its rows, and a shelf's empty sentence.
|
||||
await page.getByRole('button', { name: FR['Add files, agents, skills and more'] }).click()
|
||||
for (const word of [FR['Upload or add files']!, FR.Skills!, FR.Research!, FR.Memory!]) await expect(page.getByRole('menuitem', { name: word }).or(page.getByRole('menuitemcheckbox', { name: word }))).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: FR.Skills }).hover()
|
||||
await expect(page.getByText(FR['No skills yet']!)).toBeVisible()
|
||||
await shot(page, 'language-fr-plus.png')
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// The starters: drawn in French, and the one pressed sends its prompt in French.
|
||||
await expect(page.getByRole('list', { name: FR.Suggestions })).toBeVisible()
|
||||
const ask = FR['Ask what Hanzo can do for you']!
|
||||
await expect(page.getByRole('button', { name: `Ignorer ${ask}` })).toHaveCount(1)
|
||||
await expect(page.getByRole('button', { name: 'Build a landing page with Dev', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: ask, exact: true }).click()
|
||||
expect(await went(page)).toEqual([{ kind: 'send', prompt: FR['What can you do for me? Give me three concrete things to try today.'] }])
|
||||
|
||||
// With words in the draft, the round is Send.
|
||||
await page.getByRole('textbox', { name: 'Message' }).fill('Bonjour')
|
||||
await expect(page.getByRole('button', { name: FR.Send })).toBeVisible()
|
||||
await shot(page, 'language-fr.png')
|
||||
})
|
||||
|
||||
test('in Arabic, the frame reads right to left: `+` at its right, the row’s choices at its left, nothing past the edge', async ({ page }) => {
|
||||
await open(page, { locale: 'ar', catalog: AR })
|
||||
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl')
|
||||
await expect(page.getByRole('textbox', { name: 'Message' })).toHaveAttribute('placeholder', AR['Type / for skills']!)
|
||||
|
||||
const frame = (await page.locator('[data-slot="composer"]').first().boundingBox())!
|
||||
const plus = (await page.getByRole('button', { name: AR['Add files, agents, skills and more'] }).boundingBox())!
|
||||
const talk = (await page.getByRole('button', { name: AR['Voice mode'] }).boundingBox())!
|
||||
const mind = (await page.locator('[data-slot="mind"]').boundingBox())!
|
||||
const middle = frame.x + frame.width / 2
|
||||
expect(plus.x).toBeGreaterThan(middle)
|
||||
expect(talk.x).toBeLessThan(middle)
|
||||
expect(mind.x + mind.width).toBeLessThan(middle)
|
||||
await expect(page.getByLabel(/متوسط/)).toHaveText('متوسط')
|
||||
await expect(page.getByLabel(/تلقائي/)).toHaveText('تلقائي')
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 844 })
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width)
|
||||
await shot(page, `language-ar-${width}.png`)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* An empty Chat's composer in a language, for language.spec.ts: the panel
|
||||
* toggle, the composer with its `+`, dictation, voice mode and the row under
|
||||
* it (model, effort, mode), and the starters under that, inside @hanzo/i18n's
|
||||
* provider as a host mounts it. A press on a starter records its act in
|
||||
* `window.went`, as JSON.
|
||||
*
|
||||
* `window.lang` before the page loads: the locale, and the catalog the
|
||||
* provider loads for it (the spec writes its own, so it reads what it wrote).
|
||||
*/
|
||||
import '@hanzo/font/css'
|
||||
import '@hanzo/ui/theme.css'
|
||||
import '@hanzo/ui/styles/motion.css'
|
||||
import '@hanzo/i18n/rtl.css'
|
||||
import { I18nProvider } from '@hanzo/i18n/react'
|
||||
import { Hanzo, XStack, YStack } from '@hanzo/ui'
|
||||
import { useDictation, useTalk, useVoice } from '@hanzo/voice'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import type { Target } from '../api/call.ts'
|
||||
import { CHAT_MODES, type Mode } from '../gear.ts'
|
||||
import { useMind } from '../mind.ts'
|
||||
import { PanelToggle } from '../panel.tsx'
|
||||
import { Prompt, useEar } from '../prompt.tsx'
|
||||
import { STARTERS } from '../suggest.ts'
|
||||
import { Suggestions } from '../suggestions.tsx'
|
||||
|
||||
export interface Lang {
|
||||
locale: string
|
||||
catalog: Record<string, string>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
lang: Lang
|
||||
went: string[]
|
||||
}
|
||||
}
|
||||
|
||||
window.went = []
|
||||
|
||||
/** A token whose claims say acme/u1; the routed platform reads nothing else of it. */
|
||||
const TOKEN = `x.${btoa(JSON.stringify({ owner: 'acme', sub: 'u1' })).replace(/=+$/, '')}.x`
|
||||
|
||||
function Page() {
|
||||
const t: Target = useMemo(() => ({ api: window.location.origin, token: () => TOKEN, org: 'acme' }), [])
|
||||
const [draft, setDraft] = useState('')
|
||||
const [mode, setMode] = useState<Mode>('auto')
|
||||
const [memory, setMemory] = useState(true)
|
||||
const [research, setResearch] = useState(false)
|
||||
const mind = useMind(t, 'pro')
|
||||
const voice = useVoice({ speech: useEar(t), language: 'en', ...useDictation(draft, setDraft) })
|
||||
const talk = useTalk({ baseUrl: t.api, token: () => TOKEN, org: 'acme' })
|
||||
return (
|
||||
<YStack flex={1} height="100dvh" bg="$background" p="$4" gap="$2">
|
||||
<XStack justify="flex-end">
|
||||
<PanelToggle open={false} onToggle={() => window.went.push('"tabs"')} />
|
||||
</XStack>
|
||||
<YStack flex={1} />
|
||||
<Prompt
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSend={() => window.went.push(JSON.stringify({ sent: draft }))}
|
||||
label="Message"
|
||||
plus={{
|
||||
upload: () => window.went.push('"upload"'),
|
||||
skills: { rows: [], pick: () => {} },
|
||||
research: { on: research, set: setResearch },
|
||||
memory: { on: memory, set: setMemory },
|
||||
}}
|
||||
mind={mind}
|
||||
mode={{ value: mode, set: setMode, items: CHAT_MODES }}
|
||||
voice={voice}
|
||||
talk={talk}
|
||||
below={<Suggestions rows={STARTERS} onAct={(act) => window.went.push(JSON.stringify(act))} onDismiss={() => {}} />}
|
||||
/>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<I18nProvider userId="u1" initialLocale={window.lang.locale} loadCatalog={async () => window.lang.catalog}>
|
||||
<Hanzo theme="dark">
|
||||
<Page />
|
||||
</Hanzo>
|
||||
</I18nProvider>,
|
||||
)
|
||||
@@ -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`)
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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} />),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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} />),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user