Files
hapi/ios/Hapi/Features/Chat/ChatView.swift
T
weishu fa02830a60 fix(ios): show the chat title in the Mac toolbar
The Mac window's NSToolbar drops the custom .principal header, and the
hidden titlebar title also hid the navigation title. Use the system title
on Mac (as on the folded Duo) with a unified toolbar instead.
2026-11-03 11:21:32 +08:00

636 lines
25 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
}
/// Folded Duo and Mac use the system title: a Mac window's NSToolbar
/// drops the custom `.principal` header.
private var usesSystemTitle: Bool {
#if targetEnvironment(macCatalyst)
true
#else
isFoldedOuterScreen
#endif
}
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)
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: usesSystemTitle,
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 !usesSystemTitle {
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)
.focusedSceneValue(\.chatCommands, ChatCommandActions(
openSessionSettings: { configOpen = true },
openFiles: { filesOpen = true },
rename: {
renameText = model.header.title
renameOpen = true
},
export: model.isExporting ? nil : { model.exportConversation() },
stop: model.interactor.composer.canSteer ? { model.interactor.abortSession() } : nil
))
.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
)
.macSheetSizing()
}
.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)
.macSheetSizing()
}
.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 and Mac: 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;
/// before iOS 26 (macOS 14/15) only the title is shown.
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 if isActive {
content.navigationTitle(Text(verbatim: title))
} 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")
}
}
}