Files
zeekay 0e7a53d035 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:27 -07:00

178 lines
5.6 KiB
Dart

import 'package:flutter/material.dart';
import 'hanzo.dart';
import 'settings.dart';
import 'theme.dart';
void main() => runApp(const ChatApp());
class ChatApp extends StatelessWidget {
const ChatApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Hanzo Chat',
debugShowCheckedModeBanner: false,
theme: hanzoTheme(),
home: const ChatPage(),
);
}
class ChatPage extends StatefulWidget {
const ChatPage({super.key});
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
static const _greeting =
Msg('assistant', 'Ask me anything. Set a Hanzo API key in Settings to talk to a real model.');
final _msgs = <Msg>[_greeting];
final _input = TextEditingController();
final _scroll = ScrollController();
bool _busy = false;
String? _err;
@override
void dispose() {
_input.dispose();
_scroll.dispose();
super.dispose();
}
Future<void> _send() async {
final text = _input.text.trim();
if (text.isEmpty || _busy) return;
// Push the user turn plus an empty assistant turn, then stream into it.
final history = [..._msgs.where((m) => m != _greeting), Msg('user', text)];
setState(() {
_input.clear();
_err = null;
_busy = true;
_msgs
..clear()
..addAll([...history, const Msg('assistant', '')]);
});
try {
await for (final chunk in Hanzo.instance.complete(history)) {
setState(() => _msgs[_msgs.length - 1] = Msg('assistant', _msgs.last.content + chunk));
_toBottom();
}
} catch (e) {
setState(() {
_err = '$e';
_msgs
..clear()
..addAll(history);
});
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _toBottom() => WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent);
});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Hanzo Chat'),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const SettingsPage())),
),
],
),
body: Column(
children: [
Expanded(
child: ListView.separated(
controller: _scroll,
padding: const EdgeInsets.all(16),
itemCount: _msgs.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (_, i) => Bubble(msg: _msgs[i]),
),
),
if (_err != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(_err!, style: const TextStyle(color: Color(0xFFFF6B6B), fontSize: 13)),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _input,
minLines: 1,
maxLines: 5,
onSubmitted: (_) => _send(),
decoration: InputDecoration(
hintText: 'Message ${Hanzo.instance.model}',
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
),
const SizedBox(width: 10),
FilledButton(
onPressed: _busy ? null : _send,
style: FilledButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(14),
),
child: _busy
? const SizedBox(
width: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.arrow_upward, size: 18),
),
],
),
),
),
],
),
);
}
class Bubble extends StatelessWidget {
const Bubble({super.key, required this.msg});
final Msg msg;
@override
Widget build(BuildContext context) {
final mine = msg.role == 'user';
return Align(
alignment: mine ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.86),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: mine ? seed : const Color(0xFF141417),
border: mine ? null : Border.all(color: const Color(0xFF232329)),
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(18),
topRight: const Radius.circular(18),
bottomLeft: Radius.circular(mine ? 18 : 4),
bottomRight: Radius.circular(mine ? 4 : 18),
),
),
child: Text(
msg.content.isEmpty ? '…' : msg.content,
style: TextStyle(color: mine ? Colors.white : Colors.white.withValues(alpha: 0.92), height: 1.4),
),
),
);
}
}