921 lines
35 KiB
Swift
921 lines
35 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AgentKit
|
|
import QuickLook
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
import UniformTypeIdentifiers
|
|
|
|
private struct MobileConversationAttachmentDropModifier: ViewModifier {
|
|
let conversation: ConversationModel
|
|
let isEnabled: Bool
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@State private var isTargeted = false
|
|
|
|
@ViewBuilder
|
|
func body(content: Content) -> some View {
|
|
if isEnabled {
|
|
content
|
|
.overlay {
|
|
if isTargeted {
|
|
DropTargetOverlay(
|
|
presentation: DropTargetPresentation(
|
|
title: String(localized: AppStrings.Assistant.attachToChat),
|
|
detail: String(localized: AppStrings.Assistant.filesAndImages),
|
|
systemImage: "paperclip"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
.contentShape(.rect)
|
|
.onDrop(
|
|
of: [.fileURL, .image],
|
|
isTargeted: $isTargeted,
|
|
perform: receive
|
|
)
|
|
.animation(
|
|
reduceMotion ? nil : .spring(duration: 0.28, bounce: 0.16),
|
|
value: isTargeted
|
|
)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
|
|
private func receive(_ providers: [NSItemProvider]) -> Bool {
|
|
let kinds: Set<MobileDropKind> = [.files, .images]
|
|
guard MobileDropPayloadLoader.accepts(providers, kinds: kinds) else { return false }
|
|
Task {
|
|
let payload = await MobileDropPayloadLoader.load(providers, kinds: kinds)
|
|
if !payload.fileURLs.isEmpty {
|
|
conversation.importAttachmentFiles(payload.fileURLs)
|
|
}
|
|
if !payload.images.isEmpty {
|
|
conversation.importAttachmentImages(
|
|
payload.images, label: String(localized: AppStrings.Assistant.droppedImage)
|
|
)
|
|
}
|
|
if !payload.failures.isEmpty {
|
|
conversation.composer.attachmentImportError = [
|
|
conversation.composer.attachmentImportError,
|
|
payload.failures.joined(separator: "\n"),
|
|
]
|
|
.compactMap { $0 }
|
|
.joined(separator: "\n")
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
fileprivate func mobileConversationAttachmentDropTarget(
|
|
conversation: ConversationModel,
|
|
isEnabled: Bool
|
|
) -> some View {
|
|
modifier(
|
|
MobileConversationAttachmentDropModifier(
|
|
conversation: conversation,
|
|
isEnabled: isEnabled
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Wraps message images while keeping every row anchored to the message role's edge.
|
|
///
|
|
/// An adaptive `LazyVGrid` reserves all columns that fit the available width, so a
|
|
/// partially filled user row starts in the first (leading) column. This layout only
|
|
/// reserves the columns used by each row, matching TFM's trailing image flow.
|
|
struct MobileAssistantImageFlowLayout: Layout {
|
|
enum Alignment {
|
|
case leading
|
|
case trailing
|
|
}
|
|
|
|
struct Placement {
|
|
let index: Int
|
|
let origin: CGPoint
|
|
let size: CGSize
|
|
}
|
|
|
|
let alignment: Alignment
|
|
var spacing: CGFloat = 8
|
|
|
|
func sizeThatFits(
|
|
proposal: ProposedViewSize,
|
|
subviews: Subviews,
|
|
cache: inout ()
|
|
) -> CGSize {
|
|
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
|
|
let naturalWidth = sizes.enumerated().reduce(CGFloat.zero) { total, item in
|
|
total + item.element.width + (item.offset == 0 ? 0 : spacing)
|
|
}
|
|
let width = proposal.width.map { max(0, $0) } ?? naturalWidth
|
|
let placements = Self.placements(
|
|
for: sizes,
|
|
in: width,
|
|
spacing: spacing,
|
|
alignment: alignment
|
|
)
|
|
let height = placements.map { $0.origin.y + $0.size.height }.max() ?? 0
|
|
return CGSize(width: width, height: height)
|
|
}
|
|
|
|
func placeSubviews(
|
|
in bounds: CGRect,
|
|
proposal: ProposedViewSize,
|
|
subviews: Subviews,
|
|
cache: inout ()
|
|
) {
|
|
let placements = Self.placements(
|
|
for: subviews.map { $0.sizeThatFits(.unspecified) },
|
|
in: bounds.width,
|
|
spacing: spacing,
|
|
alignment: alignment
|
|
)
|
|
for placement in placements {
|
|
subviews[placement.index].place(
|
|
at: CGPoint(
|
|
x: bounds.minX + placement.origin.x,
|
|
y: bounds.minY + placement.origin.y
|
|
),
|
|
anchor: .topLeading,
|
|
proposal: ProposedViewSize(placement.size)
|
|
)
|
|
}
|
|
}
|
|
|
|
static func placements(
|
|
for itemSizes: [CGSize],
|
|
in availableWidth: CGFloat,
|
|
spacing: CGFloat,
|
|
alignment: Alignment
|
|
) -> [Placement] {
|
|
struct Item {
|
|
let index: Int
|
|
let size: CGSize
|
|
}
|
|
struct Row {
|
|
var items: [Item] = []
|
|
var width: CGFloat = 0
|
|
var height: CGFloat = 0
|
|
}
|
|
|
|
let availableWidth = max(0, availableWidth)
|
|
var rows: [Row] = []
|
|
var current = Row()
|
|
|
|
for (index, itemSize) in itemSizes.enumerated() {
|
|
let size = CGSize(width: min(itemSize.width, availableWidth), height: itemSize.height)
|
|
let nextWidth = current.width + (current.items.isEmpty ? 0 : spacing) + size.width
|
|
if !current.items.isEmpty, nextWidth > availableWidth {
|
|
rows.append(current)
|
|
current = Row()
|
|
}
|
|
current.width += (current.items.isEmpty ? 0 : spacing) + size.width
|
|
current.height = max(current.height, size.height)
|
|
current.items.append(Item(index: index, size: size))
|
|
}
|
|
if !current.items.isEmpty { rows.append(current) }
|
|
|
|
var placements: [Placement] = []
|
|
var y: CGFloat = 0
|
|
for row in rows {
|
|
var x = alignment == .trailing ? max(0, availableWidth - row.width) : 0
|
|
for item in row.items {
|
|
placements.append(
|
|
Placement(index: item.index, origin: CGPoint(x: x, y: y), size: item.size)
|
|
)
|
|
x += item.size.width + spacing
|
|
}
|
|
y += row.height + spacing
|
|
}
|
|
return placements
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct MobileAssistantAgentOriginPresentation: Equatable {
|
|
let label: String
|
|
let canNavigate: Bool
|
|
|
|
init(
|
|
origin: AgentMessageOrigin, source: ConversationModel?,
|
|
locale: Locale = .current
|
|
) {
|
|
let sourceTitle = source?.derivedTitle ?? origin.sourceTitle
|
|
label =
|
|
sourceTitle.map {
|
|
AppLocalization.resolve(AppStrings.Assistant.agentFrom($0), locale: locale)
|
|
} ?? AppLocalization.resolve(AppStrings.Assistant.agent, locale: locale)
|
|
canNavigate = source != nil
|
|
}
|
|
}
|
|
|
|
struct MobileAssistantAgentOriginButton: View {
|
|
let presentation: MobileAssistantAgentOriginPresentation
|
|
let navigate: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: navigate) {
|
|
Label(
|
|
AppStrings.Assistant.agent.swiftUIKey,
|
|
systemImage: "sparkles"
|
|
)
|
|
.labelStyle(.iconOnly)
|
|
.frame(minWidth: 44, minHeight: 44)
|
|
.contentShape(.rect)
|
|
}
|
|
.font(.caption.weight(.medium))
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.secondary)
|
|
.disabled(!presentation.canNavigate)
|
|
.accessibilityLabel(Text(presentation.label))
|
|
.help(presentation.label)
|
|
.accessibilityIdentifier("AssistantMessage.AgentOrigin")
|
|
}
|
|
}
|
|
|
|
struct MobileAssistantUserBubble: View {
|
|
let text: String
|
|
|
|
var body: some View {
|
|
ViewThatFits(in: .horizontal) {
|
|
content.fixedSize(horizontal: true, vertical: false)
|
|
content
|
|
}
|
|
}
|
|
|
|
private var content: some View {
|
|
MobileAssistantMarkdown(text: text)
|
|
.textSelection(.enabled)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
.tint.opacity(0.1),
|
|
in: RoundedRectangle(
|
|
cornerRadius: AssistantTranscriptStyle.bubbleRadius,
|
|
style: .continuous
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct MobileAssistantEditMessageTextEditor: View {
|
|
@Binding var text: String
|
|
|
|
var body: some View {
|
|
TextEditor(text: $text)
|
|
.scrollContentBackground(.hidden)
|
|
.padding(12)
|
|
.background(
|
|
Color(uiColor: .secondarySystemBackground),
|
|
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.stroke(.primary.opacity(0.1), lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MobileAssistantSteeringQueue: View {
|
|
let conversation: ConversationModel
|
|
|
|
@State private var rowsHeight: CGFloat = 0
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Label(AppStrings.Assistant.steering.swiftUIKey, systemImage: "arrow.turn.down.right")
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.secondary)
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(conversation.pendingSteering) { pending in
|
|
row(pending)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.onGeometryChange(for: CGFloat.self) {
|
|
$0.size.height
|
|
} action: {
|
|
rowsHeight = $0
|
|
}
|
|
}
|
|
.frame(height: min(rowsHeight, 88))
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.mobileAssistantInteractionSurface()
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel(Text(AppStrings.Assistant.queuedMessages))
|
|
}
|
|
|
|
private func row(_ pending: PendingSteeringMessage) -> some View {
|
|
Button {
|
|
conversation.returnSteeringToDraft(pending.id)
|
|
} label: {
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Text(pending.draft)
|
|
.font(.callout)
|
|
.lineLimit(2)
|
|
.truncationMode(.tail)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
if !pending.attachments.isEmpty {
|
|
Label("\(pending.attachments.count)", systemImage: "paperclip")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.labelStyle(.titleAndIcon)
|
|
}
|
|
|
|
Image(systemName: "xmark")
|
|
.font(.caption.weight(.bold))
|
|
.frame(width: 28, height: 28)
|
|
// Match the always-visible Permission control on iOS.
|
|
.foregroundStyle(Color.primary.opacity(0.75))
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(.rect)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text(AppStrings.Assistant.unqueueMessage))
|
|
.accessibilityHint(Text(AppStrings.Assistant.takeThisBackAndEditIt))
|
|
}
|
|
}
|
|
|
|
/// Native list rows have independent hosting roots, so navigation must be injected here.
|
|
struct MobileAssistantTranscriptRowRoot<Content: View>: View {
|
|
let navigate: @MainActor (AgentToolDetail.NavigationReference) -> Void
|
|
@ViewBuilder var content: () -> Content
|
|
|
|
var body: some View {
|
|
content()
|
|
.environment(\.assistantToolNavigation, navigate)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.frame(maxWidth: 840)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
struct MobileAssistantConversation: View {
|
|
let app: MobileAppModel
|
|
let model: MobileAssistantSceneModel
|
|
let conversation: ConversationModel
|
|
let composerFocus: FocusState<Bool>.Binding
|
|
let composerFocusRequest: Int?
|
|
let composerFocusRequestHandled: (Int) -> Void
|
|
@State private var editing: ChatMessage?
|
|
@State private var editedText = ""
|
|
@State private var presentation = AssistantTranscriptPresentationState()
|
|
@State private var interactionHeight: CGFloat = 0
|
|
@Environment(\.locale) private var locale
|
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
@State private var preview: URL?
|
|
@State private var previewDirectory = FileManager.default.temporaryDirectory.appending(
|
|
path: "Assistant-Previews/\(UUID().uuidString)")
|
|
|
|
private func navigateToToolReference(_ reference: AgentToolDetail.NavigationReference) {
|
|
guard case .chat(let id) = reference,
|
|
let target = model.library.conversation(id: id)
|
|
else { return }
|
|
model.select(target)
|
|
}
|
|
|
|
var body: some View {
|
|
let entries = transcriptEntries
|
|
let validPresentationIDs = entries.reduce(into: Set<UUID>()) { $0.formUnion($1.presentationIDs) }
|
|
let revisions = Dictionary(
|
|
uniqueKeysWithValues: entries.map {
|
|
($0.id, presentation.layoutRevision(for: $0.presentationIDs))
|
|
})
|
|
MobileRegionReader { regions in
|
|
let panes = regions.assistant(interactionHeight: interactionHeight)
|
|
MobilePaneLayout(frames: [panes.first, panes.second]) {
|
|
MobileNativeList(
|
|
items: entries,
|
|
estimatedRowHeight: 132,
|
|
refreshID: transcriptRefreshID,
|
|
followsBottom: true,
|
|
surface: .plain,
|
|
rowRevision: { revisions[$0.id] ?? 0 },
|
|
interactionRevision: {
|
|
Self.transcriptInteractionRevision(for: $0, isStreaming: conversation.isStreaming)
|
|
},
|
|
prepareUpdate: { old, new, visible in
|
|
presentation.prepareWorkCompletionTransitions(from: old, to: new, visibleIDs: visible)
|
|
},
|
|
onViewportChange: { presentation.collapseWorkOutsideViewport(visibleIDs: $0) },
|
|
onTap: { composerFocus.wrappedValue = false }
|
|
) { entry in
|
|
MobileAssistantTranscriptRowRoot(navigate: navigateToToolReference) {
|
|
transcriptRow(entry)
|
|
}
|
|
}
|
|
.opacity(MobileContentRegions.isUsable(panes.first) ? 1 : 0)
|
|
.allowsHitTesting(MobileContentRegions.isUsable(panes.first))
|
|
.accessibilityHidden(!MobileContentRegions.isUsable(panes.first))
|
|
.clipped()
|
|
ScrollView {
|
|
bottomSurface
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.onGeometryChange(for: CGFloat.self) {
|
|
$0.size.height
|
|
} action: {
|
|
interactionHeight = $0
|
|
}
|
|
}
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
.defaultScrollAnchor(.bottom, for: .alignment)
|
|
.opacity(MobileContentRegions.isUsable(panes.second) ? 1 : 0)
|
|
.allowsHitTesting(MobileContentRegions.isUsable(panes.second))
|
|
.accessibilityHidden(!MobileContentRegions.isUsable(panes.second))
|
|
.clipped()
|
|
}
|
|
}
|
|
.environment(\.assistantToolNavigation, navigateToToolReference)
|
|
.background(Color(uiColor: .systemBackground))
|
|
.mobileConversationAttachmentDropTarget(
|
|
conversation: conversation,
|
|
isEnabled: acceptsAttachmentDrops
|
|
)
|
|
.sheet(item: $editing) { message in
|
|
NavigationStack {
|
|
MobileAssistantEditMessageTextEditor(text: $editedText)
|
|
.padding()
|
|
.navigationTitle(AppStrings.Assistant.editMessage.swiftUIKey)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(AppStrings.Common.cancel.swiftUIKey, systemImage: "xmark") { editing = nil }
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(AppStrings.Assistant.send.swiftUIKey, systemImage: "arrow.up") {
|
|
model.edit(conversation, messageID: message.id, text: editedText)
|
|
editing = nil
|
|
}.labelStyle(.iconOnly).disabled(
|
|
editedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
}
|
|
}.mobileSheetPresentation()
|
|
}
|
|
.quickLookPreview($preview)
|
|
.onDisappear {
|
|
try? FileManager.default.removeItem(at: previewDirectory)
|
|
}
|
|
.onChange(of: validPresentationIDs) { _, valid in
|
|
presentation.prune(validTimelineIDs: valid)
|
|
}
|
|
}
|
|
|
|
private var transcriptRefreshID: String {
|
|
Self.transcriptRefreshID(for: conversation)
|
|
}
|
|
|
|
static func transcriptRefreshID(for conversation: ConversationModel) -> String {
|
|
"\(conversation.id.uuidString):\(conversation.isArchived)"
|
|
}
|
|
|
|
static func transcriptInteractionRevision(for entry: AssistantTranscriptEntry, isStreaming: Bool) -> UInt64 {
|
|
guard case .plan = entry.content else { return 0 }
|
|
return isStreaming ? 1 : 0
|
|
}
|
|
|
|
private var acceptsAttachmentDrops: Bool {
|
|
!conversation.isArchived
|
|
&& conversation.pendingApproval == nil
|
|
&& conversation.pendingUserInteraction == nil
|
|
}
|
|
|
|
private var transcriptEntries: [AssistantTranscriptEntry] {
|
|
let availability = conversation.messageActionAvailability
|
|
return AssistantTranscriptEntry.items(
|
|
timeline: conversation.timeline,
|
|
isInterrupted: conversation.runState == .interrupted,
|
|
canEdit: availability.editable.contains,
|
|
canFork: availability.forkable.contains
|
|
)
|
|
}
|
|
|
|
@ViewBuilder private var bottomSurface: some View {
|
|
bottomSurfaceContent
|
|
.padding(12)
|
|
.frame(maxWidth: 840)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color(uiColor: .systemBackground))
|
|
}
|
|
|
|
@ViewBuilder private var bottomSurfaceContent: some View {
|
|
if conversation.isArchived {
|
|
archivedNotice
|
|
.padding(12)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.stroke(.primary.opacity(0.08), lineWidth: 1)
|
|
}
|
|
} else if conversation.pendingApproval != nil || conversation.pendingUserInteraction != nil {
|
|
MobileAssistantInteraction(conversation: conversation, hosts: app.library, speech: model.speech)
|
|
} else {
|
|
regularInteractionSurface
|
|
}
|
|
}
|
|
|
|
private var regularInteractionSurface: some View {
|
|
VStack(spacing: 8) {
|
|
if !conversation.pendingSteering.isEmpty {
|
|
MobileAssistantSteeringQueue(conversation: conversation)
|
|
}
|
|
MobileAssistantComposer(
|
|
model: model,
|
|
conversation: conversation,
|
|
focus: composerFocus,
|
|
focusRequest: composerFocusRequest,
|
|
focusRequestHandled: composerFocusRequestHandled
|
|
)
|
|
}
|
|
}
|
|
|
|
private var archivedNotice: some View {
|
|
ViewThatFits(in: .horizontal) {
|
|
archivedNoticeContent
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
archivedNoticeLabel
|
|
unarchiveButton.frame(maxWidth: .infinity, alignment: .trailing)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var archivedNoticeContent: some View {
|
|
HStack(spacing: 12) {
|
|
archivedNoticeLabel
|
|
Spacer(minLength: 12)
|
|
unarchiveButton
|
|
}
|
|
}
|
|
|
|
private var archivedNoticeLabel: some View {
|
|
Label(AppStrings.Assistant.archivedConversationsAreReadOnly.swiftUIKey, systemImage: "archivebox")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
private var unarchiveButton: some View {
|
|
Button(AppStrings.Assistant.unarchive.swiftUIKey, systemImage: "arrow.uturn.backward") {
|
|
Task { await model.archive(conversation, archived: false) }
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.fixedSize()
|
|
}
|
|
|
|
@ViewBuilder private func transcriptRow(_ entry: AssistantTranscriptEntry) -> some View {
|
|
switch entry.content {
|
|
case .empty:
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(AppStrings.Assistant.startAConversation).font(.headline)
|
|
Text(AppStrings.Assistant.askAQuestionOrDescribeWhatYouWantToDo).foregroundStyle(.secondary)
|
|
}.frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 24)
|
|
case .user(let message, let canEdit):
|
|
messageRow(message, canEdit: canEdit, canFork: false)
|
|
case .assistant(let message, let canFork):
|
|
messageRow(message, canEdit: false, canFork: canFork)
|
|
case .tool(let card):
|
|
toolRow(card)
|
|
case .work(let group):
|
|
timelineRow(.work(group))
|
|
case .command(let command):
|
|
timelineRow(.command(command))
|
|
case .plan(let plan):
|
|
timelineRow(.plan(plan))
|
|
case .compaction(let mark):
|
|
timelineRow(.compaction(mark))
|
|
case .compactionActivity(let activity):
|
|
timelineRow(.compactionActivity(activity))
|
|
case .failure(let failure):
|
|
agentFailureRow(failure)
|
|
case .interrupted:
|
|
Label(AppStrings.Assistant.thisRunWasInterruptedAndWasNotResumed.swiftUIKey, systemImage: "pause.circle")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func timelineRow(_ item: TimelineItem) -> some View {
|
|
switch item {
|
|
case .message(let message):
|
|
messageRow(
|
|
message, canEdit: conversation.canEditUserMessage(message.id),
|
|
canFork: conversation.canForkAssistantMessage(message.id))
|
|
case .tool(let card): toolRow(card)
|
|
case .plan(let card):
|
|
MobileAssistantPlanRow(
|
|
card: card, presentation: presentation, readOnly: conversation.isArchived,
|
|
isStreaming: conversation.isStreaming,
|
|
implement: { model.implement(card, in: conversation) },
|
|
discard: { conversation.resolvePlan(card.id, as: .discarded) }
|
|
)
|
|
case .work(let group):
|
|
MobileAssistantWorkRow(group: group, presentation: presentation, hostName: hostName)
|
|
case .command(let command): MobileAssistantCommandRow(command: command)
|
|
case .compactionActivity(let activity): compactionActivityRow(activity)
|
|
case .compaction(let mark):
|
|
compactionRow(mark)
|
|
case .failure(let failure):
|
|
agentFailureRow(failure)
|
|
}
|
|
}
|
|
|
|
private func compactionRow(_ mark: AgentCompactionMark) -> some View {
|
|
HStack(spacing: 8) {
|
|
compactionRule
|
|
Label(
|
|
AppLocalization.resolve(
|
|
AppStrings.Assistant.compactionSummary(
|
|
summarized: ContextBreakdownView.compact(mark.summarizedTokens),
|
|
retained: ContextBreakdownView.compact(mark.keptTokens)),
|
|
locale: locale
|
|
),
|
|
systemImage: "doc.text"
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
compactionRule
|
|
}
|
|
.accessibilityHint(Text(AppStrings.Assistant.compactionExplanation))
|
|
}
|
|
|
|
private var compactionRule: some View {
|
|
Color(uiColor: .separator)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 1)
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
@ViewBuilder private func compactionActivityRow(_ activity: AgentCompactionActivity) -> some View {
|
|
switch activity.state {
|
|
case .running:
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "doc.text.magnifyingglass")
|
|
Text(AppStrings.Assistant.compactingContext).modifier(MobileAssistantScanning(active: true))
|
|
Spacer()
|
|
}
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
case .failed(let reason):
|
|
DisclosureGroup(isExpanded: disclosureBinding(for: .compactionActivity(activity.id))) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Rectangle()
|
|
.fill(Color(uiColor: .separator))
|
|
.frame(width: 1)
|
|
.accessibilityHidden(true)
|
|
Text(reason)
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(.red)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(.leading, 24)
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.foregroundStyle(.red)
|
|
Text(AppStrings.Assistant.compactionFailed)
|
|
.foregroundStyle(.primary)
|
|
}
|
|
.font(.callout)
|
|
}
|
|
.tint(.secondary)
|
|
.accessibilityHint(Text(AppStrings.Assistant.showFailureDetails))
|
|
}
|
|
}
|
|
|
|
private func agentFailureRow(_ failure: AgentRunFailureNotice) -> some View {
|
|
DisclosureGroup(isExpanded: disclosureBinding(for: .failure(failure.id))) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Rectangle()
|
|
.fill(Color(uiColor: .separator))
|
|
.frame(width: 1)
|
|
.accessibilityHidden(true)
|
|
Text(failure.reason)
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(.red)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(.leading, 24)
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.foregroundStyle(.red)
|
|
Text(verbatim: AssistantFailurePresentation.summary(for: failure.reason))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(2)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.font(.callout)
|
|
}
|
|
.tint(.secondary)
|
|
.accessibilityHint(Text(AppStrings.Assistant.showFailureDetails))
|
|
}
|
|
|
|
private var imageTileSize: CGFloat { sizeClass == .compact ? 104 : 160 }
|
|
|
|
private func messageRow(_ message: ChatMessage, canEdit: Bool, canFork: Bool) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if !message.images.isEmpty {
|
|
MobileAssistantImageFlowLayout(
|
|
alignment: message.role == .user ? .trailing : .leading
|
|
) {
|
|
ForEach(message.images) { image in
|
|
Button {
|
|
open(data: image.data, name: image.label)
|
|
} label: {
|
|
if let uiImage = UIImage(data: image.data) {
|
|
Image(uiImage: uiImage).resizable().scaledToFill()
|
|
.frame(width: imageTileSize, height: imageTileSize)
|
|
.clipShape(.rect(cornerRadius: 12))
|
|
.accessibilityLabel(image.label)
|
|
} else {
|
|
Label(image.label, systemImage: "photo")
|
|
}
|
|
}.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
alignment: message.role == .user ? .trailing : .leading
|
|
)
|
|
}
|
|
ForEach(message.contextChips) { chip in
|
|
HStack {
|
|
if message.role == .user { Spacer(minLength: 0) }
|
|
Button {
|
|
if let attachment = chip.attachment {
|
|
open(data: attachment.data, name: attachment.suggestedFilename)
|
|
} else if let image = message.images.first(where: { $0.id == chip.id || $0.label == chip.label }
|
|
) {
|
|
open(data: image.data, name: image.label)
|
|
}
|
|
} label: {
|
|
Label(chip.label, systemImage: chip.kind.symbol)
|
|
.font(.caption).padding(.horizontal, 8).padding(.vertical, 6)
|
|
.background(.quaternary, in: .rect(cornerRadius: 8))
|
|
}
|
|
.buttonStyle(.plain).foregroundStyle(.secondary)
|
|
.disabled(
|
|
chip.attachment == nil
|
|
&& !message.images.contains { $0.id == chip.id || $0.label == chip.label })
|
|
if message.role != .user { Spacer(minLength: 0) }
|
|
}
|
|
}
|
|
if !message.text.isEmpty {
|
|
if message.role == .user {
|
|
HStack {
|
|
Spacer(minLength: 44)
|
|
MobileAssistantUserBubble(text: message.text)
|
|
}
|
|
} else {
|
|
MobileAssistantMarkdown(text: message.text).textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
if message.isStreaming && message.text.isEmpty && message.reasoning.isEmpty {
|
|
MobileAssistantActivity(
|
|
cyclesText: message.retryProgress == nil,
|
|
reconnectingTitle: message.retryProgress.map {
|
|
AgentWorkPresentation.reconnectingTitle($0, locale: locale)
|
|
}
|
|
)
|
|
}
|
|
if !message.isStreaming { messageActions(message, canEdit: canEdit, canFork: canFork) }
|
|
ForEach(message.commands) { command in MobileAssistantCommandRow(command: command) }
|
|
}
|
|
.contextMenu {
|
|
Button(AppStrings.Common.copy.swiftUIKey, systemImage: "doc.on.doc") {
|
|
UIPasteboard.general.string = message.text
|
|
}
|
|
ShareLink(item: message.text)
|
|
if message.origin == nil {
|
|
if canEdit {
|
|
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") {
|
|
editedText = message.text
|
|
editing = message
|
|
}
|
|
}
|
|
if canFork {
|
|
Button(AppStrings.Assistant.forkConversation.swiftUIKey, systemImage: "arrow.triangle.branch") {
|
|
Task { await model.fork(conversation, at: message.id) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func messageActions(_ message: ChatMessage, canEdit: Bool, canFork: Bool) -> some View {
|
|
HStack(spacing: 4) {
|
|
if message.role == .user {
|
|
Spacer(minLength: 0)
|
|
timestamp(message)
|
|
}
|
|
|
|
if canEdit {
|
|
messageAction(AppStrings.Common.edit, systemImage: "pencil") {
|
|
editedText = message.text
|
|
editing = message
|
|
}
|
|
}
|
|
|
|
AssistantCopyButton(text: message.text, minimumLabelSize: 44) {
|
|
UIPasteboard.general.string = $0
|
|
}
|
|
.font(.caption)
|
|
.labelStyle(.iconOnly)
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if let origin = message.origin {
|
|
let presentation = MobileAssistantAgentOriginPresentation(
|
|
origin: origin,
|
|
source: model.library.conversation(id: origin.sourceConversationID),
|
|
locale: locale)
|
|
MobileAssistantAgentOriginButton(presentation: presentation) {
|
|
guard let target = model.library.conversation(id: origin.sourceConversationID) else { return }
|
|
model.select(target)
|
|
}
|
|
}
|
|
|
|
if canFork {
|
|
messageAction(AppStrings.Assistant.fork, systemImage: "arrow.triangle.branch") {
|
|
Task { await model.fork(conversation, at: message.id) }
|
|
}
|
|
}
|
|
|
|
if message.role != .user {
|
|
timestamp(message)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 44)
|
|
}
|
|
|
|
private func messageAction(
|
|
_ title: LocalizedStringResource,
|
|
systemImage: String,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
Label(title, systemImage: systemImage)
|
|
.font(.caption)
|
|
.labelStyle(.iconOnly)
|
|
.frame(minWidth: 44, minHeight: 44)
|
|
.contentShape(.rect)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
private func timestamp(_ message: ChatMessage) -> some View {
|
|
Text(ChatMessageTimestampFormatter.string(for: message.createdAt, locale: locale))
|
|
.font(.caption.monospacedDigit()).foregroundStyle(.tertiary)
|
|
}
|
|
|
|
private func hostName(_ id: UUID) -> String {
|
|
app.library.host(id)?.name ?? String(id.uuidString.prefix(8))
|
|
}
|
|
|
|
private func toolRow(_ card: AgentToolCard) -> some View {
|
|
MobileAssistantToolRow(card: card, presentation: presentation, hostName: hostName)
|
|
}
|
|
|
|
private func disclosureBinding(for id: AssistantTranscriptDisclosureID) -> Binding<Bool> {
|
|
Binding(
|
|
get: { presentation.isExpanded(id) },
|
|
set: { expanded in
|
|
if expanded != presentation.isExpanded(id) { presentation.toggleDisclosure(id) }
|
|
}
|
|
)
|
|
}
|
|
|
|
private func open(data: Data, name: String) {
|
|
do {
|
|
let folder = previewDirectory.appending(path: UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
let filename = (name as NSString).lastPathComponent
|
|
let url = folder.appending(
|
|
path: filename.isEmpty || filename == "." || filename == ".." ? "Attachment" : filename)
|
|
try data.write(to: url, options: .atomic)
|
|
preview = url
|
|
} catch { model.error = error.localizedDescription }
|
|
}
|
|
}
|