mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
204 lines
7.7 KiB
Swift
204 lines
7.7 KiB
Swift
import HapiClient
|
|
import SwiftUI
|
|
|
|
/// Post-pairing home: the session list for the active hub, with the hub
|
|
/// switcher (switch / add / settings / sign out), the "+" new-session sheet
|
|
/// (A-M3c), the Settings sheet (A-M4e), and a unified session-filter menu.
|
|
/// Degraded connections appear below navigation, not among its actions.
|
|
/// Tapping a row pushes the chat (M2f); a successful
|
|
/// spawn dismisses the sheet and pushes the new chat the same way.
|
|
struct HomeView: View {
|
|
let session: HubSession
|
|
|
|
@Environment(AppModel.self) private var model
|
|
@State private var listModel: SessionListModel
|
|
@State private var confirmSignOut = false
|
|
@State private var showNewSession = false
|
|
@State private var showSettings = false
|
|
@State private var path: [String] = []
|
|
|
|
init(session: HubSession) {
|
|
self.session = session
|
|
_listModel = State(initialValue: SessionListModel(session: session))
|
|
}
|
|
|
|
var body: some View {
|
|
@Bindable var model = model
|
|
NavigationStack(path: $path) {
|
|
VStack(spacing: 0) {
|
|
if let failedHub = model.authFailureNotice {
|
|
authFailureBanner(failedHub: failedHub)
|
|
}
|
|
SessionConnectionNotice(
|
|
state: session.connectionState,
|
|
showsCachedSessions: listModel.isOffline && listModel.hasLoaded
|
|
)
|
|
SessionListView(model: listModel) { sessionId in
|
|
path.append(sessionId)
|
|
}
|
|
}
|
|
.navigationDestination(for: String.self) { sessionId in
|
|
ChatView(session: session, sessionId: sessionId) { superseding in
|
|
// Resume returned a different session id (A-M3a): replace
|
|
// the current chat entry so back still pops to the list.
|
|
if let last = path.indices.last, path[last] == sessionId {
|
|
path[last] = superseding
|
|
} else {
|
|
path.append(superseding)
|
|
}
|
|
}
|
|
// A replaced path element must rebuild the screen's @State.
|
|
.id(sessionId)
|
|
}
|
|
.navigationTitle("Sessions")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
hubMenu
|
|
}
|
|
if listModel.showsFilterMenu {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
SessionFilterMenu(model: listModel)
|
|
}
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
showNewSession = true
|
|
} label: {
|
|
Label("New Session", systemImage: "plus")
|
|
.frame(minWidth: 44, minHeight: 44)
|
|
}
|
|
.accessibilityIdentifier("home.new-session")
|
|
}
|
|
}
|
|
.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.")
|
|
}
|
|
}
|
|
// Notification tap (P3): consume the pending target into this hub's
|
|
// navigation path. `initial: true` covers a tap that cold-started
|
|
// the app before this view existed.
|
|
.onChange(of: model.pendingOpenSessionId, initial: true) { _, sessionId in
|
|
guard let sessionId else { return }
|
|
model.pendingOpenSessionId = nil
|
|
if path.last != sessionId {
|
|
path.append(sessionId)
|
|
}
|
|
}
|
|
.sheet(isPresented: $model.showAddHub) {
|
|
PairingFlowView(context: .addHub)
|
|
}
|
|
.sheet(isPresented: $showNewSession) {
|
|
NewSessionView(session: session) { sessionId in
|
|
// Navigate-replace: drop the sheet, push the fresh chat.
|
|
showNewSession = false
|
|
path.append(sessionId)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSettings) {
|
|
SettingsView(session: session)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
Divider()
|
|
Button {
|
|
showSettings = true
|
|
} label: {
|
|
Label("Settings", systemImage: "gearshape")
|
|
}
|
|
Button(role: .destructive) {
|
|
confirmSignOut = true
|
|
} label: {
|
|
Label("Sign Out…", systemImage: "rectangle.portrait.and.arrow.right")
|
|
}
|
|
} label: {
|
|
Label("Hubs", systemImage: "server.rack")
|
|
.frame(minWidth: 44, minHeight: 44)
|
|
}
|
|
.accessibilityValue(HubDisplay.host(session.hubUrl))
|
|
.accessibilityIdentifier("home.hubs")
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// One status line; a failed list refresh takes precedence over SSE status.
|
|
struct SessionConnectionNotice: View {
|
|
let state: SSEConnectionState
|
|
let showsCachedSessions: Bool
|
|
|
|
var message: String? {
|
|
if showsCachedSessions { return String(localized: "Offline — showing cached sessions") }
|
|
switch state {
|
|
case .connected: return nil
|
|
case .connecting: return String(localized: "Connecting…")
|
|
case .backoff: return String(localized: "Reconnecting…")
|
|
case .suspended: return String(localized: "Paused")
|
|
case .idle: return String(localized: "Offline")
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
if let message {
|
|
Label(message, systemImage: "wifi.exclamationmark")
|
|
.font(.footnote)
|
|
.foregroundStyle(.primary)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 6)
|
|
.background(.orange.opacity(0.15))
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityIdentifier("home.connection-notice")
|
|
}
|
|
}
|
|
}
|