477 lines
20 KiB
Swift
477 lines
20 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AgentKit
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindPresentation
|
|
|
|
enum MobileAssistantLibraryRoute: String, Identifiable, Hashable {
|
|
case conversations
|
|
case archived
|
|
|
|
var id: String { rawValue }
|
|
|
|
var title: LocalizedStringResource {
|
|
switch self {
|
|
case .conversations: AppStrings.Assistant.conversations
|
|
case .archived: AppStrings.Assistant.archived
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MobileAssistantView: View {
|
|
let app: MobileAppModel
|
|
let 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<MobileAssistantLibraryRoute?>
|
|
) {
|
|
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: {
|
|
MobileNotificationMenuLabel(
|
|
title: String(localized: AppStrings.Assistant.conversations), symbol: "rectangle.stack",
|
|
hasUnread: app.unreadAssistantNotificationCount > 0, isToolbar: true)
|
|
}
|
|
.labelStyle(.iconOnly)
|
|
.accessibilityValue(conversationTitle)
|
|
.disabled(!hasSelectableConversations)
|
|
}
|
|
.mobileProminentAction()
|
|
} else if libraryRoute == nil && hasSelectableConversations {
|
|
ToolbarTitleMenu { conversationTitleMenu }
|
|
}
|
|
}
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button(
|
|
AppStrings.Assistant.newConversation.swiftUIKey, systemImage: "plus.bubble",
|
|
action: newConversation
|
|
)
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
.mobileProminentAction()
|
|
MobileToolbarOverflow {
|
|
Button(AppStrings.Assistant.conversations.swiftUIKey, systemImage: "clock") {
|
|
showLibrary(.conversations)
|
|
}
|
|
Button(AppStrings.Assistant.archived.swiftUIKey, systemImage: "archivebox") {
|
|
showLibrary(.archived)
|
|
}
|
|
Divider()
|
|
Button(AppStrings.Common.rename.swiftUIKey, systemImage: "pencil") {
|
|
title = model.conversation.derivedTitle
|
|
renaming = model.conversation
|
|
}
|
|
.disabled(model.conversation.isEmpty)
|
|
Button(
|
|
model.conversation.isPinned
|
|
? AppStrings.Assistant.unpin.swiftUIKey : AppStrings.Assistant.pin.swiftUIKey,
|
|
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(AppStrings.Assistant.unarchive.swiftUIKey, systemImage: "arrow.uturn.backward") {
|
|
let conversation = model.conversation
|
|
Task { await model.archive(conversation, archived: false) }
|
|
}
|
|
.disabled(!canChangeHistory)
|
|
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
|
|
deleting = model.conversation
|
|
}
|
|
.disabled(!canChangeHistory)
|
|
} else {
|
|
Button(AppStrings.Assistant.archive.swiftUIKey, 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(
|
|
AppStrings.Common.rename,
|
|
isPresented: Binding(get: { renaming != nil }, set: { if !$0 { renaming = nil } })
|
|
) {
|
|
TextField(
|
|
AppStrings.Assistant.conversationName.swiftUIKey, text: $title.singleLineText, axis: .horizontal
|
|
).singleLinePaste()
|
|
.lineLimit(1)
|
|
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { renaming = nil }
|
|
Button(AppStrings.Common.save.swiftUIKey) {
|
|
if let renaming { Task { await model.rename(renaming, title: title) } }
|
|
renaming = nil
|
|
}
|
|
}
|
|
.operationConfirmation(
|
|
.init(AppStrings.Assistant.deleteConversation),
|
|
isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }),
|
|
placement: .centered,
|
|
actions: [
|
|
.init(AppStrings.Common.delete, role: .destructive) {
|
|
if let deleting { Task { await model.delete(deleting) } }
|
|
deleting = nil
|
|
},
|
|
.init(AppStrings.Common.cancel, role: .cancel) { deleting = nil },
|
|
],
|
|
message: .init(AppStrings.Assistant.deleteStagedConversationWarning)
|
|
)
|
|
.task { await model.load() }
|
|
.alert(
|
|
AppStrings.Common.operationFailed,
|
|
isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })
|
|
) {
|
|
Button(AppStrings.Common.ok.swiftUIKey) { model.error = nil }
|
|
} message: {
|
|
Text(model.error ?? "")
|
|
}
|
|
}
|
|
|
|
private var conversationTitle: String {
|
|
model.conversation.isEmpty
|
|
? String(localized: AppStrings.Navigation.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(AppStrings.Assistant.pinned.swiftUIKey) {
|
|
ForEach(model.library.pinnedConversations) { conversation in titleMenuButton(conversation) }
|
|
}
|
|
}
|
|
if !model.library.activeConversations.isEmpty {
|
|
Section(AppStrings.Assistant.conversations.swiftUIKey) {
|
|
ForEach(model.library.activeConversations) { conversation in titleMenuButton(conversation) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func titleMenuButton(_ conversation: ConversationModel) -> some View {
|
|
Button {
|
|
select(conversation)
|
|
} label: {
|
|
MobileNotificationMenuLabel(
|
|
title: conversation.derivedTitle,
|
|
symbol: model.conversation.id == conversation.id
|
|
? "checkmark" : (conversation.isStreaming ? "ellipsis.circle" : "sparkles"),
|
|
hasUnread: conversation.unreadNotificationCount > 0
|
|
)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
let 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(AppStrings.Assistant.newConversation.swiftUIKey, systemImage: "plus.bubble")
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.foregroundStyle(.primary)
|
|
|
|
Button {
|
|
showArchived()
|
|
} label: {
|
|
HStack {
|
|
Label(AppStrings.Assistant.archived.swiftUIKey, 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(AppStrings.Assistant.pinned.swiftUIKey) {
|
|
ForEach(pinnedConversations) { conversationRow($0) }
|
|
}
|
|
}
|
|
|
|
if archived {
|
|
ForEach(ordinaryConversations) { conversationRow($0) }
|
|
} else if !ordinaryConversations.isEmpty {
|
|
Section(String(localized: AppStrings.Assistant.conversations)) {
|
|
ForEach(ordinaryConversations) { conversationRow($0) }
|
|
}
|
|
}
|
|
|
|
if values.isEmpty {
|
|
ContentUnavailableView(
|
|
archived
|
|
? AppStrings.Assistant.noArchivedChats.swiftUIKey
|
|
: AppStrings.Assistant.noConversations.swiftUIKey,
|
|
systemImage: archived ? "archivebox" : "sparkles"
|
|
)
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.searchable(text: $query.singleLineText, prompt: AppStrings.Common.search.swiftUIKey)
|
|
.alert(
|
|
AppStrings.Common.rename,
|
|
isPresented: Binding(get: { renaming != nil }, set: { if !$0 { renaming = nil } })
|
|
) {
|
|
TextField(AppStrings.Assistant.conversationName.swiftUIKey, text: $title.singleLineText, axis: .horizontal)
|
|
.singleLinePaste().lineLimit(
|
|
1)
|
|
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { renaming = nil }
|
|
Button(AppStrings.Common.save.swiftUIKey) {
|
|
if let renaming { Task { await model.rename(renaming, title: title) } }
|
|
renaming = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
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(AppStrings.Assistant.workingProgress).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
if conversation.unreadNotificationCount > 0 { NotificationUnreadDot() }
|
|
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(AppStrings.Assistant.unarchive.swiftUIKey, systemImage: "arrow.uturn.backward") {
|
|
Task { await model.archive(conversation, archived: false) }
|
|
}
|
|
.tint(.accentColor)
|
|
.disabled(!canChangeHistory(conversation))
|
|
} else {
|
|
Button(AppStrings.Assistant.archive.swiftUIKey, systemImage: "archivebox", role: .destructive) {
|
|
Task { await model.archive(conversation, archived: true) }
|
|
}
|
|
.disabled(!canChangeHistory(conversation))
|
|
}
|
|
}
|
|
.contextMenu { actions(conversation) }
|
|
.accessibilityValue(
|
|
conversation.unreadNotificationCount > 0
|
|
? String(localized: AppStrings.Notifications.unread(conversation.unreadNotificationCount)) : ""
|
|
)
|
|
.accessibilityAddTraits(model.conversation.id == conversation.id ? .isSelected : [])
|
|
.operationConfirmation(
|
|
.init(AppStrings.Assistant.deleteConversation),
|
|
isPresented: $deleting.isPresenting { $0.id == conversation.id },
|
|
actions: [
|
|
.init(AppStrings.Common.delete, role: .destructive) {
|
|
if let deleting { Task { await model.delete(deleting) } }
|
|
deleting = nil
|
|
},
|
|
.init(AppStrings.Common.cancel, role: .cancel) { deleting = nil },
|
|
],
|
|
message: .init(AppStrings.Assistant.deleteStagedConversationWarning)
|
|
)
|
|
}
|
|
|
|
private func select(_ conversation: ConversationModel) {
|
|
model.select(conversation)
|
|
selected()
|
|
}
|
|
|
|
@ViewBuilder private func actions(_ conversation: ConversationModel) -> some View {
|
|
let hasUnread = conversation.unreadNotificationCount > 0
|
|
Button(AppStrings.Common.rename.swiftUIKey, systemImage: "pencil") {
|
|
title = conversation.derivedTitle
|
|
renaming = conversation
|
|
}
|
|
Button(
|
|
hasUnread ? AppStrings.Notifications.markRead : AppStrings.Notifications.markUnread,
|
|
systemImage: hasUnread ? "circle" : "circle.fill"
|
|
) {
|
|
if hasUnread { conversation.markNotificationsRead() } else { conversation.markNotificationsUnread() }
|
|
}
|
|
if !conversation.isArchived {
|
|
Button(
|
|
conversation.isPinned ? AppStrings.Assistant.unpin.swiftUIKey : AppStrings.Assistant.pin.swiftUIKey,
|
|
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(AppStrings.Assistant.unarchive.swiftUIKey, systemImage: "arrow.uturn.backward") {
|
|
Task { await model.archive(conversation, archived: false) }
|
|
}
|
|
.disabled(!canChangeHistory(conversation))
|
|
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
|
|
deleting = conversation
|
|
}
|
|
.disabled(!canChangeHistory(conversation))
|
|
} else {
|
|
Button(AppStrings.Assistant.archive.swiftUIKey, 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
|
|
}
|
|
}
|