mirror of
https://github.com/hanzo-templates/expo-chat.git
synced 2026-10-30 20:54:58 +00:00
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.
111 lines
3.9 KiB
TypeScript
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 },
|
|
});
|