// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import AgentKit import SwiftUI import TermindApplication import TermindDomain import TermindPresentation 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.pendingInteraction { blockingSurface { switch interaction { case .approval(let approval): AgentApprovalCard(request: approval, conversation: conversation) case .userInteraction(let interaction): userInteraction(interaction) } } } 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( @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( @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 availability = conversation.messageActionAvailability let entries = TranscriptEntry.items( timeline: timeline, isInterrupted: conversation.runState == .interrupted, canEdit: availability.editable.contains, canFork: availability.forkable.contains ) 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 = TermindToolPresentationProfiles.renderer.approval( for: request, presenter: AppDependencies.toolPresenter(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: presentation.detail.layout.boundsApprovalHeight ? 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)) } }