Files
hapi/ios/Hapi/Features/Home/HomeView.swift
T
weishu a4355d1837 feat(ios): read-only chat assembly (A-M2f)
Wires the merged M2 pieces into a usable pair -> list -> live chat flow:

- pbxproj: link HapiUI into the app target (UUIDs C7/C8, mirroring the
  existing HapiProtocol/HapiClient product references; only project change).
- HubSession: MessageWindowControllers registry + per-chat ChatSession
  factory with in-memory per-session SSE resume cursors and scene-phase
  forwarding to the active chat.
- ChatSession (app): session-scope SSEClient while the chat is open;
  window opened/activated before subscribing; a single consume task awaits
  every event into the window actor, giving Android-channel-equivalent
  arrival-order ingestion; session-updated and friends route through the
  shared SyncEventRouter (detail patching), session-removed clears the
  window, a gap handshake triggers full resync + detail refetch + catch-up
  tail sync; stop hands the cursor back for seamless reopen.
- ChatPipeline (HapiClient): actor running the reduction path off-main -
  queued-row filter, normalization memoized by row instance identity,
  reduce + buildVisibleChatBlocks with previousGroups-stable group ids.
- ChatModel: window state + detail agentState + machine labels -> serial
  ~100 ms-coalesced pipeline loop (first run immediate, publishes strictly
  ordered), header title cascade, loading/empty/error states, loadOlder /
  retry, last-seen stamping on every update.
- ChatView: bottom-anchored ScrollView/LazyVStack (defaultScrollAnchor +
  scrollPosition(id:)), auto-stick at bottom, new-messages pill, top
  sentinel paging with documented scroll re-anchoring, degraded banners.
- Block views: user bubble w/ attachment chips, agent markdown, collapsed
  reasoning, tool cards (knownTools.tsx-parity presentation, status chip,
  read-only permission row w/ pending banner, per-tool bodies: terminal,
  before/after edits, write content, unified-diff detection -> DiffTextView,
  checklists, read-only questions, pretty-JSON fallback, children rail),
  tool groups, centered event rows (presentation.ts port), cli output,
  generated images (authed bytes + in-memory cache + full-screen viewer),
  codex review verdict card.
- Navigation/links: session list now pushes ChatView; app-level
  \.hapiOpenURL handler (https/http -> SFSafariViewController, custom
  schemes confirm first, hapi-file:// -> M4 placeholder) + theme injection.
- Tests: ChatPipelineTests drive the runner with fixture-derived windows
  (stable ids, memo-stable recomputes, queued filter, group-id stability
  across an older-page arrival).
- README: M2f app-layer description.
2026-08-17 20:55:36 +08:00

144 lines
4.7 KiB
Swift

import HapiClient
import SwiftUI
/// Post-pairing home: the session list for the active hub, with the hub
/// switcher (switch / add / sign out) and the live global-SSE connection dot
/// in the toolbar. Tapping a row pushes the read-only chat (M2f).
struct HomeView: View {
let session: HubSession
@Environment(AppModel.self) private var model
@State private var confirmSignOut = false
@State private var path: [String] = []
var body: some View {
@Bindable var model = model
NavigationStack(path: $path) {
VStack(spacing: 0) {
if let failedHub = model.authFailureNotice {
authFailureBanner(failedHub: failedHub)
}
SessionListView(session: session) { sessionId in
path.append(sessionId)
}
}
.navigationDestination(for: String.self) { sessionId in
ChatView(session: session, sessionId: sessionId)
}
.navigationTitle(HubDisplay.host(session.hubUrl))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
connectionIndicator
}
ToolbarItem(placement: .topBarTrailing) {
hubMenu
}
}
.confirmationDialog(
"Sign out of \(HubDisplay.host(session.hubUrl))?",
isPresented: $confirmSignOut,
titleVisibility: .visible
) {
Button("Sign Out", role: .destructive) {
model.signOut(hub: session.hubUrl)
}
} message: {
Text("Removes the stored access token for this hub. "
+ "Pair again to reconnect.")
}
}
.sheet(isPresented: $model.showAddHub) {
PairingFlowView(context: .addHub)
}
}
// MARK: - Connection state
private var connectionIndicator: some View {
HStack(spacing: 6) {
Circle()
.fill(connectionColor)
.frame(width: 8, height: 8)
Text(connectionLabel)
.font(.footnote)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Connection: \(connectionLabel)")
}
private var connectionColor: Color {
switch session.connectionState {
case .connected: .green
case .connecting, .backoff: .orange
case .idle, .suspended: .gray
}
}
private var connectionLabel: String {
switch session.connectionState {
case .connected: "Live"
case .connecting: "Connecting…"
case .backoff: "Reconnecting…"
case .suspended: "Paused"
case .idle: "Offline"
}
}
// MARK: - Hub switcher
private var hubMenu: some View {
Menu {
Section("Hubs") {
ForEach(model.hubs, id: \.self) { hub in
Button {
model.switchHub(to: hub)
} label: {
if hub == session.hubUrl {
Label(HubDisplay.host(hub), systemImage: "checkmark")
} else {
Text(HubDisplay.host(hub))
}
}
}
}
Button {
model.showAddHub = true
} label: {
Label("Add Hub…", systemImage: "plus")
}
Button(role: .destructive) {
confirmSignOut = true
} label: {
Label("Sign Out…", systemImage: "rectangle.portrait.and.arrow.right")
}
} label: {
Label("Hubs", systemImage: "server.rack")
}
}
private func authFailureBanner(failedHub: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("\(HubDisplay.host(failedHub)) rejected its stored "
+ "credentials and was signed out. Pair it again from the hub "
+ "menu.")
.font(.footnote)
Spacer(minLength: 0)
Button {
model.authFailureNotice = nil
} label: {
Image(systemName: "xmark")
.font(.footnote.bold())
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.top, 8)
}
}