fix(ios): refine pairing and session list UX

- Pairing now allows direct entry/scan with inline progress and error feedback
- PairingConfirmView is now deep-link-only for security confirmations
- Session list rows: move thinking spinner and unread indicator to trailing edge
- Extracted shared PairingAttempt state for reuse
- Updated strings: "Continue" → "Pair" button label
- Removed unused PendingPairing.source

Claude-Session: https://claude.ai/code/session_01ViH4oaLSpTcksxDFEMQgeD
This commit is contained in:
weishu
2026-08-27 11:16:53 +08:00
parent 4f407a5b9d
commit bc9df82dc6
7 changed files with 139 additions and 90 deletions
+35 -21
View File
@@ -1,16 +1,18 @@
import HapiClient
import HapiProtocol
import SwiftUI
/// Manual pairing path for hubs without `--relay` (nothing to scan): type or
/// paste the hub URL and the access token the hub prints at startup, then
/// continue into the shared confirm step.
/// pair directly with inline progress and error states.
struct ManualEntryView: View {
@Environment(\.dismiss) private var dismiss
@Environment(AppModel.self) private var model
@State private var hubUrl = ""
@State private var accessToken = ""
@State private var pending: PendingPairing?
@State private var attempt = PairingAttempt()
private var canContinue: Bool {
private var canPair: Bool {
!hubUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
@@ -24,6 +26,7 @@ struct ManualEntryView: View {
.textContentType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.disabled(attempt.isPairing)
} header: {
Text("Hub URL")
} footer: {
@@ -35,18 +38,35 @@ struct ManualEntryView: View {
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.monospaced()
.disabled(attempt.isPairing)
} header: {
Text("Access token")
} footer: {
Text("Printed by the hub at startup, and shown in the web app under Settings → Companion Pairing. Pasting a full pairing link into either field also works.")
}
Section {
Button("Continue") {
continueToConfirm()
if let failure = attempt.failure {
Section {
PairingErrorView(failure: failure)
}
.frame(maxWidth: .infinity)
.disabled(!canContinue && parsePastedLink() == nil)
}
Section {
Button {
pairNow()
} label: {
if attempt.isPairing {
HStack(spacing: 8) {
ProgressView()
Text("Pairing…")
}
.frame(maxWidth: .infinity)
} else {
Text(attempt.failure == nil ? String(localized: "Pair") : String(localized: "Try Again"))
.frame(maxWidth: .infinity)
}
}
.disabled((!canPair && parsePastedLink() == nil) || attempt.isPairing)
}
}
.navigationTitle("Enter Hub Details")
@@ -58,9 +78,7 @@ struct ManualEntryView: View {
}
}
}
.navigationDestination(item: $pending) { pending in
PairingConfirmView(pending: pending)
}
.interactiveDismissDisabled(attempt.isPairing)
}
}
@@ -70,25 +88,21 @@ struct ManualEntryView: View {
BindLink.parse(hubUrl) ?? BindLink.parse(accessToken)
}
private func continueToConfirm() {
private func pairNow() {
if let link = parsePastedLink() {
pending = PendingPairing(
hubUrl: link.hubUrl,
accessToken: link.accessToken,
source: .manual
)
attempt.pair(model, hubUrl: link.hubUrl, accessToken: link.accessToken)
return
}
var address = hubUrl.trimmingCharacters(in: .whitespacesAndNewlines)
// Typing the scheme on a phone is annoying; local hubs serve plain
// HTTP, and the confirm step shows the resulting URL before pairing.
// HTTP.
if !address.isEmpty, !address.contains("://") {
address = "http://\(address)"
}
pending = PendingPairing(
attempt.pair(
model,
hubUrl: address,
accessToken: accessToken.trimmingCharacters(in: .whitespacesAndNewlines),
source: .manual
accessToken: accessToken.trimmingCharacters(in: .whitespacesAndNewlines)
)
}
}
@@ -0,0 +1,28 @@
import HapiClient
import Observation
/// Inline pairing progress/error state shared by manual entry, QR scan,
/// and the deep-link confirm step.
@Observable @MainActor
final class PairingAttempt {
private(set) var isPairing = false
var failure: PairingFailure?
func pair(_ model: AppModel, hubUrl: String, accessToken: String) {
guard !isPairing else { return }
isPairing = true
failure = nil
Task {
do {
try await model.pair(hubUrl: hubUrl, accessToken: accessToken)
// Success: AppModel flips to .paired and tears down the
// presenting hierarchy; nothing to do here.
} catch let pairingFailure as PairingFailure {
failure = pairingFailure
} catch {
failure = .unreachable
}
isPairing = false
}
}
}
@@ -1,9 +1,9 @@
import HapiClient
import SwiftUI
/// Final confirmation step shared by every pairing path (deep link, QR scan,
/// manual entry): shows what is about to be paired, runs `AppModel.pair`
/// with progress, and renders ``PairingErrorView`` states on failure.
/// Confirmation step for the deep-link path (`hapicompanion://bind`) — the
/// one entry where an external link initiates pairing, so an explicit user
/// confirmation stays in between. Manual entry and QR scan pair inline.
///
/// On success `AppModel` clears all pairing presentation state and flips to
/// `.paired`, which dismisses this view's container — nothing to do here.
@@ -11,8 +11,7 @@ struct PairingConfirmView: View {
let pending: PendingPairing
@Environment(AppModel.self) private var model
@State private var isPairing = false
@State private var failure: PairingFailure?
@State private var attempt = PairingAttempt()
private var isAddingAnotherHub: Bool {
if case .paired = model.state { return true }
@@ -37,7 +36,7 @@ struct PairingConfirmView: View {
: String(localized: "The app checks the hub is reachable, then exchanges the token for a session."))
}
if let failure {
if let failure = attempt.failure {
Section {
PairingErrorView(failure: failure)
}
@@ -45,43 +44,25 @@ struct PairingConfirmView: View {
Section {
Button {
pairNow()
attempt.pair(model, hubUrl: pending.hubUrl, accessToken: pending.accessToken)
} label: {
if isPairing {
if attempt.isPairing {
HStack(spacing: 8) {
ProgressView()
Text("Pairing…")
}
.frame(maxWidth: .infinity)
} else {
Text(failure == nil ? String(localized: "Pair") : String(localized: "Try Again"))
Text(attempt.failure == nil ? String(localized: "Pair") : String(localized: "Try Again"))
.frame(maxWidth: .infinity)
}
}
.disabled(isPairing)
.disabled(attempt.isPairing)
}
}
.navigationTitle(isAddingAnotherHub ? String(localized: "Add Hub") : String(localized: "Pair"))
.navigationBarTitleDisplayMode(.inline)
.interactiveDismissDisabled(isPairing)
}
private func pairNow() {
guard !isPairing else { return }
isPairing = true
failure = nil
Task {
do {
try await model.pair(hubUrl: pending.hubUrl, accessToken: pending.accessToken)
// Success: AppModel flipped to .paired and closed the pairing
// surfaces; this view is on its way out.
} catch let pairingFailure as PairingFailure {
failure = pairingFailure
} catch {
failure = .unreachable
}
isPairing = false
}
.interactiveDismissDisabled(attempt.isPairing)
}
}
+43 -16
View File
@@ -1,3 +1,4 @@
import HapiClient
import HapiProtocol
import SwiftUI
import UIKit
@@ -6,13 +7,14 @@ import VisionKit
/// QR scanning step: a VisionKit live scanner that feeds every recognized
/// string through ``BindLink/parse(_:)`` (so both the companion deeplink QR
/// and the web direct-access QR pair) and pushes the shared confirm step on
/// success. Falls back to guidance when scanning is unsupported (Simulator,
/// no camera) or camera access is denied.
/// and the web direct-access QR pair) and pairs immediately on success, with
/// inline progress/error below the scanner. Falls back to guidance when
/// scanning is unsupported (Simulator, no camera) or camera access is denied.
struct QRScannerView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var scanned: PendingPairing?
@Environment(AppModel.self) private var model
@State private var attempt = PairingAttempt()
@State private var cameraDenied = false
@State private var sawForeignCode = false
@@ -28,19 +30,20 @@ struct QRScannerView: View {
}
}
}
.navigationDestination(item: $scanned) { pending in
PairingConfirmView(pending: pending)
}
.interactiveDismissDisabled(attempt.isPairing)
}
}
@ViewBuilder
private var scannerContent: some View {
if DataScannerViewController.isSupported {
DataScannerRepresentable(isActive: scanned == nil, onScan: handleScannedString)
DataScannerRepresentable(
isActive: !attempt.isPairing && attempt.failure == nil,
onScan: handleScannedString
)
.ignoresSafeArea(edges: .bottom)
.overlay(alignment: .bottom) {
hintBar
statusBar
}
.overlay {
if cameraDenied {
@@ -65,6 +68,33 @@ struct QRScannerView: View {
}
}
@ViewBuilder
private var statusBar: some View {
if attempt.isPairing {
HStack(spacing: 8) {
ProgressView()
Text("Pairing…")
}
.padding(12)
.frame(maxWidth: .infinity)
.background(.thinMaterial)
} else if let failure = attempt.failure {
VStack(alignment: .leading, spacing: 12) {
PairingErrorView(failure: failure)
Button("Scan Again") {
attempt.failure = nil
}
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
}
.padding(12)
.frame(maxWidth: .infinity)
.background(.thinMaterial)
} else {
hintBar
}
}
private var hintBar: some View {
Text(sawForeignCode
? String(localized: "That code is not a HAPI pairing QR — scan one printed by the hub.")
@@ -95,24 +125,21 @@ struct QRScannerView: View {
/// Returns whether the payload was accepted (stops further scanning).
private func handleScannedString(_ raw: String) -> Bool {
guard scanned == nil else { return true }
guard !attempt.isPairing, attempt.failure == nil else { return true }
guard let link = BindLink.parse(raw) else {
sawForeignCode = true
return false
}
UINotificationFeedbackGenerator().notificationOccurred(.success)
scanned = PendingPairing(
hubUrl: link.hubUrl,
accessToken: link.accessToken,
source: .qrScan
)
sawForeignCode = false
attempt.pair(model, hubUrl: link.hubUrl, accessToken: link.accessToken)
return true
}
}
/// `DataScannerViewController` wrapped for SwiftUI, restricted to QR codes.
/// `isActive` gates scanning so a successful scan freezes the camera while
/// the confirm step is up and re-arms when the user navigates back.
/// pairing runs (or its error is showing) and re-arms on "Scan Again".
private struct DataScannerRepresentable: UIViewControllerRepresentable {
var isActive: Bool
var onScan: (String) -> Bool
@@ -1,5 +1,6 @@
import HapiClient
import HapiProtocol
import HapiUI
import SwiftUI
/// The session list (A-M2a) — standalone screen: navigation and hub chrome
@@ -18,6 +19,7 @@ import SwiftUI
/// - long-press context menu → pin (none/project/global) + archive with
/// optimistic store updates; failures land in an alert.
struct SessionListView: View {
@Environment(\.hapiTheme) private var theme
@State private var model: SessionListModel
private let onOpenSession: (String) -> Void
@@ -127,6 +129,9 @@ struct SessionListView: View {
SessionRowView(row: row, now: now)
}
.buttonStyle(.plain)
// Default separator color reads heavy against these rows; the theme
// divider is the WeChat-style faint hairline.
.listRowSeparatorTint(theme.divider)
.contextMenu {
contextMenuActions(row)
}
@@ -227,12 +232,15 @@ struct SessionRowView: View {
}
private var titleLine: some View {
// Spinner/dot pinned to the trailing edge next to the timestamp
// (Android row order), so they don't drift with the title length.
HStack(spacing: 6) {
AgentFlavorIconView(flavor: row.flavor)
Text(row.title)
.font(.body)
.fontWeight(row.unread ? .semibold : .regular)
.lineLimit(1)
Spacer(minLength: 4)
if row.summary.active && row.summary.thinking {
ProgressView()
.scaleEffect(0.7)
@@ -246,7 +254,6 @@ struct SessionRowView: View {
.frame(width: 8, height: 8)
.accessibilityLabel("Unread")
}
Spacer(minLength: 4)
Text(formatRelativeAge(now: now, thenEpochMs: row.summary.updatedAt))
.font(.caption2)
.foregroundStyle(.secondary)
+5 -13
View File
@@ -4,20 +4,13 @@ import HapiProtocol
import Observation
import SwiftUI
/// A pairing that awaits user confirmation — from a deep link, a scanned QR,
/// or manual entry. Drives the confirm sheet.
struct PendingPairing: Identifiable, Hashable {
enum Source: Hashable {
case deepLink
case qrScan
case manual
}
/// A pairing that awaits user confirmation — from a deep link. Drives the
/// confirm sheet.
struct PendingPairing: Identifiable {
let id = UUID()
/// Hub URL as carried by the link/entry (normalized during `pair`).
/// Hub URL as carried by the link (normalized during `pair`).
let hubUrl: String
let accessToken: String
let source: Source
}
/// Root application state: which hubs are paired, which one is active, and
@@ -184,8 +177,7 @@ final class AppModel {
showAddHub = false
pendingPairing = PendingPairing(
hubUrl: link.hubUrl,
accessToken: link.accessToken,
source: .deepLink
accessToken: link.accessToken
)
return true
}
+10 -10
View File
@@ -931,16 +931,6 @@
}
}
},
"Continue": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "继续"
}
}
}
},
"Copy Contents": {
"localizations": {
"zh-Hans": {
@@ -2981,6 +2971,16 @@
}
}
},
"Scan Again": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "重新扫描"
}
}
}
},
"Scan QR Code": {
"localizations": {
"zh-Hans": {