Files
hapi/ios/Hapi/Features/Chat/ChatView.swift
T
weishu 6ed83eda3c fix: show unread for running sessions
Running sessions previously suppressed the ordinary unread dot, hiding
answers the user had not seen. Unread is now
manualUnread || contentRevision > readRevision on web, iOS and Android.

Web rows draw the running/background spinner beside the unread dot, and
unread outranks background status. Because session patches arrive before
the message they count, a chat following its visible tail hides its own
ordinary dot until the rendered acknowledgement lands (display-only; web
and iPad split view).
2026-11-01 15:39:46 +08:00

612 lines
24 KiB
Swift

import HapiClient
import HapiProtocol
import HapiUI
import SwiftUI
import UniformTypeIdentifiers
/// The chat screen: an anchored UIKit transcript over the reduced
/// `VisibleChatBlock`s, newest at the bottom (M2f), plus the A-M3ab
/// interaction chrome — composer + queued bar (bottom inset), permission
/// action footers (via `\.chatInteractions`), the session config sheet
/// (toolbar gear), supersede renavigation, and toast notices.
///
/// Transcript layout and scroll intent live in `ChatTranscriptView`.
struct ChatView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.scenePhase) private var scenePhase
@State private var model: ChatModel
/// Adaptive session settings (toolbar gear).
@State private var configOpen = false
/// Files browser push (toolbar folder, A-M4a).
@State private var filesOpen = false
/// File viewer push for `hapi-file://` chat citations (A-M4a).
@State private var viewerRoute: FileViewerRoute?
/// Full inventory; the compact drawer lives with the composer below.
@State private var scratchlistOpen = false
@State private var scratchlistEntry: ScratchlistEntry?
@State private var scratchlistStartsEditing = false
@State private var renameOpen = false
@State private var renameText = ""
@State private var deleteOpen = false
@State private var forkConfirmOpen = false
@State private var historyOpen = false
/// Resume/reopen handed back a superseding session id — the host swaps
/// its navigation entry (HomeView replaces the path element).
private let onNavigateToSession: ((String) -> Void)?
/// Kept for the files/viewer pushes (the model owns its own reference).
private let session: HubSession
private let sessionId: String
/// The iPhone Duo outer screen: 678pt tall, so chrome is kept minimal.
private var isFoldedOuterScreen: Bool {
FoldableDevice.shared.hasHinge && horizontalSizeClass == .compact
}
init(
session: HubSession,
sessionId: String,
onNavigateToSession: ((String) -> Void)? = nil
) {
self.init(session: session, model: ChatModel(session: session, sessionId: sessionId),
onNavigateToSession: onNavigateToSession)
}
init(session: HubSession, model: ChatModel, onNavigateToSession: ((String) -> Void)? = nil) {
_model = State(initialValue: model)
self.session = session
self.sessionId = model.sessionId
self.onNavigateToSession = onNavigateToSession
}
var body: some View {
Group {
if model.isInitialLoading {
initialLoading
} else if model.loadFailed {
loadFailedState
} else if model.blocks.isEmpty && !model.hasMore {
emptyState
.task(id: scenePhase == .active ? model.readThroughRevision : nil) {
if scenePhase == .active {
model.markDisplayed(revision: model.readThroughRevision, serverState: model.renderedReadState)
}
}
} else {
ChatTranscriptView(model: model)
}
}
// Reconnection must not resize the transcript and trigger its
// viewport/bottom-follow layout handlers. Keep it below any warning.
.overlay(alignment: .top) {
reconnectNotice
}
.safeAreaInset(edge: .top, spacing: 0) {
warningBanner
}
// Toast overlays the thread; applying it before the bottom inset
// anchors it just above the composer instead of on top of it.
.overlay(alignment: .bottom) {
noticeToast
}
.safeAreaInset(edge: .bottom, spacing: 0) {
VStack(spacing: 0) {
QueuedMessagesBarView(interactor: model.interactor)
.hapiReadingColumn()
ChatComposerView(
interactor: model.interactor,
dictation: model.dictation,
hugsBottomEdge: isFoldedOuterScreen
) { entry, editing in
scratchlistEntry = entry
scratchlistStartsEditing = editing
scratchlistOpen = true
}
}
}
.modifier(FoldedTitleMinimization(isActive: isFoldedOuterScreen))
.modifier(FoldedSystemTitle(
isActive: isFoldedOuterScreen,
title: model.header.title,
subtitle: model.header.subtitle
))
.toolbar {
// Folded Duo: Back and the actions live in the side bar, so a
// centered title would float right of an empty gap; the system
// title (below) sits at the leading margin instead.
if !isFoldedOuterScreen {
ToolbarItem(placement: .principal) {
headerTitle
}
}
// Two icons max (device feedback: a crowded trailing edge
// squeezed the title out): gear for the frequent config
// switches, everything else behind one menu.
ToolbarItem(placement: .topBarTrailing) {
SessionConfigButton(isPresented: $configOpen) {
SessionConfigView(interactor: model.interactor, notice: model.notice)
}
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
filesOpen = true
} label: {
Label("Session files", systemImage: "folder")
}
Button {
model.interactor.setComposerDestination(.scratchlist)
} label: {
let count = model.interactor.scratchlistCount
if count > 0 {
Label(
String(format: String(localized: "Scratchlist (%lld)"), Int64(count)),
systemImage: "note.text"
)
} else {
Label("Scratchlist", systemImage: "note.text")
}
}
Divider()
if model.interactor.canForkCurrentConversation {
Button {
forkConfirmOpen = true
} label: {
Label("Fork Conversation", systemImage: "arrow.triangle.branch")
}
.disabled(model.interactor.historyActionPending)
}
if !model.historyPointMessages.isEmpty {
Button {
historyOpen = true
} label: {
Label("Rewind or Fork…", systemImage: "clock.arrow.circlepath")
}
}
Button {
model.exportConversation()
} label: {
Label("Export Conversation…", systemImage: "square.and.arrow.up")
}
.disabled(model.isExporting)
Divider()
Button {
renameText = model.header.title
renameOpen = true
} label: {
Label("Rename…", systemImage: "pencil")
}
// Web `SessionActionMenu`: reopen/delete only for a known
// inactive session; a removal closes this chat.
if model.sessionIsActive == false {
Button {
model.interactor.reopenSession()
} label: {
Label("Reopen", systemImage: "arrow.clockwise")
}
.disabled(model.interactor.sessionOpPending)
Button(role: .destructive) {
deleteOpen = true
} label: {
Label("Delete…", systemImage: "trash")
}
.disabled(model.interactor.sessionOpPending)
}
} label: {
Image(systemName: "ellipsis.circle")
}
.accessibilityLabel("More actions")
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(isPresented: $filesOpen) {
FilesView(session: session, sessionId: sessionId)
}
.navigationDestination(item: $viewerRoute) { route in
// A replaced route value must rebuild the screen's @State model
// (HomeView's chat-push precedent).
FileViewerView(session: session, route: route)
.id(route)
}
// Chat markdown citations open the real viewer in full mode with the
// cited line as a hint chip (replaces the root placeholder alert for
// this subtree; the destinations above inherit the handler too).
.handlesHapiLinks { link in
viewerRoute = FileViewerRoute(
sessionId: sessionId,
path: link.path,
mode: .file,
line: link.line
)
}
.sheet(isPresented: $scratchlistOpen) {
ScratchlistView(
store: model.scratchlist,
sessionId: model.sessionId,
attachments: model.scratchlistAttachments,
interactor: model.interactor,
initialEntry: scratchlistEntry,
initiallyEditing: scratchlistStartsEditing
)
}
.alert("Rename Session", isPresented: $renameOpen) {
TextField("Session name", text: $renameText)
Button("Cancel", role: .cancel) {}
Button("Save") {
let name = renameText.trimmingCharacters(in: .whitespacesAndNewlines)
// An untouched title must not freeze an agent-generated one.
if name != model.header.title {
model.interactor.renameSession(name)
}
}
.disabled(!SessionNameRules.isValid(renameText))
}
.alert("Delete Session", isPresented: $deleteOpen) {
Button("Delete", role: .destructive) {
model.interactor.deleteSession()
}
Button("Cancel", role: .cancel) {}
} message: {
Text(SessionNameRules.deleteMessage(title: model.header.title))
}
.alert("Fork Conversation", isPresented: $forkConfirmOpen) {
Button("Fork") { model.interactor.forkConversation() }
Button("Cancel", role: .cancel) {}
} message: {
Text("Create a new session from this point? The current session will not be changed.")
}
.alert(
"Rewind Unavailable",
isPresented: Binding(
get: { model.interactor.rewindForkFallbackLocalId != nil },
set: { if !$0 { model.interactor.dismissRewindForkFallback() } }
),
presenting: model.interactor.rewindForkFallbackLocalId
) { localId in
Button("Fork Here") { model.interactor.forkConversation(fromMessageLocalId: localId) }
Button("Cancel", role: .cancel) {}
} message: { _ in
Text("HAPI did not change this session because its native Codex boundary is ambiguous. Create a new session before this point instead? The current session will not be changed.")
}
.alert(
exportPromptTitle,
isPresented: Binding(
get: { model.exportPrompt != nil },
set: { if !$0 { model.exportPrompt = nil } }
),
presenting: model.exportPrompt
) { prompt in
switch prompt {
case .warning:
Button("Export Anyway") { model.exportConversation(force: true) }
Button("Cancel", role: .cancel) {}
case .tooLarge:
Button("OK", role: .cancel) {}
}
} message: { prompt in
Text(Self.exportPromptMessage(prompt))
}
.fileExporter(
isPresented: Binding(
get: { model.exportDocument != nil },
set: { if !$0 { model.exportDocument = nil } }
),
document: model.exportDocument,
contentType: .json,
defaultFilename: model.exportDocument?.file.filename
) { result in
model.exportFinished(result)
}
.sheet(isPresented: $historyOpen) {
ConversationHistorySheet(model: model)
}
.toolPresentations(model: model, session: session, owner: "chat") { path in
viewerRoute = FileViewerRoute(sessionId: sessionId, path: path, mode: .file)
}
.environment(\.chatMedia, model.mediaLoader)
.environment(\.chatPresentationState, model.presentationState)
.environment(\.hapiMarkdownCache, model.markdownCache)
.environment(\.chatInteractions, model.interactor)
.onChange(of: model.supersededSessionId) {
if let superseding = model.supersededSessionId {
onNavigateToSession?(superseding)
}
}
.onChange(of: scenePhase) {
if scenePhase != .active { model.resetDisplayedReadReceipt() }
}
.onChange(of: scenePhase == .active && model.followsReadableTail, initial: true) { _, following in
model.setTailFollowing(following)
}
}
private var exportPromptTitle: Text {
switch model.exportPrompt {
case .some(.tooLarge): Text("Export Too Large")
default: Text("Large Export")
}
}
static func exportPromptMessage(_ prompt: ChatModel.ExportPrompt) -> String {
func bytes(_ value: Int) -> String {
ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
}
switch prompt {
case .warning(let count, let limit, let estimatedBytes):
return String(
format: String(localized: "This export contains %lld messages (about %@), above the recommended %lld."),
Int64(count), bytes(estimatedBytes), Int64(limit)
)
case .tooLarge(let count, let estimatedBytes, let maxBytes):
return String(
format: String(localized: "This export is too large to prepare safely (%lld messages, %@; maximum %@)."),
Int64(count), bytes(estimatedBytes), bytes(maxBytes)
)
}
}
/// Transient interaction notice (Android snackbar analogue).
@ViewBuilder
private var noticeToast: some View {
if let notice = model.notice {
Text(LocalizedNoticeMapper.map(notice))
.font(.footnote)
.lineLimit(3)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.shadow(radius: 4, y: 2)
.padding(.horizontal, 24)
.padding(.bottom, 8)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
// MARK: - Chrome
private var headerTitle: some View {
VStack(alignment: .leading, spacing: 0) {
Text(model.header.title)
.font(.headline)
.lineLimit(1)
if let subtitle = model.header.subtitle {
HStack(spacing: 4) {
if model.header.flavor != nil {
// Inherits .secondary like the meta text (web:
// currentColor under --app-hint); color variants
// ignore the tint.
AgentFlavorIconView(flavor: model.header.flavor, size: 12)
}
Text(subtitle)
.font(.caption2)
.lineLimit(1)
}
.foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private var warningBanner: some View {
if let warning = model.warning {
HStack(spacing: 8) {
Text(LocalizedNoticeMapper.map(warning))
.font(.footnote)
.lineLimit(2)
Spacer(minLength: 8)
Button("Retry") {
model.retry()
}
.font(.footnote.weight(.semibold))
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.background(.red.opacity(0.14))
.foregroundStyle(.red)
}
}
@ViewBuilder
private var reconnectNotice: some View {
if model.isReconnecting {
Text("Live updates interrupted — reconnecting…")
.font(.footnote)
.multilineTextAlignment(.center)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.foregroundStyle(.orange)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.shadow(radius: 4, y: 2)
.padding(.horizontal, 16)
.padding(.top, 8)
.allowsHitTesting(false)
}
}
// MARK: - Empty / loading / error
private var initialLoading: some View {
VStack(spacing: 12) {
ProgressView()
Text("Loading messages…")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var loadFailedState: some View {
ContentUnavailableView {
Label("Couldn't load this session", systemImage: "wifi.slash")
} description: {
Text("Check the connection to your hub and try again.")
} actions: {
Button("Retry") {
model.retry()
}
.buttonStyle(.borderedProminent)
}
}
private var emptyState: some View {
ContentUnavailableView {
Label("No messages yet", systemImage: "bubble.left.and.bubble.right")
} description: {
Text("Messages will appear here as the agent works.")
}
}
}
/// Folded iPhone Duo: once the reader scrolls toward newer output, the title
/// row stays minimized (content reflows into it) until the transcript's top.
/// The system offers no permanently minimized state.
private struct FoldedTitleMinimization: ViewModifier {
let isActive: Bool
func body(content: Content) -> some View {
if #available(iOS 27.0, *), isActive {
content
.toolbarMinimizationBehavior(.onScrollDown, for: .navigationBar)
.toolbarMinimizationRestoration(.atScrollEdge, for: .navigationBar)
.toolbarMinimizationSafeAreaAdjustment(.enabled, for: .navigationBar)
} else {
content
}
}
}
/// Folded iPhone Duo: the system inline title is leading-aligned, unlike the
/// fixed centered `.principal` slot (a leading item becomes a glass button).
/// The subtitle is plain text, so the agent icon is omitted here.
private struct FoldedSystemTitle: ViewModifier {
let isActive: Bool
let title: String
let subtitle: String?
func body(content: Content) -> some View {
if #available(iOS 26.0, *), isActive {
content
.navigationTitle(Text(verbatim: title))
.navigationSubtitle(Text(verbatim: subtitle ?? ""))
} else {
content
}
}
}
/// Fork or rewind at a loaded user message with a native history point
/// (web message actions). Kept out of the transcript so its cached row
/// heights and text selection stay untouched.
private struct ConversationHistorySheet: View {
@Environment(\.dismiss) private var dismiss
let model: ChatModel
@State private var pending: PendingAction?
private enum PendingAction: Identifiable {
case fork(String)
case rewind(String)
var id: String {
switch self {
case .fork(let localId): "fork:\(localId)"
case .rewind(let localId): "rewind:\(localId)"
}
}
}
var body: some View {
let messages = model.historyPointMessages
NavigationStack {
List {
Section {
ForEach(messages, id: \.id) { message in
row(message)
}
} footer: {
Text("Only messages with a native history point are listed. Load older messages in the conversation to reach earlier ones.")
}
}
.overlay {
if messages.isEmpty {
ContentUnavailableView("No History Points", systemImage: "clock.arrow.circlepath")
}
}
.disabled(model.interactor.historyActionPending)
.navigationTitle("Conversation History")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
.alert(
pendingTitle,
isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }),
presenting: pending
) { action in
switch action {
case .fork(let localId):
Button("Fork") {
model.interactor.forkConversation(fromMessageLocalId: localId)
dismiss()
}
case .rewind(let localId):
Button("Rewind", role: .destructive) {
model.interactor.rewindConversation(toMessageLocalId: localId)
dismiss()
}
}
Button("Cancel", role: .cancel) {}
} message: { action in
switch action {
case .fork:
Text("Create a new session from this point? The current session will not be changed.")
case .rewind:
Text("Rewind this session to this point? Later conversation history will be permanently removed. Files will not be changed.")
}
}
}
.presentationDetents([.medium, .large])
}
private var pendingTitle: Text {
switch pending {
case .some(.rewind): Text("Rewind Conversation")
default: Text("Fork Conversation")
}
}
private func row(_ message: UserTextBlock) -> some View {
let actions = model.interactor.historyActions(forMessageLocalId: message.localId)
return HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(verbatim: message.text.isEmpty ? " " : message.text)
.lineLimit(3)
Text(verbatim: Date(timeIntervalSince1970: Double(message.createdAt) / 1000)
.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Menu {
if actions.fork, let localId = message.localId {
Button("Fork from Here", systemImage: "arrow.triangle.branch") {
pending = .fork(localId)
}
}
if actions.rewind, let localId = message.localId {
Button("Rewind to Here", systemImage: "arrow.uturn.backward", role: .destructive) {
pending = .rewind(localId)
}
}
} label: {
Image(systemName: "ellipsis.circle")
.frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel("History actions")
}
}
}