Files
zeekay 1634d055f4 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:32 -07:00

113 lines
3.7 KiB
Swift

import SwiftUI
struct ChatView: View {
@Environment(Hanzo.self) private var hanzo
@State private var msgs: [Msg] = [
Msg(role: "assistant",
content: "Ask me anything. Set a Hanzo API key in Settings to talk to a real model."),
]
@State private var draft = ""
@State private var busy = false
@State private var error: String?
@State private var showSettings = false
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 10) {
ForEach(msgs) { Bubble(msg: $0) }
}
.padding()
}
.onChange(of: msgs.last?.content) {
withAnimation { proxy.scrollTo(msgs.last?.id, anchor: .bottom) }
}
}
if let error {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
HStack(alignment: .bottom, spacing: 10) {
TextField("Message \(hanzo.model)", text: $draft, axis: .vertical)
.lineLimit(1...5)
.textFieldStyle(.roundedBorder)
.onSubmit(send)
Button(action: send) {
Image(systemName: "arrow.up")
.fontWeight(.bold)
.frame(width: 34, height: 34)
}
.buttonStyle(.borderedProminent)
.clipShape(.circle)
.disabled(busy || draft.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding()
.background(.bar)
}
.navigationTitle("Hanzo Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
Button { showSettings = true } label: { Image(systemName: "gearshape") }
}
.sheet(isPresented: $showSettings) {
NavigationStack { SettingsView() }
}
}
private func send() {
let text = draft.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty, !busy else { return }
// Push the user turn plus an empty assistant turn, then stream into it.
let history = msgs.dropFirst(msgs.count == 1 ? 1 : 0) + [Msg(role: "user", content: text)]
draft = ""
error = nil
busy = true
msgs = Array(history) + [Msg(role: "assistant", content: "")]
Task {
defer { busy = false }
do {
for try await chunk in hanzo.complete(Array(history)) {
msgs[msgs.count - 1].content += chunk
}
} catch {
self.error = error.localizedDescription
msgs = Array(history) // keep the draft the user just lost
}
}
}
}
struct Bubble: View {
let msg: Msg
private var mine: Bool { msg.role == "user" }
var body: some View {
HStack {
if mine { Spacer(minLength: 40) }
Text(msg.content.isEmpty ? "…" : msg.content)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(mine ? Color.hanzo : Color(.secondarySystemBackground))
.foregroundStyle(mine ? .white : .primary)
.clipShape(.rect(cornerRadius: 18))
if !mine { Spacer(minLength: 40) }
}
.id(msg.id)
}
}
#Preview {
NavigationStack { ChatView() }
.environment(Hanzo())
.preferredColorScheme(.dark)
}