1023 lines
39 KiB
Swift
1023 lines
39 KiB
Swift
import AgentKit
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
extension View {
|
|
fileprivate func transcriptProminentButtonStyle() -> some View {
|
|
buttonStyle(.borderedProminent)
|
|
.foregroundStyle(TermindColor.transcriptProminentForeground)
|
|
}
|
|
}
|
|
|
|
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: "Attach to Chat"),
|
|
detail: String(localized: "Files and images"),
|
|
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: "Dropped image")
|
|
)
|
|
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.tasks.isEmpty {
|
|
chatColumn {
|
|
AgentTaskCapsuleView(
|
|
conversation: conversation, isCompact: isCompact
|
|
)
|
|
}
|
|
// Below the composer's Slash overlay, above Steering and
|
|
// every ordinary surface in this interaction region.
|
|
.zIndex(50)
|
|
}
|
|
if !conversation.pendingSteering.isEmpty {
|
|
chatColumn {
|
|
SteeringQueueView(
|
|
conversation: conversation,
|
|
isCompact: isCompact,
|
|
isEmbedded: true,
|
|
surfaceStyle: interactionSurfaceStyle
|
|
)
|
|
}
|
|
}
|
|
ComposerView(
|
|
conversation: conversation,
|
|
surfaceStyle: interactionSurfaceStyle,
|
|
maxContentWidth: isCompact ? nil : Metrics.Chat.contentMaxWidth,
|
|
sessionContext: sessionContext,
|
|
includesTerminalSuggestion: showsTerminalActions,
|
|
onNewChat: onNewChat,
|
|
onSend: onSend
|
|
)
|
|
.zIndex(100)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var archivedSurface: some View {
|
|
if isCompact {
|
|
archivedSurfaceContent
|
|
.composerBlock(style: interactionSurfaceStyle)
|
|
.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("Archived conversations are read-only.", systemImage: "archivebox")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button("Unarchive") {
|
|
window.setAssistantArchived(conversation.id, archived: false)
|
|
}
|
|
.transcriptProminentButtonStyle()
|
|
}
|
|
}
|
|
|
|
private var interactionSurfaceStyle: AssistantInteractionSurfaceStyle {
|
|
isCompact ? .sidebar : .standard
|
|
}
|
|
|
|
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 .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(style: interactionSurfaceStyle)
|
|
.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,
|
|
locale: locale,
|
|
reduceMotion: reduceMotion,
|
|
colorScheme: colorScheme
|
|
),
|
|
actions: .init(
|
|
hostName: { id in
|
|
app.hostLibrary.host(id)?.name ?? String(id.uuidString.prefix(8))
|
|
},
|
|
editUserMessage: { id, text in
|
|
window.editAssistantUserMessage(
|
|
conversation.id,
|
|
messageID: id,
|
|
text: text,
|
|
context: sessionContext,
|
|
includesTerminalSuggestion: showsTerminalActions
|
|
)
|
|
},
|
|
forkAssistantMessage: { id in
|
|
window.forkAssistantConversation(
|
|
conversation.id,
|
|
at: id,
|
|
inSidebar: isCompact
|
|
)
|
|
},
|
|
resolvePlan: { id, outcome in
|
|
conversation.resolvePlan(id, as: outcome)
|
|
},
|
|
implementPlan: { card in
|
|
guard app.proAccess.require(.assistant) else { return }
|
|
conversation.resolvePlan(card.id, as: .implemented)
|
|
let prompt = ConversationModel.implementationPrompt(for: card.plan)
|
|
conversation.appendUserTurn(prompt)
|
|
app.startAgentRun(
|
|
conversation, prompt: prompt, context: sessionContext,
|
|
includesTerminalSuggestion: showsTerminalActions)
|
|
},
|
|
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: - Tasks
|
|
|
|
/// What the agent is working through, as one small capsule.
|
|
///
|
|
/// Above the composer rather than in the transcript, and that difference is the
|
|
/// design. A plan is a document decided once, so it belongs where it happened. A
|
|
/// task list is live state rewritten every turn: one transcript row that
|
|
/// silently mutated would misdate itself, and one row per revision would bury
|
|
/// the conversation it describes.
|
|
///
|
|
/// A capsule rather than a strip because at rest the only thing worth saying is
|
|
/// "4 / 9". It sits above every other thing stacked over the composer — the
|
|
/// approval card, a question, the steering queue — because it is the *state* of
|
|
/// the work, and those are all interruptions to it.
|
|
///
|
|
/// The list opens on hover, as an overlay drawn upward over the transcript.
|
|
/// Inserting it into the stack instead would push the conversation every time
|
|
/// the pointer crossed the pill.
|
|
struct AgentTaskCapsuleView: View {
|
|
let conversation: ConversationModel
|
|
var isCompact = false
|
|
|
|
/// Two hover flags, not one. The list is an overlay outside the capsule's
|
|
/// own frame, so hovering it does not keep the capsule's `onHover` true —
|
|
/// and with one flag the list would vanish the moment the pointer reached
|
|
/// for it.
|
|
@State private var isHoveringCapsule = false
|
|
@State private var isHoveringList = false
|
|
@State private var isPinned = false
|
|
/// The rows' own height. A `ScrollView` reports none — left alone it takes
|
|
/// every point it is offered.
|
|
@State private var rowsHeight: CGFloat = 0
|
|
@State private var overlayHeight: CGFloat = 0
|
|
|
|
private var isComplete: Bool {
|
|
conversation.completedTaskCount == conversation.tasks.count
|
|
}
|
|
|
|
private var isExpanded: Bool { isPinned || isHoveringCapsule || isHoveringList }
|
|
|
|
var body: some View {
|
|
capsule
|
|
.overlay(alignment: .top) {
|
|
if isExpanded {
|
|
VStack(spacing: 0) {
|
|
list
|
|
// Keeps hover continuous while leaving a small visual
|
|
// gap between the floating list and its capsule.
|
|
Color.clear.frame(height: Metrics.Space.xs)
|
|
}
|
|
.onGeometryChange(for: CGFloat.self) {
|
|
$0.size.height
|
|
} action: {
|
|
overlayHeight = $0
|
|
}
|
|
// The overlay's bottom meets the capsule's top. Using
|
|
// `.top` is important: bottom-aligning first and then
|
|
// subtracting the list height moved it upward twice.
|
|
.offset(y: -overlayHeight)
|
|
.onHover { isHoveringList = $0 }
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, Metrics.Space.s)
|
|
.animation(.easeOut(duration: 0.12), value: isExpanded)
|
|
}
|
|
|
|
private var capsule: some View {
|
|
Button {
|
|
// Hover is not available to everyone, and a pinned list is also how
|
|
// you read one without holding the pointer still.
|
|
isPinned.toggle()
|
|
} label: {
|
|
HStack(spacing: Metrics.Space.xs) {
|
|
ProgressRing(
|
|
share: Double(conversation.completedTaskCount)
|
|
/ Double(max(conversation.tasks.count, 1)),
|
|
tint: isComplete ? .green : .accentColor,
|
|
diameter: 10, lineWidth: 1.7
|
|
)
|
|
Text("Step \(conversation.completedTaskCount) / \(conversation.tasks.count)")
|
|
.font(.caption2.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.horizontal, Metrics.Space.m)
|
|
.padding(.vertical, 3)
|
|
.termindGlassCapsule(isInteractive: true)
|
|
.contentShape(.capsule)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.onHover { isHoveringCapsule = $0 }
|
|
.help(Text("\(conversation.completedTaskCount) of \(conversation.tasks.count) tasks done"))
|
|
.accessibilityLabel(
|
|
Text("Tasks, \(conversation.completedTaskCount) of \(conversation.tasks.count) done")
|
|
)
|
|
}
|
|
|
|
private var list: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
|
|
ForEach(conversation.tasks) { task in
|
|
row(task)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.onGeometryChange(for: CGFloat.self) {
|
|
$0.size.height
|
|
} action: {
|
|
rowsHeight = $0
|
|
}
|
|
}
|
|
.frame(height: min(rowsHeight, Metrics.Chat.taskListMaxHeight))
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
.padding(Metrics.Space.l)
|
|
.frame(
|
|
width: isCompact
|
|
? Metrics.Chat.compactTaskListWidth : Metrics.Chat.taskListWidth
|
|
)
|
|
.termindGlassSurface(cornerRadius: Metrics.Chat.composerRadius)
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel(Text("Tasks"))
|
|
}
|
|
|
|
private func row(_ task: AgentTask) -> some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.s) {
|
|
Image(systemName: symbol(task.status))
|
|
.font(.caption)
|
|
.foregroundStyle(tint(task.status))
|
|
.frame(width: Metrics.Sidebar.rowIcon)
|
|
Text(task.title)
|
|
.font(.callout)
|
|
.foregroundStyle(task.status == .completed ? .secondary : .primary)
|
|
.strikethrough(task.status == .completed, color: .secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private func symbol(_ status: AgentTask.Status) -> String {
|
|
switch status {
|
|
case .pending: "circle"
|
|
case .inProgress: "circle.lefthalf.filled"
|
|
case .completed: "checkmark.circle.fill"
|
|
}
|
|
}
|
|
|
|
private func tint(_ status: AgentTask.Status) -> Color {
|
|
switch status {
|
|
case .pending: .secondary
|
|
case .inProgress: .accentColor
|
|
case .completed: .green
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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("Approval required", 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("Deny", role: .destructive) {
|
|
conversation.respondToApproval(request.id, allow: false)
|
|
}
|
|
.accessibilityIdentifier("AssistantApproval.Deny")
|
|
Spacer()
|
|
Button("Allow Once") {
|
|
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.
|
|
private struct QueueDepthLabel: View {
|
|
let remaining: Int
|
|
|
|
var body: some View {
|
|
if remaining > 1 {
|
|
Text("1 of \(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)
|
|
}
|
|
|
|
DesktopSSHAuthenticationForm(
|
|
prompt: request.journeyPrompt,
|
|
submit: submit,
|
|
cancel: {
|
|
conversation.respondToSSHAuthentication(.cancelled)
|
|
},
|
|
usesContainerChrome: false
|
|
)
|
|
// 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
|
|
)
|
|
))
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The agent asking the user to decide one or more things.
|
|
///
|
|
/// One step at a time rather than a tall form. Answers stay in the model while
|
|
/// the user moves back and forth, and Skip makes an intentional null distinct
|
|
/// from cancelling the whole request.
|
|
///
|
|
/// Options are a list rather than a `Picker` because each one carries a
|
|
/// sentence explaining what it costs, and a pop-up menu has nowhere to put
|
|
/// that. Nothing is preselected — including the recommendation, which is a
|
|
/// label and not a default, so that an answer always takes a deliberate click.
|
|
/// A question may also be explicitly skipped: the tool reports that as a null
|
|
/// and the model decides it itself.
|
|
private struct AgentUserInputCard: View {
|
|
let request: AgentUserInputRequest
|
|
let conversation: ConversationModel
|
|
var isCompact = false
|
|
|
|
private var isLastQuestion: Bool {
|
|
conversation.currentUserInputQuestionIndex == request.questions.count - 1
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
|
if conversation.pendingUserInputs.count > 1 {
|
|
HStack {
|
|
Spacer()
|
|
QueueDepthLabel(remaining: conversation.pendingUserInputs.count)
|
|
}
|
|
}
|
|
|
|
// Prefer the step's natural height. When the surrounding VStack
|
|
// offers less room, the scrolling fallback accepts that proposal.
|
|
ViewThatFits(in: .vertical) {
|
|
currentQuestion
|
|
ScrollView { currentQuestion }
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
}
|
|
|
|
footer
|
|
}
|
|
.activeFormKeyboardActions(
|
|
isDefaultEnabled: conversation.canAdvanceUserInput,
|
|
onDefault: conversation.advanceUserInputQuestion,
|
|
onCancel: conversation.cancelUserInput
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var currentQuestion: some View {
|
|
if let question = conversation.currentUserInputQuestion {
|
|
questionBlock(question)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.id(question.id)
|
|
}
|
|
}
|
|
|
|
private var footer: some View {
|
|
HStack(spacing: Metrics.Space.m) {
|
|
HStack(spacing: Metrics.Space.s) {
|
|
Button("Cancel") { conversation.cancelUserInput() }
|
|
.keyboardShortcut(.cancelAction)
|
|
if conversation.currentUserInputQuestionIndex > 0 {
|
|
Button("Back") { conversation.previousUserInputQuestion() }
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
questionProgress
|
|
.font(.caption.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize()
|
|
|
|
HStack(spacing: Metrics.Space.s) {
|
|
if conversation.canSkipUserInputQuestion {
|
|
Button("Skip") { conversation.skipUserInputQuestion() }
|
|
}
|
|
Button(isLastQuestion ? "Submit" : "Next") {
|
|
conversation.advanceUserInputQuestion()
|
|
}
|
|
.transcriptProminentButtonStyle()
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(!conversation.canAdvanceUserInput)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var questionProgress: some View {
|
|
let current = conversation.currentUserInputQuestionIndex + 1
|
|
if isCompact {
|
|
Text("\(current) / \(request.questions.count)")
|
|
} else {
|
|
Text("Question \(current) of \(request.questions.count)")
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func questionBlock(_ question: AgentUserQuestion) -> some View {
|
|
@Bindable var conversation = conversation
|
|
VStack(alignment: .leading, spacing: Metrics.Space.s) {
|
|
if !question.header.isEmpty {
|
|
Text(question.header)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text(question.prompt)
|
|
.font(.callout.weight(.medium))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
switch question.kind {
|
|
case .secret:
|
|
SecureField("Secret", text: $conversation.secretInputDraft)
|
|
.textFieldStyle(.roundedBorder)
|
|
case .choice(let choice):
|
|
choiceList(choice, in: question.id)
|
|
case .text:
|
|
TextField("Answer", text: customBinding(for: question.id))
|
|
.textFieldStyle(.roundedBorder)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func choiceList(_ choice: AgentUserInputChoice, in questionID: String) -> some View {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.xs) {
|
|
ForEach(choice.options) { option in
|
|
optionRow(option, choice: choice, in: questionID)
|
|
}
|
|
if choice.allowsCustomAnswer {
|
|
customRow(choice, in: questionID)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func optionRow(
|
|
_ option: AgentUserInputOption, choice: AgentUserInputChoice, in questionID: String
|
|
) -> some View {
|
|
let isSelected = conversation.userInputDraft(for: questionID).selection.contains(option.id)
|
|
return Button {
|
|
conversation.answerUserInputOption(option.id, in: questionID)
|
|
} label: {
|
|
HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.s) {
|
|
Image(systemName: marker(isSelected: isSelected, choice: choice))
|
|
.foregroundStyle(isSelected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
|
|
.frame(width: Metrics.Sidebar.rowIcon)
|
|
|
|
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
|
|
HStack(spacing: Metrics.Space.s) {
|
|
Text(option.label).font(.callout)
|
|
if option.isRecommended {
|
|
Text("Recommended")
|
|
.font(.caption2.weight(.medium))
|
|
.padding(.horizontal, Metrics.Space.s)
|
|
.padding(.vertical, 1)
|
|
.background(.tint.opacity(0.15), in: .capsule)
|
|
.foregroundStyle(.tint)
|
|
}
|
|
}
|
|
if !option.detail.isEmpty {
|
|
Text(option.detail)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(isCompact ? 2 : 3)
|
|
.truncationMode(.tail)
|
|
.help(Text(option.detail))
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.contentShape(.rect)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
/// The escape hatch, sitting in the list as one more choice rather than
|
|
/// below it as an afterthought — an answer the model did not think of is
|
|
/// not a lesser kind of answer.
|
|
private func customRow(_ choice: AgentUserInputChoice, in questionID: String) -> some View {
|
|
let typed = conversation.userInputDraft(for: questionID).text
|
|
return HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.s) {
|
|
Image(systemName: marker(isSelected: !typed.isEmpty, choice: choice))
|
|
.foregroundStyle(typed.isEmpty ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tint))
|
|
.frame(width: Metrics.Sidebar.rowIcon)
|
|
|
|
TextField(
|
|
choice.customPlaceholder.isEmpty
|
|
? String(localized: "Something else…") : choice.customPlaceholder,
|
|
text: customBinding(for: questionID)
|
|
)
|
|
.textFieldStyle(.roundedBorder)
|
|
}
|
|
}
|
|
|
|
private func customBinding(for questionID: String) -> Binding<String> {
|
|
Binding(
|
|
get: { conversation.userInputDraft(for: questionID).text },
|
|
set: { conversation.setUserInputCustomAnswer($0, for: questionID) }
|
|
)
|
|
}
|
|
|
|
private func marker(isSelected: Bool, choice: AgentUserInputChoice) -> String {
|
|
if choice.allowsMultipleAnswers {
|
|
isSelected ? "checkmark.square.fill" : "square"
|
|
} else {
|
|
isSelected ? "largecircle.fill.circle" : "circle"
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
var surfaceStyle: AssistantInteractionSurfaceStyle = .standard
|
|
|
|
/// 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("Steering", 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(style: surfaceStyle)
|
|
.padding(.horizontal, isEmbedded ? 0 : Metrics.Space.l)
|
|
.padding(.top, Metrics.Space.s)
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel(Text("Queued messages"))
|
|
}
|
|
|
|
/// 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("Take this back and edit it"))
|
|
.accessibilityLabel(Text("Unqueue message"))
|
|
}
|
|
}
|