Files
zeekay ba26864780 Add the iOS lane: hanzo-templates had zero of them
The catalogue shipped 71 web templates and one EMPTY `mobile` repo, so
"build an app" had no starting point on the platform people actually
ship to. This is the iOS lane — Expo/React Native and Flutter for
cross-platform, SwiftUI for native.

The constraint that shaped every file here: an iOS archive needs macOS +
Xcode + a signing identity, and our arc fleet has none. hanzoai has 519
registered runners and every one reports `os: linux`; luxfi has 151, same
story; zooai has none. The only macOS builds in the org today
(hanzoai/world build-desktop.yml) run on GitHub-HOSTED macos-14, which is
exactly what the house CI rule says not to do.

So `hanzo.yml` declares only what a Linux arc runner can genuinely
execute — typecheck + web bundle for Expo, analyze + test + web for
Flutter, `swiftc -parse` for SwiftUI — and the macOS archive lives in a
separate `workflow_dispatch`-only `ios.yml`. A job pinned to a label no
runner advertises would sit queued forever if it fired on push; parking
it is honest, a permanently-yellow check is not. The README of every
template states the gap and the one command that closes it.

Same reasoning on the demos. Expo and Flutter both have a real web
target, so `<slug>.hanzo.app` serves the SAME source the simulator runs.
SwiftUI has no web target, so those two templates ship with no demo link
rather than a screenshot pretending to be one.

Upstreams are permissive only and recorded per template: expo/expo
templates (MIT), flutter/samples (BSD-3-Clause), XcodeGen (MIT).

Second measured gap, found while wiring this up: `hanzo-templates` is not in
arcd's `orgs:` list either (hanzoai, luxfi, zooai, parsdao, zenlm), so the
Linux jobs would queue with no runner too. Both gaps are one installer
re-run, no code change; both are spelled out in every README rather than
left to be discovered on the first red build.

Third gap, found by probing rather than assuming, and FIXED here: every
run in this org died as `startup_failure` with no logs and no annotation.
A bare `runs-on: [self-hosted, linux]` probe queued fine while the same
job importing hanzoai/ci failed identically at @v1 and at a pinned SHA —
so it was never the tag. The org defaulted GITHUB_TOKEN to `read`, and
the reusable declares `permissions: packages: write`; a caller cannot
exceed the org maximum, so GitHub kills the run before creating a job.
hanzo-templates now defaults to `write` like hanzoai/luxfi/zooai, and
runs reach `queued` — the honest state, waiting on gap 2.
2026-07-27 23:25:40 -07:00

111 lines
3.9 KiB
TypeScript

import { useRef, useState } from 'react';
import {
ActivityIndicator, FlatList, KeyboardAvoidingView, Platform, Pressable,
StyleSheet, Text, TextInput, View,
} from 'react-native';
import { complete, Msg, useConfig } from '../hanzo';
import { t } from '../theme';
const GREETING: Msg = {
role: 'assistant',
content: 'Ask me anything. Set a Hanzo API key in Settings to talk to a real model.',
};
export default function Chat() {
const cfg = useConfig();
const [msgs, setMsgs] = useState<Msg[]>([GREETING]);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const list = useRef<FlatList<Msg>>(null);
const send = async () => {
const text = draft.trim();
if (!text || busy) return;
setDraft('');
setErr(null);
setBusy(true);
// Push the user turn plus an empty assistant turn, then stream into it.
const history = [...msgs.filter((m) => m !== GREETING), { role: 'user', content: text } as Msg];
setMsgs([...history, { role: 'assistant', content: '' }]);
try {
await complete(history, (chunk) =>
setMsgs((prev) => {
const next = prev.slice();
next[next.length - 1] = {
role: 'assistant',
content: next[next.length - 1].content + chunk,
};
return next;
}),
);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
setMsgs(history);
} finally {
setBusy(false);
}
};
return (
<KeyboardAvoidingView
style={s.wrap}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={90}>
<FlatList
ref={list}
data={msgs}
keyExtractor={(_, i) => String(i)}
contentContainerStyle={{ padding: t.pad, gap: 10 }}
onContentSizeChange={() => list.current?.scrollToEnd({ animated: true })}
renderItem={({ item }) => <Bubble msg={item} />}
/>
{err && <Text style={s.err}>{err}</Text>}
<View style={s.bar}>
<TextInput
style={s.input}
value={draft}
onChangeText={setDraft}
onSubmitEditing={send}
placeholder={cfg.key ? `Message ${cfg.model}` : 'Set a key in Settings'}
placeholderTextColor={t.dim}
multiline
/>
<Pressable style={[s.send, (busy || !draft.trim()) && { opacity: 0.4 }]} onPress={send}>
{busy ? <ActivityIndicator color="#fff" size="small" /> : <Text style={s.sendText}>↑</Text>}
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
function Bubble({ msg }: { msg: Msg }) {
const mine = msg.role === 'user';
return (
<View style={[s.bubble, mine ? s.mine : s.theirs]}>
<Text style={[s.text, mine && { color: '#fff' }]}>{msg.content || '…'}</Text>
</View>
);
}
const s = StyleSheet.create({
wrap: { flex: 1, backgroundColor: t.bg },
bubble: { maxWidth: '86%', borderRadius: 18, paddingHorizontal: 14, paddingVertical: 10 },
mine: { alignSelf: 'flex-end', backgroundColor: t.accent, borderBottomRightRadius: 4 },
theirs: { alignSelf: 'flex-start', backgroundColor: t.card, borderWidth: 1, borderColor: t.line, borderBottomLeftRadius: 4 },
text: { color: t.text, fontSize: 16, lineHeight: 23 },
err: { color: '#ff6b6b', fontSize: 13, paddingHorizontal: t.pad, paddingBottom: 6 },
bar: {
flexDirection: 'row', alignItems: 'flex-end', gap: 10, padding: t.pad,
borderTopWidth: 1, borderTopColor: t.line, backgroundColor: t.bg,
},
input: {
flex: 1, maxHeight: 120, backgroundColor: t.card, color: t.text, borderRadius: 20,
borderWidth: 1, borderColor: t.line, paddingHorizontal: 14, paddingVertical: 10, fontSize: 16,
},
send: { width: 40, height: 40, borderRadius: 20, backgroundColor: t.accent, alignItems: 'center', justifyContent: 'center' },
sendText: { color: '#fff', fontSize: 20, fontWeight: '700', lineHeight: 22 },
});