mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
The API wrappers existed but iOS had no UI for these actions, while web and Android did. The session row's context menu and the chat's … menu now offer Rename for every session. Active sessions keep Archive. Inactive sessions get Reopen and a confirmed Delete instead of Archive, which the hub rejects with 409 for them. The store applies renames and deletes optimistically. A failed rename refetches server state; a failed delete restores the row and the cached detail. Reopen marks the returned session active right away. When reopen returns a superseding id, the chat seeds the new window, moves the draft and navigates to it, as send-resume already does. A reopen from the list also moves the draft and opens the session. A 422 shows the hub's message with the missing fields. On iPhone, an authoritative removal (delete, or archive elsewhere) now pops that chat, matching the iPad split, which already clears the selection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
11 KiB
Swift
278 lines
11 KiB
Swift
import HapiClient
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
/// 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.
|
|
/// iPhone pushes the chat; iPad and foldable iPhones select a stable detail in
|
|
/// a native split that collapses to a stack when the window is compact.
|
|
/// Spawn, notification and row selection share the same opening path.
|
|
struct HomeView: View {
|
|
let session: HubSession
|
|
|
|
@Environment(AppModel.self) private var model
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
@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] = []
|
|
@State private var tabletNavigation = SessionNavigationState()
|
|
|
|
private var usesSplitNavigation: Bool {
|
|
UIDevice.current.userInterfaceIdiom == .pad || FoldableDevice.shared.hasHinge
|
|
}
|
|
|
|
/// The sidebar is visible next to the chat (not collapsed into a stack).
|
|
private var showsSideBySide: Bool {
|
|
usesSplitNavigation && horizontalSizeClass == .regular
|
|
}
|
|
|
|
/// iPhone Duo outer screen: hub, filter and new session live in the
|
|
/// system's vertical bar, leaving the top bar to the title alone.
|
|
private var isFoldedOuterScreen: Bool {
|
|
FoldableDevice.shared.hasHinge && horizontalSizeClass == .compact
|
|
}
|
|
|
|
init(session: HubSession) {
|
|
self.session = session
|
|
_listModel = State(initialValue: SessionListModel(session: session))
|
|
}
|
|
|
|
var body: some View {
|
|
@Bindable var model = model
|
|
Group {
|
|
if usesSplitNavigation {
|
|
SessionSplitView(navigation: tabletNavigation, onNewSession: { showNewSession = true }) {
|
|
sessionList
|
|
} detail: { sessionId in
|
|
chat(sessionId)
|
|
}
|
|
} else {
|
|
NavigationStack(path: $path) {
|
|
sessionList
|
|
.navigationDestination(for: String.self) { sessionId in
|
|
chat(sessionId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.background(HingeProbe())
|
|
// The hinge is reported after the first render; carry a chat that was
|
|
// already pushed (e.g. a cold-start notification) into the split.
|
|
.onChange(of: usesSplitNavigation) { _, split in
|
|
guard split else { return }
|
|
if let sessionId = path.last { openSession(sessionId) }
|
|
path = []
|
|
}
|
|
// 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
|
|
openSession(sessionId)
|
|
}
|
|
.onChange(of: session.sessionRemoval) { _, removal in
|
|
guard let removal else { return }
|
|
if usesSplitNavigation {
|
|
tabletNavigation.remove(removal.sessionId)
|
|
} else if let index = path.firstIndex(of: removal.sessionId) {
|
|
// Deleted or archived elsewhere: leave the emptied chat.
|
|
path.removeSubrange(index...)
|
|
}
|
|
}
|
|
.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
|
|
openSession(sessionId)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSettings) {
|
|
SettingsView(session: session)
|
|
}
|
|
}
|
|
|
|
private var sessionList: some View {
|
|
VStack(spacing: 0) {
|
|
if let failedHub = model.authFailureNotice {
|
|
authFailureBanner(failedHub: failedHub)
|
|
}
|
|
SessionConnectionNotice(
|
|
state: session.connectionState,
|
|
showsCachedSessions: listModel.isOffline && listModel.hasLoaded
|
|
)
|
|
SessionListView(model: listModel, selection: usesSplitNavigation ? Binding(
|
|
get: { tabletNavigation.selectedSessionId },
|
|
// Native list deselection (e.g. a filter hides the row) is
|
|
// not an authoritative removal and must not close the chat.
|
|
set: { if let id = $0 { openSession(id) } }
|
|
) : nil, onOpenSession: openSession)
|
|
}
|
|
.navigationTitle("Sessions")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.modifier(SidebarTitleRemoval(isSidebar: showsSideBySide || isFoldedOuterScreen))
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) { hubMenu }
|
|
if listModel.showsFilterMenu {
|
|
// Beside the hub menu when the system sidebar toggle shares
|
|
// the trailing edge, so the three icons do not bunch up.
|
|
ToolbarItem(placement: showsSideBySide ? .topBarLeading : .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")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func openSession(_ sessionId: String) {
|
|
if usesSplitNavigation {
|
|
listModel.onSessionOpened(sessionId)
|
|
tabletNavigation.open(sessionId)
|
|
} else if path.last != sessionId {
|
|
path.append(sessionId)
|
|
}
|
|
}
|
|
|
|
private func chat(_ sessionId: String) -> some View {
|
|
ChatView(session: session, sessionId: sessionId) { superseding in
|
|
if usesSplitNavigation {
|
|
tabletNavigation.supersede(sessionId, with: superseding)
|
|
} else if let last = path.indices.last, path[last] == sessionId {
|
|
path[last] = superseding
|
|
}
|
|
// A late response from a chat already left must not steal focus.
|
|
}
|
|
.id(sessionId)
|
|
}
|
|
|
|
// 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")
|
|
.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.")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// A side-by-side sidebar's bar already holds hub, filter, new session and the
|
|
/// system sidebar toggle; at 320pt the inline "Sessions" title only got a
|
|
/// truncated sliver. The title stays set for VoiceOver and collapsed stacks.
|
|
private struct SidebarTitleRemoval: ViewModifier {
|
|
let isSidebar: Bool
|
|
|
|
func body(content: Content) -> some View {
|
|
if #available(iOS 18.0, *) {
|
|
content.toolbar(removing: isSidebar ? .title : nil)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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")
|
|
}
|
|
}
|
|
}
|