Files
Termind/TFI/Features/MobileAssistantView.swift
T
2026-09-11 21:07:44 +08:00

182 lines
8.1 KiB
Swift

import AgentKit
import SwiftUI
import TermindApplication
import TermindDomain
struct MobileAssistantView: View {
let app: MobileAppModel
@Bindable var model: MobileAssistantModel
@State private var history = false
var body: some View {
GeometryReader { geometry in
if UIDevice.current.userInterfaceIdiom == .pad && geometry.size.width >= 760 {
HStack(spacing: 0) {
MobileAssistantLibrary(model: model, selected: {})
.frame(width: 280)
Divider()
detail
}
} else {
detail
}
}
.navigationTitle("Assistant")
.toolbar {
Button("Conversations", systemImage: "clock") { history = true }.labelStyle(.iconOnly)
Button("New Conversation", systemImage: "square.and.pencil") { model.newConversation() }.labelStyle(
.iconOnly)
}
.navigationDestination(isPresented: $history) {
MobileAssistantLibrary(model: model) { history = false }
.navigationTitle("Conversations")
}
.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 detail: some View {
MobileAssistantConversation(app: app, model: model, conversation: model.conversation)
.id(model.conversation.id)
}
}
struct MobileAssistantLibrary: View {
@Bindable var model: MobileAssistantModel
var archived = false
let selected: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var query = ""
@State private var renaming: ConversationModel?
@State private var title = ""
@State private var deleting: ConversationModel?
@State private var editMode = EditMode.inactive
var body: some View {
List {
if !archived {
Section {
Button("New Conversation", systemImage: "square.and.pencil") {
model.newConversation()
selected()
}
NavigationLink {
MobileAssistantLibrary(model: model, archived: true, selected: selected).navigationTitle(
"Archived")
} label: {
Label("Archived", systemImage: "archivebox")
Spacer()
Text(model.library.archivedConversations.count, format: .number).foregroundStyle(.secondary)
}
}
Section("Pinned") { rows(values.filter(\.isPinned)) }
Section("Conversations") { rows(values.filter { !$0.isPinned }) }
} else {
rows(values)
}
if values.isEmpty { Text(archived ? "No Archived Chats" : "No Conversations").foregroundStyle(.secondary) }
}
.searchable(text: $query, prompt: "Search")
.environment(\.editMode, $editMode)
.toolbar {
Button(editMode.isEditing ? "Done" : "Edit", systemImage: editMode.isEditing ? "checkmark" : "pencil") {
withAnimation { editMode = editMode.isEditing ? .inactive : .active }
}.labelStyle(.iconOnly)
}
.alert("Rename", isPresented: Binding(get: { renaming != nil }, set: { if !$0 { renaming = nil } })) {
TextField("Conversation Name", text: $title)
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) }
@ViewBuilder private func rows(_ values: [ConversationModel]) -> some View {
ForEach(values) { conversation in
MobileActionCard {
model.select(conversation)
if archived { dismiss() }
selected()
} content: {
HStack(alignment: .top, spacing: 12) {
MobileCardIcon(symbol: conversation.isArchived ? "archivebox" : "bubble.left")
VStack(alignment: .leading, spacing: 6) {
Text(conversation.derivedTitle).font(.headline).foregroundStyle(.primary)
if conversation.isStreaming { Text("Working…").font(.caption).foregroundStyle(.secondary) }
}
if model.conversation.id == conversation.id {
Image(systemName: "checkmark").accessibilityHidden(true)
}
}
} actions: {
actions(conversation)
}
.accessibilityAddTraits(model.conversation.id == conversation.id ? .isSelected : [])
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
.swipeActions(edge: .trailing) {
Button(
conversation.isArchived ? "Unarchive" : "Archive",
systemImage: conversation.isArchived ? "tray.and.arrow.up" : "archivebox"
) {
Task { await model.archive(conversation, archived: !conversation.isArchived) }
}.disabled(conversation.isStreaming || conversation.isCompacting || conversation.isHistoryChanging)
Button("Delete", systemImage: "trash", role: .destructive) { deleting = conversation }
.disabled(conversation.isStreaming || conversation.isCompacting || conversation.isHistoryChanging)
}
}
.onMove { indices, destination in
guard query.isEmpty, !archived, let index = indices.first else { return }
let value = values[index]
var remaining = values
remaining.remove(at: index)
let target = min(max(0, destination - (destination > index ? 1 : 0)), remaining.count)
let next = target < remaining.count ? remaining[target].id : nil
Task { await model.move(value, pinned: value.isPinned, before: next) }
}
.moveDisabled(archived || !query.isEmpty)
}
@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)
}
Button(
conversation.isArchived ? "Unarchive" : "Archive",
systemImage: conversation.isArchived ? "tray.and.arrow.up" : "archivebox"
) {
Task { await model.archive(conversation, archived: !conversation.isArchived) }
}.disabled(conversation.isStreaming || conversation.isCompacting || conversation.isHistoryChanging)
Button("Delete", systemImage: "trash", role: .destructive) { deleting = conversation }
.disabled(conversation.isStreaming || conversation.isCompacting || conversation.isHistoryChanging)
}
}