import AgentKit import SwiftUI import TermindApplication import TermindPresentation enum MobileAssistantLibraryRoute: String, Identifiable, Hashable { case conversations case archived var id: String { rawValue } var title: LocalizedStringKey { switch self { case .conversations: "Conversations" case .archived: "Archived" } } } struct MobileAssistantView: View { let app: MobileAppModel @Bindable var model: MobileAssistantSceneModel @Binding var libraryRoute: MobileAssistantLibraryRoute? @Environment(\.mobileToolbarLayout) private var toolbarLayout @State private var restoreComposerFocusAfterLibrary = false @State private var nextComposerFocusRequest = 0 @State private var composerFocusRequest: Int? @State private var renaming: ConversationModel? @State private var title = "" @State private var deleting: ConversationModel? @FocusState private var composerFocused: Bool init( app: MobileAppModel, model: MobileAssistantSceneModel, libraryRoute: Binding ) { self.app = app self.model = model _libraryRoute = libraryRoute } var body: some View { detail .navigationTitle(conversationTitle) .navigationBarTitleDisplayMode(.inline) .toolbar(removing: usesConversationIcon ? .title : nil) .toolbar { if usesConversationIcon { ToolbarItem(placement: .topBarLeading) { Menu { conversationTitleMenu } label: { Label("Conversations", systemImage: "rectangle.stack") } .labelStyle(.iconOnly) .accessibilityValue(conversationTitle) .disabled(!hasSelectableConversations) } .mobileProminentAction() } else if libraryRoute == nil && hasSelectableConversations { ToolbarTitleMenu { conversationTitleMenu } } } .toolbar { ToolbarItem(placement: .primaryAction) { Button("New Conversation", systemImage: "plus.bubble", action: newConversation) .labelStyle(.iconOnly) } .mobileProminentAction() MobileToolbarOverflow { Button("Conversations", systemImage: "clock") { showLibrary(.conversations) } Button("Archived", systemImage: "archivebox") { showLibrary(.archived) } Divider() Button("Rename", systemImage: "pencil") { title = model.conversation.derivedTitle renaming = model.conversation } .disabled(model.conversation.isEmpty) Button(model.conversation.isPinned ? "Unpin" : "Pin", systemImage: "pin") { let conversation = model.conversation Task { await model.move(conversation, pinned: !conversation.isPinned) } } .disabled(model.conversation.isEmpty || model.conversation.isArchived) if model.conversation.isArchived { Button("Unarchive", systemImage: "arrow.uturn.backward") { let conversation = model.conversation Task { await model.archive(conversation, archived: false) } } .disabled(!canChangeHistory) Button("Delete", systemImage: "trash", role: .destructive) { deleting = model.conversation } .disabled(!canChangeHistory) } else { Button("Archive", systemImage: "archivebox", role: .destructive) { let conversation = model.conversation Task { await model.archive(conversation, archived: true) } } .disabled(!canChangeHistory) } } } .navigationDestination(item: $libraryRoute) { route in MobileAssistantLibrary( model: model, route: route, selected: { libraryRoute = nil }, showArchived: { libraryRoute = .archived } ) .navigationTitle(route.title) } .onChange(of: libraryRoute) { previous, current in guard previous != nil, current == nil else { return } requestComposerFocusIfNeeded(restoreComposerFocusAfterLibrary) restoreComposerFocusAfterLibrary = false } .alert( "Rename", isPresented: Binding(get: { renaming != nil }, set: { if !$0 { renaming = nil } }) ) { TextField("Conversation Name", text: $title.singleLineText, axis: .horizontal).singleLinePaste() .lineLimit(1) Button("Cancel", role: .cancel) { renaming = nil } Button("Save") { if let renaming { Task { await model.rename(renaming, title: title) } } renaming = nil } } .confirmationDialog( "Delete Conversation", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible ) { Button("Delete", role: .destructive) { if let deleting { Task { await model.delete(deleting) } } deleting = nil } } message: { Text("Permanently delete this conversation and its staged files?") } .task { await model.load() } .alert( "Operation Failed", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } }) ) { Button("OK") { model.error = nil } } message: { Text(model.error ?? "") } } private var conversationTitle: String { model.conversation.isEmpty ? String(localized: "Assistant") : model.conversation.derivedTitle } private var usesConversationIcon: Bool { toolbarLayout.usesIconSwitcher && libraryRoute == nil } private var hasSelectableConversations: Bool { !model.library.pinnedConversations.isEmpty || !model.library.activeConversations.isEmpty } private var detail: some View { MobileAssistantConversation( app: app, model: model, conversation: model.conversation, composerFocus: $composerFocused, composerFocusRequest: composerFocusRequest, composerFocusRequestHandled: composerFocusRequestHandled ) .id(model.conversation.id) } @ViewBuilder private var conversationTitleMenu: some View { if !model.library.pinnedConversations.isEmpty { Section("Pinned") { ForEach(model.library.pinnedConversations) { conversation in titleMenuButton(conversation) } } } if !model.library.activeConversations.isEmpty { Section("Conversations") { ForEach(model.library.activeConversations) { conversation in titleMenuButton(conversation) } } } } private func titleMenuButton(_ conversation: ConversationModel) -> some View { Button { select(conversation) } label: { Label( conversation.derivedTitle, systemImage: model.conversation.id == conversation.id ? "checkmark" : (conversation.isStreaming ? "ellipsis.circle" : "sparkles") ) } } private func newConversation() { libraryRoute = nil replaceConversation(preservingComposerFocus: model.newConversation) } private func select(_ conversation: ConversationModel) { guard model.conversation.id != conversation.id else { return } replaceConversation { model.select(conversation) } } private func showLibrary(_ route: MobileAssistantLibraryRoute) { if libraryRoute == nil { restoreComposerFocusAfterLibrary = composerFocused } libraryRoute = route } private func replaceConversation(preservingComposerFocus action: () -> Void) { let shouldRestoreFocus = composerFocused action() requestComposerFocusIfNeeded(shouldRestoreFocus) } private func requestComposerFocusIfNeeded(_ requested: Bool) { guard requested, canFocusComposer else { return } let conversationID = model.conversation.id Task { @MainActor in await Task.yield() guard model.conversation.id == conversationID, canFocusComposer else { return } nextComposerFocusRequest += 1 composerFocusRequest = nextComposerFocusRequest } } private func composerFocusRequestHandled(_ request: Int) { if composerFocusRequest == request { composerFocusRequest = nil } } private var canFocusComposer: Bool { !model.conversation.isArchived && model.conversation.pendingApproval == nil && model.conversation.pendingUserInteraction == nil } private var canChangeHistory: Bool { !model.conversation.isEmpty && !model.conversation.isStreaming && !model.conversation.isCompacting && !model.conversation.isHistoryChanging } } struct MobileAssistantLibrary: View { @Bindable var model: MobileAssistantSceneModel let route: MobileAssistantLibraryRoute let selected: () -> Void let showArchived: () -> Void @State private var query = "" @State private var renaming: ConversationModel? @State private var title = "" @State private var deleting: ConversationModel? init( model: MobileAssistantSceneModel, route: MobileAssistantLibraryRoute, selected: @escaping () -> Void, showArchived: @escaping () -> Void ) { self.model = model self.route = route self.selected = selected self.showArchived = showArchived } private var archived: Bool { route == .archived } var body: some View { List { if !archived && query.isEmpty { Section { Button { model.newConversation() selected() } label: { Label("New Conversation", systemImage: "plus.bubble") .frame(maxWidth: .infinity, alignment: .leading) } .foregroundStyle(.primary) Button { showArchived() } label: { HStack { Label("Archived", systemImage: "archivebox") Spacer() Text(model.library.archivedConversations.count, format: .number) .foregroundStyle(.secondary) Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) .accessibilityHidden(true) } } .foregroundStyle(.primary) } } if !pinnedConversations.isEmpty { Section("Pinned") { ForEach(pinnedConversations) { conversationRow($0) } } } if archived { ForEach(ordinaryConversations) { conversationRow($0) } } else if !ordinaryConversations.isEmpty { Section(String(localized: "Conversations")) { ForEach(ordinaryConversations) { conversationRow($0) } } } if values.isEmpty { ContentUnavailableView( archived ? "No Archived Chats" : "No Conversations", systemImage: archived ? "archivebox" : "sparkles" ) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } } .navigationBarTitleDisplayMode(.inline) .searchable(text: $query.singleLineText, prompt: "Search") .alert("Rename", isPresented: Binding(get: { renaming != nil }, set: { if !$0 { renaming = nil } })) { TextField("Conversation Name", text: $title.singleLineText, axis: .horizontal).singleLinePaste().lineLimit( 1) Button("Cancel", role: .cancel) { renaming = nil } Button("Save") { if let renaming { Task { await model.rename(renaming, title: title) } } renaming = nil } } .confirmationDialog( "Delete Conversation", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible ) { Button("Delete", role: .destructive) { if let deleting { Task { await model.delete(deleting) } } deleting = nil } } message: { Text("Permanently delete this conversation and its staged files?") } } private var values: [ConversationModel] { model.library.search(query, scope: archived ? .archived : .active) } private var pinnedConversations: [ConversationModel] { values.filter(\.isPinned) } private var ordinaryConversations: [ConversationModel] { values.filter { !$0.isPinned } } private func conversationRow(_ conversation: ConversationModel) -> some View { Button { select(conversation) } label: { HStack(spacing: 12) { Image(systemName: conversation.isArchived ? "archivebox" : "sparkles") .foregroundStyle(.secondary) .frame(width: 28, height: 28) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(conversation.derivedTitle).foregroundStyle(.primary).lineLimit(1) if conversation.isStreaming { Text("Working…").font(.caption).foregroundStyle(.secondary) } } Spacer(minLength: 0) if model.conversation.id == conversation.id { Image(systemName: "checkmark").foregroundStyle(.tint).accessibilityHidden(true) } } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) } .buttonStyle(.plain) .swipeActions(edge: .trailing, allowsFullSwipe: true) { if conversation.isArchived { Button("Unarchive", systemImage: "arrow.uturn.backward") { Task { await model.archive(conversation, archived: false) } } .tint(.accentColor) .disabled(!canChangeHistory(conversation)) } else { Button("Archive", systemImage: "archivebox", role: .destructive) { Task { await model.archive(conversation, archived: true) } } .disabled(!canChangeHistory(conversation)) } } .contextMenu { actions(conversation) } .accessibilityAddTraits(model.conversation.id == conversation.id ? .isSelected : []) } private func select(_ conversation: ConversationModel) { model.select(conversation) selected() } @ViewBuilder private func actions(_ conversation: ConversationModel) -> some View { Button("Rename", systemImage: "pencil") { title = conversation.derivedTitle renaming = conversation } if !conversation.isArchived { Button(conversation.isPinned ? "Unpin" : "Pin", systemImage: conversation.isPinned ? "pin.slash" : "pin") { Task { await model.move(conversation, pinned: !conversation.isPinned) } }.disabled(conversation.isStreaming || conversation.isCompacting || conversation.isHistoryChanging) } if conversation.isArchived { Button("Unarchive", systemImage: "arrow.uturn.backward") { Task { await model.archive(conversation, archived: false) } } .disabled(!canChangeHistory(conversation)) Button("Delete", systemImage: "trash", role: .destructive) { deleting = conversation } .disabled(!canChangeHistory(conversation)) } else { Button("Archive", systemImage: "archivebox", role: .destructive) { Task { await model.archive(conversation, archived: true) } } .disabled(!canChangeHistory(conversation)) } } private func canChangeHistory(_ conversation: ConversationModel) -> Bool { !conversation.isStreaming && !conversation.isCompacting && !conversation.isHistoryChanging } }