Files
Termind/TFI/Features/AI/MobileAssistantWorkRow.swift
T

234 lines
10 KiB
Swift

import AgentKit
import SwiftUI
import TermindApplication
import TermindPresentation
import TermindRemoteInfrastructure
struct MobileAssistantWorkRow: View {
let group: AgentWorkGroup
let presentation: AssistantTranscriptPresentationState
let hostName: (UUID) -> String
@Environment(\.locale) private var locale
@Environment(\.mobileListRowIsDisplayed) private var displayed
@Environment(\.mobileListSceneIsActive) private var sceneIsActive
private var defaultExpanded: Bool {
AgentWorkPresentation.isExpandedByDefault(for: group.run.state, appearsAfterAnswer: group.appearsAfterAnswer)
}
var body: some View {
VStack(spacing: 12) {
MobileAssistantDisclosure(
expanded: presentation.isExpanded(.work(group.id), default: defaultExpanded),
toggle: { presentation.toggleDisclosure(.work(group.id), default: defaultExpanded) }
) {
if !group.run.state.isTerminal && displayed && sceneIsActive {
TimelineView(.periodic(from: .now, by: 1)) { context in workTitle(now: context.date) }
} else {
workTitle(now: group.run.finishedAt ?? .now)
}
} content: {
VStack(alignment: .leading, spacing: AssistantTranscriptStyle.blockSpacing) {
ForEach(group.items) { item in
switch item {
case .reasoning(let block):
MobileAssistantMarkdown(text: block.text, secondary: true)
.modifier(MobileAssistantScanning(active: group.activeReasoningBlockID == block.id))
case .message(let message):
if message.text.isEmpty && message.isStreaming {
MobileAssistantActivity(
reconnectingTitle: group.retryProgress.map {
AgentWorkPresentation.reconnectingTitle($0, locale: locale)
}
)
} else if !message.text.isEmpty {
MobileAssistantMarkdown(text: message.text)
}
case .tool(let card):
MobileAssistantToolRow(card: card, presentation: presentation, hostName: hostName)
case .tools(let batch):
MobileAssistantToolBatchRow(batch: batch, presentation: presentation, hostName: hostName)
}
}
}
}
Divider()
}
}
private func workTitle(now: Date) -> some View {
Text(
group.retryProgress.map {
AgentWorkPresentation.reconnectingTitle($0, locale: locale)
}
?? AgentWorkPresentation.title(
for: group.run.state, from: group.run.startedAt,
to: group.run.finishedAt ?? now, locale: locale
)
)
.font(.callout)
.foregroundStyle(
group.retryProgress == nil
? AnyShapeStyle(.secondary) : AnyShapeStyle(.tertiary)
)
.monospacedDigit()
}
}
struct MobileAssistantToolRow: View {
@ScaledMetric(relativeTo: .callout) private var iconSize: CGFloat = 16
let card: AgentToolCard
let presentation: AssistantTranscriptPresentationState
let hostName: (UUID) -> String
let defaultExpanded: Bool
@Environment(\.locale) private var locale
init(
card: AgentToolCard,
presentation: AssistantTranscriptPresentationState,
hostName: @escaping (UUID) -> String,
defaultExpanded: Bool = false
) {
self.card = card
self.presentation = presentation
self.hostName = hostName
self.defaultExpanded = defaultExpanded
}
var body: some View {
let expanded = presentation.isExpanded(.tool(card.id), default: defaultExpanded)
MobileAssistantDisclosure(expanded: expanded, toggle: toggle) {
HStack(spacing: 8) {
Image(systemName: AgentToolPresentation.symbol(for: card))
.foregroundStyle(AgentToolPresentation.tint(for: card.state).color)
.font(.system(size: iconSize)).frame(width: iconSize)
Text(
AgentToolPresentation.summary(
for: card, hostName: card.invocation.targetHostID.map(hostName), locale: locale
)
)
.foregroundStyle(card.state == .running ? Color.secondary : .primary)
.lineLimit(AgentToolPresentation.usesCompactDetails(for: card.descriptor) ? 1 : (expanded ? nil : 1))
.modifier(MobileAssistantScanning(active: card.state == .running))
if card.state == .reviewing {
Image(systemName: "checkmark.shield").foregroundStyle(.orange)
.accessibilityLabel(Text("Auto-reviewing in progress"))
}
}
.font(.callout).opacity(card.state == .interrupted ? 0.5 : 1)
} content: {
VStack(alignment: .leading, spacing: 8) {
if let review = card.review {
Text("Auto-reviewing: \(review.risk.displayName) — \(review.reason)")
.font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
}
if let detail = AgentToolPresentation.detail(
for: card, presenter: TermindAgentTools.presenter(for: card.descriptor), locale: locale
) {
if AgentToolPresentation.usesCompactDetails(for: card.descriptor) {
AssistantCompactToolDetails(
detail: detail, isError: card.result?.isError == true,
expanded: Binding(
get: { presentation.isExpanded(.toolDetail(card.id)) },
set: { value in
if value != presentation.isExpanded(.toolDetail(card.id)) {
presentation.toggleDisclosure(.toolDetail(card.id))
}
}
)
) { UIPasteboard.general.string = $0 }
} else {
AssistantToolDetails(items: detail.items, isError: card.result?.isError == true) {
UIPasteboard.general.string = $0
}
}
}
if card.result?.isTruncated == true {
Text("Output truncated").font(.caption2).foregroundStyle(.orange)
}
}
.padding(.leading, iconSize + 8)
}
}
private func toggle() { presentation.toggleDisclosure(.tool(card.id), default: defaultExpanded) }
}
private struct MobileAssistantToolBatchRow: View {
@ScaledMetric(relativeTo: .callout) private var iconSize: CGFloat = 16
let batch: AgentToolBatch
let presentation: AssistantTranscriptPresentationState
let hostName: (UUID) -> String
@Environment(\.locale) private var locale
@Environment(\.mobileListRowIsDisplayed) private var displayed
@Environment(\.mobileListSceneIsActive) private var sceneIsActive
@State private var deadline: Date?
@State private var now = Date.now
private var unfinished: [AgentToolCard] { batch.cards.filter { !$0.state.isTerminal } }
private var reasoning: AgentToolReasoning? { unfinished.isEmpty ? nil : batch.reasoning }
private var showsReasoning: Bool { reasoning != nil && deadline.map { $0 > now } == true }
@ViewBuilder
var body: some View {
if batch.cards.count == 1, let card = batch.cards.first {
MobileAssistantToolRow(
card: card,
presentation: presentation,
hostName: hostName
)
} else {
MobileAssistantDisclosure(
expanded: presentation.isExpanded(.toolBatch(batch.id)),
toggle: { presentation.toggleDisclosure(.toolBatch(batch.id)) }
) {
HStack(spacing: 8) {
Image(systemName: AgentToolPresentation.batchSymbol(for: batch.cards))
.foregroundStyle(AgentToolPresentation.batchTint(for: batch.cards).color)
.font(.system(size: iconSize)).frame(width: iconSize)
Group {
if showsReasoning, let reasoning {
Text(TermindMarkdownParser.inlineTitle(reasoning.markdown))
} else {
Text(fallbackTitle)
}
}
.lineLimit(1).foregroundStyle(.secondary)
.modifier(MobileAssistantScanning(active: !unfinished.isEmpty))
}
.font(.callout)
} content: {
VStack(alignment: .leading, spacing: AssistantTranscriptStyle.blockSpacing) {
ForEach(batch.cards) { card in
MobileAssistantToolRow(card: card, presentation: presentation, hostName: hostName)
}
}
}
.task(id: TitleUpdate(revision: reasoning?.revision, displayed: displayed, sceneIsActive: sceneIsActive)) {
guard displayed, sceneIsActive, let reasoning else { return }
now = .now
let expires = presentation.reasoningTitleDeadline(for: batch.id, revision: reasoning.revision)
deadline = expires
guard expires > now else { return }
do {
try await Task.sleep(for: .seconds(expires.timeIntervalSinceNow))
now = .now
} catch {}
}
}
}
private struct TitleUpdate: Equatable {
var revision: String?
var displayed: Bool
var sceneIsActive: Bool
}
private var fallbackTitle: String {
if unfinished.isEmpty { return AgentToolPresentation.batchSummary(for: batch.cards, locale: locale) }
return unfinished.map {
AgentToolPresentation.summary(for: $0, hostName: $0.invocation.targetHostID.map(hostName), locale: locale)
}.joined(separator: "; ")
}
}