Files
Termind/TFM/Features/AI/ConversationTranscriptView.swift
T
Kaede Akino d2cce7d2ce feat: make core features free and add host-based Pro limits
Make AI, MCP, Metrics, and Wake-on-LAN free while retaining iCloud sync as a Pro feature. Limit free accounts to three saved hosts and preserve excess hosts with remote-operation locks.

Gate host creation, import, and runtime access; preserve stable host admission across persistence and sync. Add upgrade states on macOS and iOS, open the paywall from locked Wake actions, and use wrench icons for MCP.

Include regression coverage, opt-in UI review fixtures, localized copy, and the updated pricing website revision.
2026-10-17 06:13:38 +08:00

648 lines
24 KiB
Swift

import AgentKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import struct TermindDomain.Host
private struct ConversationAttachmentDropModifier: ViewModifier {
let conversation: ConversationModel
let isEnabled: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var targetedKind: NativeDropKind?
private var acceptsAttachments: Bool {
isEnabled
&& !conversation.isArchived
&& conversation.pendingUserInteraction == nil
&& conversation.pendingApproval == nil
}
func body(content: Content) -> some View {
content
.overlay { decoration }
.overlay {
NativeDropTarget(
targetedKind: $targetedKind,
acceptedKinds: [.files, .images],
isEnabled: acceptsAttachments,
receive: receive
)
}
.animation(
Motion.dropTarget(reduceMotion: reduceMotion),
value: targetedKind
)
}
@ViewBuilder
private var decoration: some View {
if targetedKind != nil {
DropTargetOverlay(
presentation: DropTargetPresentation(
title: String(localized: AppStrings.Assistant.attachToChat),
detail: String(localized: AppStrings.Assistant.filesAndImages),
systemImage: targetedKind == .images
? "photo.badge.plus" : "paperclip"
))
}
}
private func receive(_ payload: NativeDropPayload) -> Bool {
var accepted = false
if !payload.fileURLs.isEmpty {
conversation.importAttachmentFiles(payload.fileURLs)
accepted = true
}
if !payload.images.isEmpty {
conversation.importAttachmentImages(
payload.images, label: String(localized: AppStrings.Assistant.droppedImage)
)
accepted = true
}
return accepted
}
}
extension View {
func conversationAttachmentDropTarget(
conversation: ConversationModel,
isEnabled: Bool = true
) -> some View {
modifier(
ConversationAttachmentDropModifier(
conversation: conversation,
isEnabled: isEnabled
)
)
}
}
/// A conversation rendered as chips + transcript + composer.
///
/// Shared by the session Assistant sidebar and full Assistant destination so the two can never drift; only
/// the padding and the provider header differ.
struct ConversationView: View {
enum Region: Equatable {
case all
case transcript
case interaction
}
let conversation: ConversationModel
var isCompact: Bool = false
var region: Region = .all
/// Forwarded to the composer. Only the sidebar has a focused pane to
/// describe, so only the sidebar passes one.
var sessionContext: SessionContext = .none
/// A conversation can be shown on both surfaces. Terminal actions belong
/// to the Workspace sidebar displaying it, not to the conversation itself.
var showsTerminalActions = false
/// Surface-owned navigation. `nil` means this composer already is the new
/// chat surface, so offering New chat there would be a circular action.
var onNewChat: (() -> Void)?
/// Used by a scratch New Chat surface that must be promoted into history
/// before the app starts its first run.
var onSend: (() -> Void)?
@Environment(WindowModel.self) private var window
var body: some View {
Group {
switch region {
case .all:
VStack(spacing: 0) {
transcript
interactionSurface
}
case .transcript:
transcript
case .interaction:
interactionSurface
}
}
.conversationAttachmentDropTarget(
conversation: conversation,
isEnabled: region == .all
)
}
@ViewBuilder
private var interactionSurface: some View {
if conversation.isArchived {
archivedSurface
} else if let interaction = conversation.pendingUserInteraction {
blockingSurface { userInteraction(interaction) }
} else if let approval = conversation.pendingApproval {
blockingSurface {
AgentApprovalCard(request: approval, conversation: conversation)
}
} else {
if !conversation.pendingSteering.isEmpty {
chatColumn {
SteeringQueueView(
conversation: conversation,
isCompact: isCompact,
isEmbedded: true
)
}
}
ComposerView(
conversation: conversation,
maxContentWidth: isCompact ? nil : Metrics.Chat.contentMaxWidth,
sessionContext: sessionContext,
terminalSuggestionsAvailable: showsTerminalActions,
onNewChat: onNewChat,
onSend: onSend
)
.zIndex(100)
}
}
@ViewBuilder
private var archivedSurface: some View {
if isCompact {
archivedSurfaceContent
.composerBlock()
.padding(.horizontal, Metrics.Space.l)
.padding(.top, Metrics.Space.s)
.padding(.bottom, Metrics.Space.s)
} else {
archivedSurfaceContent
.padding(Metrics.Space.l)
.termindGlassBar()
}
}
private var archivedSurfaceContent: some View {
HStack(spacing: Metrics.Space.m) {
Label(AppStrings.Assistant.archivedConversationsAreReadOnly.swiftUIKey, systemImage: "archivebox")
.font(.callout)
.foregroundStyle(.secondary)
Spacer()
Button(AppStrings.Assistant.unarchive.swiftUIKey) {
window.setAssistantArchived(conversation.id, archived: false)
}
.transcriptProminentButtonStyle()
}
}
private func chatColumn<Content: View>(
@ViewBuilder content: () -> Content
) -> some View {
content()
.padding(.horizontal, Metrics.Space.l)
.frame(maxWidth: isCompact ? .infinity : Metrics.Chat.contentMaxWidth)
.frame(maxWidth: .infinity)
}
@ViewBuilder
private func userInteraction(_ interaction: ConversationModel.PendingUserInteraction)
-> some View
{
switch interaction {
case .mcpAuthorization(let prompt):
MCPAuthorizationCard(prompt: prompt, conversation: conversation).id(prompt.id)
case .questions(let input):
AgentUserInputCard(
request: input, conversation: conversation, isCompact: isCompact
)
case .sshAuthentication(let authentication):
AgentSSHAuthenticationCard(
request: authentication, conversation: conversation
)
case .sshHostKey(let request):
AgentSSHHostKeyCard(request: request, conversation: conversation)
}
}
private func blockingSurface<Content: View>(
@ViewBuilder content: () -> Content
) -> some View {
content()
.frame(
minHeight: max(
0,
(isCompact
? Metrics.Chat.compactComposerSurfaceMinHeight
: Metrics.Chat.composerSurfaceMinHeight) - Metrics.Space.xxl
),
alignment: .top
)
.composerBlock()
.padding(.horizontal, Metrics.Space.l)
.frame(maxWidth: isCompact ? .infinity : Metrics.Chat.contentMaxWidth)
.frame(maxWidth: .infinity)
.padding(.top, Metrics.Space.s)
.padding(.bottom, isCompact ? Metrics.Space.s : Metrics.Space.l)
.zIndex(50)
}
private var transcript: some View {
ConversationTranscriptSurface(
conversation: conversation,
isCompact: isCompact,
sessionContext: sessionContext,
showsTerminalActions: showsTerminalActions
)
}
}
/// Keeps composer draft edits outside the transcript's observation boundary.
///
/// `ConversationView` owns both surfaces, but a draft keystroke must not rebuild
/// the timeline, hash every Markdown entry, or ask ListViewKit to compare a long
/// conversation. This child observes only transcript-affecting properties; the
/// composer remains free to update at key-repeat speed.
private struct ConversationTranscriptSurface: View {
let conversation: ConversationModel
let isCompact: Bool
let sessionContext: SessionContext
let showsTerminalActions: Bool
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@Environment(\.locale) private var locale
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorScheme) private var colorScheme
var body: some View {
let timeline = conversation.timeline
let entries = TranscriptEntry.items(
timeline: timeline,
isInterrupted: conversation.runState == .interrupted,
canEdit: conversation.canEditUserMessage,
canFork: conversation.canForkAssistantMessage
)
let owner = window.transcriptCache.owner(
for: .init(
conversationID: conversation.id,
surface: isCompact ? .sidebar : .fullScreen
)
)
return TranscriptListView(
owner: owner,
entries: entries,
latestUserMessageID: conversation.messages.last(where: { $0.role == .user })?.id,
configuration: .init(
isCompact: isCompact,
showsTerminalActions: showsTerminalActions,
isConversationStreaming: conversation.isStreaming,
chatDestinations: Dictionary(
uniqueKeysWithValues: window.assistantLibrary.conversations.map {
($0.id, $0.derivedTitle)
}),
locale: locale,
reduceMotion: reduceMotion,
colorScheme: colorScheme
),
actions: .init(
hostName: { id in
app.hostLibrary.host(id)?.name ?? String(id.uuidString.prefix(8))
},
navigateChat: { window.selectAssistant($0) },
editUserMessage: { id, text in
window.editAssistantUserMessage(
conversation.id,
messageID: id,
text: text,
context: sessionContext,
terminalSuggestionsAvailable: showsTerminalActions
)
},
forkAssistantMessage: { id in
window.forkAssistantConversation(
conversation.id,
at: id,
inSidebar: isCompact
)
},
resolvePlan: { id, outcome in
conversation.resolvePlan(id, as: outcome)
},
implementPlan: { card in
app.sendPlanImplementation(
card, in: conversation, context: sessionContext,
terminalSuggestionsAvailable: showsTerminalActions, window: window)
},
canDeliverCommand: {
guard let workspace = window.selectedWorkspace else { return false }
return workspace.focusedPane.sshSession != nil
|| !workspace.broadcastTargets.isEmpty
},
deliverCommand: { command, submit in
guard let workspace = window.selectedWorkspace,
let origin = workspace.focusedPane.sshSession
?? workspace.broadcastTargets.first
else { return }
workspace.deliver(command, from: origin, submit: submit)
}
)
)
// A representable coordinator is retained at one structural position.
// Conversation or surface changes must therefore replace the mount
// bridge instead of letting an old owner receive the new transcript.
.id(owner.key)
}
}
// MARK: - Decisions
/// Kept as an emphasised card on purpose. This is the one place in the
/// transcript that is waiting on a person, and the de-emphasis the rest of this
/// view got would make it read like history.
private struct AgentApprovalCard: View {
@ScaledMetric(relativeTo: .caption) private var approvalHeight: CGFloat = 240
let request: AgentApprovalRequest
let conversation: ConversationModel
@Environment(AppModel.self) private var app
@Environment(\.locale) private var locale
@State private var showsSafetyDetails = false
var body: some View {
let presentation = AgentToolPresentation.approval(
for: request,
presenter: TermindAgentTools.presenter(for: request.descriptor),
hostName: request.invocation.targetHostID.map(hostName),
locale: locale
)
return VStack(alignment: .leading, spacing: Metrics.Space.m) {
HStack {
Label(AppStrings.Assistant.approvalRequired.swiftUIKey, systemImage: "hand.raised.fill").font(
.headline)
Spacer()
QueueDepthLabel(remaining: conversation.pendingApprovals.count)
}
HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.s) {
Image(systemName: presentation.symbol)
.foregroundStyle(.orange)
.accessibilityHidden(true)
Text(presentation.title)
.font(.callout.weight(.medium))
.lineLimit(3)
}
ViewThatFits(in: .vertical) {
approvalDetails(presentation)
.fixedSize(horizontal: false, vertical: true)
ScrollView {
approvalDetails(presentation)
.frame(maxWidth: .infinity, alignment: .leading)
}
.scrollBounceBehavior(.basedOnSize)
}
.frame(maxHeight: AgentToolPresentation.usesCompactDetails(for: request.descriptor) ? approvalHeight : nil)
HStack {
Button(AppStrings.Common.deny.swiftUIKey, role: .destructive) {
conversation.respondToApproval(request.id, allow: false)
}
.accessibilityIdentifier("AssistantApproval.Deny")
Spacer()
Button(AppStrings.Assistant.allowOnce.swiftUIKey) {
conversation.respondToApproval(request.id, allow: true)
}
.transcriptProminentButtonStyle()
.accessibilityIdentifier("AssistantApproval.AllowOnce")
}
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(2)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("AssistantApproval.Card")
.activeFormKeyboardActions(
isDefaultEnabled: true,
onDefault: { conversation.respondToApproval(request.id, allow: true) },
onCancel: { conversation.respondToApproval(request.id, allow: false) }
)
}
private func approvalDetails(_ presentation: AgentApprovalPresentation) -> some View {
AssistantApprovalDetails(
detail: presentation.detail,
reasons: request.localReasons,
showsSafetyDetails: $showsSafetyDetails
)
}
private func hostName(_ id: UUID) -> String {
app.hostLibrary.host(id)?.name ?? String(id.uuidString.prefix(8))
}
}
/// How much else is waiting behind this card.
///
/// A parallel turn can raise several questions, and they are answered one at a
/// time. Without this, dismissing one card and finding another in its place
/// reads as the same card having failed to go away.
struct QueueDepthLabel: View {
let remaining: Int
var body: some View {
if remaining > 1 {
Text(AppStrings.Assistant.firstQuestionOf(remaining))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
/// SSH authentication presented in the Agent surface. Agent owns placement and
/// queue state; the actual credential form is shared with the Journey.
private struct AgentSSHAuthenticationCard: View {
let request: AgentSSHAuthenticationRequest
let conversation: ConversationModel
var body: some View {
VStack(spacing: 0) {
if conversation.pendingSSHAuthentications.count > 1 {
HStack {
Spacer()
QueueDepthLabel(
remaining: conversation.pendingSSHAuthentications.count
)
}
.padding(.bottom, Metrics.Space.s)
}
if let signing = request.securityKey {
AgentSecurityKeySigningCard(request: signing) {
conversation.respondToSSHAuthentication($0, requestID: request.id)
}
} else {
DesktopSSHAuthenticationForm(
prompt: request.journeyPrompt,
submit: submit,
cancel: {
conversation.respondToSSHAuthentication(.cancelled, requestID: request.id)
},
usesContainerChrome: false,
respondKeyboardInteractive: {
conversation.respondToSSHAuthentication(.keyboardInteractive($0), requestID: request.id)
}
)
// A retry or the next route node must start with fresh SecureFields.
.id(request.id)
}
}
}
private func submit(
identityID: Identity.ID?, username: String, password: String?,
passphrase: String?, keyID: SSHKeyRef.ID?, save: Bool
) {
conversation.respondToSSHAuthentication(
.submit(
AgentSSHAuthenticationAnswer(
identityID: identityID,
username: username,
password: password,
passphrase: passphrase,
keyID: keyID,
save: request.requiresPassphrase ? false : save
)
), requestID: request.id)
}
}
private struct AgentSSHHostKeyCard: View {
let request: AgentSSHHostKeyRequest
let conversation: ConversationModel
var body: some View {
VStack(spacing: 0) {
if conversation.pendingSSHHostKeys.count > 1 {
HStack {
Spacer()
QueueDepthLabel(remaining: conversation.pendingSSHHostKeys.count)
}
.padding(.bottom, Metrics.Space.s)
}
DesktopSSHHostKeyConfirmationForm(
prompt: request.prompt,
displayAddress: request.address,
proceed: { conversation.respondToSSHHostKey(.continueOnce) },
trust: request.allowsPersistentTrust
? { conversation.respondToSSHHostKey(.trustAndContinue) } : nil,
deny: { conversation.respondToSSHHostKey(.deny) },
usesContainerChrome: false
)
.id(request.id)
}
}
}
// MARK: - Steering
/// What the user has said to a busy agent that it has not been told yet.
///
/// Deliberately outside the transcript. These are not turns of the conversation
/// until the run delivers them, and drawing them as bubbles would put words in
/// a place the model never read them. The strip empties itself: each row leaves
/// as its message enters the transcript proper.
struct SteeringQueueView: View {
let conversation: ConversationModel
var isCompact = false
var isEmbedded = false
/// The rows' own height, because a `ScrollView` will not report one — left
/// alone it takes every point it is offered, and the strip would stand at
/// its ceiling with one line in it. Same problem, and same answer, as the
/// draft editor's height in `ComposerView`.
@State private var rowsHeight: CGFloat = 0
@State private var hoveredMessageID: PendingSteeringMessage.ID?
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.m) {
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, Metrics.Chat.steeringQueueMaxHeight))
.scrollBounceBehavior(.basedOnSize)
}
.frame(maxWidth: .infinity, alignment: .leading)
// The same surface the Composer directly below it uses, so the two read
// as one object with a queue on top rather than as two panels — see
// `assistantInteractionSurface`. No hand-drawn stroke: system glass
// draws its own edge, and a second one on top of it reads as a seam.
.assistantInteractionSurface()
.padding(.horizontal, isEmbedded ? 0 : Metrics.Space.l)
.padding(.top, Metrics.Space.s)
.accessibilityElement(children: .contain)
.accessibilityLabel(Text(AppStrings.Assistant.queuedMessages))
}
/// What was typed, not what will be sent: `message.text` carries the
/// attachment blocks folded in for the model, and a row of markup is not a
/// reminder of anything.
private func row(_ pending: PendingSteeringMessage) -> some View {
Button {
conversation.returnSteeringToDraft(pending.id)
} label: {
HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.s) {
Text(pending.draft)
.font(.callout)
.lineLimit(isCompact ? 1 : 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(.system(size: 8, weight: .bold))
.frame(width: Metrics.Sidebar.rowIcon, height: Metrics.Sidebar.rowIcon)
.foregroundStyle(.secondary)
}
.padding(.horizontal, Metrics.Space.s)
.padding(.vertical, Metrics.Space.xs)
.frame(maxWidth: .infinity, alignment: .leading)
// Transparent at rest, so the rows read as content on the surface
// rather than as chips laid on top of it. The hover fill is the
// app's one hover level — see `Highlight`.
.background(
Highlight.fill(
isSelected: false, isHovering: hoveredMessageID == pending.id
),
in: .rect(cornerRadius: Metrics.Radius.medium)
)
.contentShape(.rect)
}
.buttonStyle(.plain)
.onHover { isHovering in
if isHovering {
hoveredMessageID = pending.id
} else if hoveredMessageID == pending.id {
hoveredMessageID = nil
}
}
.help(Text(AppStrings.Assistant.takeThisBackAndEditIt))
.accessibilityLabel(Text(AppStrings.Assistant.unqueueMessage))
}
}