ales27pm's picture
Add autonomous on-device iOS assistant
7b2dfc5 verified
Raw
History Blame Contribute Delete
33.7 kB
import SwiftUI
import UIKit
struct AssistantAppView: View {
@Bindable var session: AssistantSession
@State private var selection: AssistantTab = .chat
var body: some View {
TabView(selection: $selection) {
ChatView(session: session)
.tabItem {
Label("Chat", systemImage: "bubble.left.and.bubble.right")
}
.tag(AssistantTab.chat)
ActivityView(session: session)
.tabItem {
Label("Activity", systemImage: "list.bullet.rectangle.portrait")
}
.badge(activeActivityCount)
.tag(AssistantTab.activity)
KnowledgeView(session: session)
.tabItem {
Label("Knowledge", systemImage: "books.vertical")
}
.tag(AssistantTab.knowledge)
SettingsView(session: session)
.tabItem {
Label("Settings", systemImage: "gearshape")
}
.tag(AssistantTab.settings)
}
.sheet(item: pendingApprovalBinding) { request in
ToolApprovalSheet(
request: request,
approve: session.approvePending,
deny: session.denyPending
)
}
.onReceive(
NotificationCenter.default.publisher(
for: .dolphinIntentHandoffConsumed
)
) { _ in
selection = .chat
}
}
private var activeActivityCount: Int {
session.pendingApproval != nil ? 1 : (session.isRunning ? 1 : 0)
}
private var pendingApprovalBinding: Binding<ToolApprovalRequest?> {
Binding(
get: { session.pendingApproval },
set: { newValue in
if newValue == nil, session.pendingApproval != nil {
session.denyPending()
}
}
)
}
}
private enum AssistantTab: Hashable {
case chat
case activity
case knowledge
case settings
}
struct ChatView: View {
@Bindable var session: AssistantSession
@State private var showingClearConfirmation = false
var body: some View {
NavigationStack {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 14) {
ModelStatusBanner(session: session)
if session.workspace.messages.isEmpty {
EmptyChatView(isModelLoaded: session.isModelLoaded)
.padding(.top, 36)
} else {
ForEach(session.workspace.messages) { message in
MessageBubble(message: message)
.id(message.id)
}
}
if session.isRunning {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text("Dolphin is working…")
.font(.footnote)
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 4)
.id(Self.runningAnchor)
}
}
.padding(.horizontal)
.padding(.vertical, 12)
.frame(maxWidth: 760)
.frame(maxWidth: .infinity)
}
.defaultScrollAnchor(.bottom)
.onChange(of: session.workspace.messages.count) { _, _ in
scrollToLatest(using: proxy)
}
.onChange(of: session.isRunning) { _, _ in
scrollToLatest(using: proxy)
}
.scrollDismissesKeyboard(.interactively)
}
.safeAreaInset(edge: .bottom, spacing: 0) {
ChatComposer(session: session)
}
.navigationTitle("Dolphin")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("Clear Chat & Run Activity", systemImage: "trash", role: .destructive) {
showingClearConfirmation = true
}
.disabled(session.workspace.messages.isEmpty || session.isRunning)
if session.isModelLoaded {
Button("Release Model", systemImage: "eject") {
session.releaseModel()
}
.disabled(session.isRunning)
}
} label: {
Image(systemName: "ellipsis.circle")
}
.accessibilityLabel("Chat actions")
}
}
.confirmationDialog(
"Clear chat and run activity?",
isPresented: $showingClearConfirmation,
titleVisibility: .visible
) {
Button("Clear Chat & Run Activity", role: .destructive) {
session.clearConversation()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Messages, run records, run-scoped Activity receipts, and copied recent-chat excerpts in handoffs will be removed. Saved knowledge, memories, handoffs, tasks, and standalone Activity entries remain.")
}
}
}
private func scrollToLatest(using proxy: ScrollViewProxy) {
withAnimation(.easeOut(duration: 0.2)) {
if session.isRunning {
proxy.scrollTo(Self.runningAnchor, anchor: .bottom)
} else if let lastID = session.workspace.messages.last?.id {
proxy.scrollTo(lastID, anchor: .bottom)
}
}
}
private static let runningAnchor = "assistant-running"
}
private struct ModelStatusBanner: View {
@Bindable var session: AssistantSession
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Image(systemName: statusSymbol)
.font(.title3)
.foregroundStyle(statusColor)
.symbolEffect(
.pulse,
isActive: session.isRunning || session.isModelLoading
)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(session.modelStatus)
.font(.subheadline.weight(.semibold))
Text("Private, on-device tools and Core ML assistant")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
if session.isModelLoading {
ProgressView()
.controlSize(.small)
.accessibilityLabel("Loading model")
Button("Cancel") {
session.cancelModelLoad()
}
.buttonStyle(.bordered)
.controlSize(.small)
.accessibilityLabel("Cancel model load")
.accessibilityIdentifier("cancel-model-load")
} else if !session.isModelLoaded {
Button("Load") {
session.loadModel()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!session.isWorkspaceReady)
.accessibilityIdentifier("load-model")
}
}
if let error = session.errorMessage, !error.isEmpty {
Label(error, systemImage: "exclamationmark.triangle.fill")
.font(.footnote)
.foregroundStyle(.red)
.textSelection(.enabled)
.accessibilityIdentifier("assistant-error")
}
if let warning = session.resourceWarning, !warning.isEmpty {
Label(warning, systemImage: "gauge.with.dots.needle.50percent")
.font(.footnote)
.foregroundStyle(.orange)
.accessibilityIdentifier("resource-warning")
}
}
.padding(14)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
private var statusSymbol: String {
if session.isRunning { return "sparkles" }
if !session.isWorkspaceReady { return "exclamationmark.triangle.fill" }
if session.isModelLoading { return "memorychip.fill" }
return session.isModelLoaded ? "checkmark.circle.fill" : "memorychip"
}
private var statusColor: Color {
if session.isRunning { return .orange }
if !session.isWorkspaceReady { return .red }
if session.isModelLoading { return .orange }
return session.isModelLoaded ? .green : .secondary
}
}
private struct EmptyChatView: View {
let isModelLoaded: Bool
var body: some View {
ContentUnavailableView {
Label("Your on-device assistant", systemImage: "sparkles")
} description: {
Text(
isModelLoaded
? "Ask a question, save a memory, or manage a task. Actions stay bounded and auditable."
: "Explicit local commands work now. Load the model when you want a private conversation."
)
}
}
}
private struct MessageBubble: View {
let message: AssistantMessage
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
if message.role == .user {
Spacer(minLength: 46)
}
VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 5) {
Text(message.content)
.font(.body)
.foregroundStyle(message.role == .user ? Color.white : Color.primary)
.textSelection(.enabled)
HStack(spacing: 5) {
if message.wasStopped {
Image(systemName: "stop.circle")
Text("Stopped")
}
Text(message.createdAt, format: .dateTime.hour().minute())
}
.font(.caption2)
.foregroundStyle(message.role == .user ? Color.white.opacity(0.75) : .secondary)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(bubbleColor, in: RoundedRectangle(cornerRadius: 17))
.accessibilityElement(children: .combine)
if message.role == .assistant {
Spacer(minLength: 46)
}
}
.frame(maxWidth: .infinity)
}
private var bubbleColor: Color {
message.role == .user ? .accentColor : Color(uiColor: .secondarySystemBackground)
}
}
private struct ChatComposer: View {
@Bindable var session: AssistantSession
var body: some View {
HStack(alignment: .bottom, spacing: 10) {
TextField(
session.isModelLoaded
? "Message Dolphin"
: "Local command, or load model to chat",
text: $session.draft,
axis: .vertical
)
.lineLimit(1...5)
.textFieldStyle(.plain)
.padding(.horizontal, 14)
.padding(.vertical, 11)
.background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 20))
.disabled(
session.isRunning
|| session.isModelLoading
|| !session.isWorkspaceReady
)
.submitLabel(.send)
.onSubmit(sendIfPossible)
.accessibilityIdentifier("assistant-composer")
if session.isRunning {
Button(role: .destructive) {
session.stop()
} label: {
Image(systemName: "stop.fill")
.font(.headline)
.frame(width: 44, height: 44)
}
.buttonStyle(.borderedProminent)
.clipShape(Circle())
.accessibilityLabel("Stop assistant")
.accessibilityIdentifier("stop-assistant")
} else {
Button(action: sendIfPossible) {
Image(systemName: "arrow.up")
.font(.headline)
.frame(width: 44, height: 44)
}
.buttonStyle(.borderedProminent)
.clipShape(Circle())
.disabled(!canSend)
.accessibilityLabel("Send message")
.accessibilityIdentifier("send-message")
}
}
.padding(.horizontal)
.padding(.vertical, 10)
.background(.bar)
}
private var canSend: Bool {
session.canSubmitDraft
}
private func sendIfPossible() {
guard canSend else { return }
session.send()
}
}
struct ActivityView: View {
@Bindable var session: AssistantSession
@State private var expandedRunIDs: Set<UUID> = []
var body: some View {
NavigationStack {
Group {
if session.workspace.runs.isEmpty && session.workspace.events.isEmpty {
ContentUnavailableView(
"No activity yet",
systemImage: "list.bullet.rectangle.portrait",
description: Text("Model turns, approvals, and tool actions will appear here.")
)
} else {
List {
if !orderedRuns.isEmpty {
Section("Runs") {
ForEach(orderedRuns) { run in
DisclosureGroup(
isExpanded: expansionBinding(for: run.id)
) {
let events = runEvents(for: run.id)
if events.isEmpty {
Text("No timeline events were recorded for this run.")
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.vertical, 6)
} else {
ForEach(events) { event in
ActivityRow(event: event)
.padding(.leading, 4)
}
}
} label: {
RunSummaryCard(run: run)
}
}
}
}
if !standaloneEvents.isEmpty {
Section("Standalone events") {
ForEach(standaloneEvents) { event in
ActivityRow(event: event)
}
}
}
}
.listStyle(.insetGrouped)
}
}
.navigationTitle("Activity")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if session.isRunning {
Button("Stop", systemImage: "stop.fill", role: .destructive) {
session.stop()
}
}
}
}
}
}
private var orderedRuns: [AgentRunRecord] {
session.workspace.runs.sorted { first, second in
first.createdAt > second.createdAt
}
}
private var standaloneEvents: [AgentEvent] {
let currentRunIDs = Set(session.workspace.runs.map(\.id))
return session.workspace.events.filter { event in
guard let runID = event.runID else { return true }
return !currentRunIDs.contains(runID)
}.sorted { first, second in
first.createdAt > second.createdAt
}
}
private func runEvents(for runID: UUID) -> [AgentEvent] {
session.workspace.events.filter { $0.runID == runID }.sorted {
first, second in
if first.sequence == second.sequence {
return first.createdAt < second.createdAt
}
return first.sequence < second.sequence
}
}
private func expansionBinding(for runID: UUID) -> Binding<Bool> {
Binding(
get: { expandedRunIDs.contains(runID) },
set: { isExpanded in
if isExpanded {
expandedRunIDs.insert(runID)
} else {
expandedRunIDs.remove(runID)
}
}
)
}
}
private struct RunSummaryCard: View {
let run: AgentRunRecord
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(run.requestText.isEmpty ? "Untitled request" : run.requestText)
.font(.headline)
.lineLimit(3)
HStack(spacing: 8) {
StatusPill(text: run.status.label, color: run.status.tint)
if let stopReason = run.stopReason {
Label(stopReason.label, systemImage: "stop.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 4)
Text(run.createdAt, format: .dateTime.month().day().hour().minute())
.font(.caption)
.foregroundStyle(.tertiary)
}
Label(
"Phase: \(run.checkpoint.phase.label)",
systemImage: run.checkpoint.phase.symbolName
)
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 14) {
RunMetric(
value: run.checkpoint.modelTurns,
label: "model",
systemImage: "memorychip"
)
RunMetric(
value: run.checkpoint.toolCallsUsed,
label: "tools",
systemImage: "wrench.and.screwdriver"
)
RunMetric(
value: run.checkpoint.completedReceipts.count,
label: "verified",
systemImage: "checkmark.shield"
)
}
if let error = run.errorSummary, !error.isEmpty {
Label(error, systemImage: "exclamationmark.triangle.fill")
.font(.footnote)
.foregroundStyle(.red)
.textSelection(.enabled)
}
}
.padding(.vertical, 6)
}
}
private struct RunMetric: View {
let value: Int
let label: String
let systemImage: String
var body: some View {
Label("\(value) \(label)", systemImage: systemImage)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("\(value) \(label)")
}
}
private struct ActivityRow: View {
let event: AgentEvent
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: event.status.symbolName)
.foregroundStyle(event.status.tint)
.font(.title3)
.frame(width: 26)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline) {
Text(event.title)
.font(.headline)
Spacer()
StatusPill(text: event.status.label, color: event.status.tint)
}
if !event.detail.isEmpty {
Text(event.detail)
.font(.subheadline)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
HStack(spacing: 6) {
Label(event.kind.label, systemImage: event.kind.symbolName)
Text("·")
Text("Step \(event.sequence)")
Text("·")
Text(event.createdAt, format: .dateTime.hour().minute().second())
}
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
.accessibilityElement(children: .combine)
}
}
struct SettingsView: View {
@Bindable var session: AssistantSession
@Environment(\.openURL) private var openURL
@State private var settingsDraft = AgentSettings()
@State private var hasSynchronizedSettings = false
var body: some View {
NavigationStack {
Form {
modelSection
autonomySection
permissionsSection
systemContextSection
instructionSection
privacySection
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
if let appliedSettings = session.saveSettings(settingsDraft) {
settingsDraft = appliedSettings
hasSynchronizedSettings = true
}
}
.disabled(
!session.isWorkspaceReady
|| session.isRunning
|| !hasUnsavedSettings
)
.accessibilityIdentifier("save-settings")
}
}
.onAppear {
synchronizeSettingsIfNeeded()
}
.onChange(of: session.isWorkspaceReady) { _, isReady in
if isReady {
synchronizeSettings(force: true)
} else {
hasSynchronizedSettings = false
}
}
.onChange(
of: session.workspace.settings.enabledSystemCapabilities
) { _, capabilities in
settingsDraft.enabledSystemCapabilities = capabilities
}
.task(id: session.isWorkspaceReady) {
guard session.isWorkspaceReady else { return }
session.refreshResourceSnapshot()
await session.refreshCalendarAuthorization()
}
}
}
private var modelSection: some View {
Section("On-device model") {
LabeledContent("Status") {
HStack(spacing: 6) {
Circle()
.fill(
!session.isWorkspaceReady
? Color.red
: (session.isModelLoaded ? Color.green : Color.secondary)
)
.frame(width: 8, height: 8)
Text(session.modelStatus)
}
}
if session.isModelLoaded {
Button("Release Model", systemImage: "eject") {
session.releaseModel()
}
.disabled(session.isRunning)
} else if session.isModelLoading {
HStack {
ProgressView()
.controlSize(.small)
Text("Loading model…")
.foregroundStyle(.secondary)
Spacer()
Button("Cancel Load", systemImage: "xmark.circle") {
session.cancelModelLoad()
}
.accessibilityIdentifier("settings-cancel-model-load")
}
} else {
Button("Load Model", systemImage: "memorychip") {
session.loadModel()
}
.buttonStyle(.borderedProminent)
.disabled(!session.isWorkspaceReady || session.isModelLoading)
}
Text("The 1.81 GB stateful INT4 model stays on this device. Releasing it recovers memory.")
.font(.footnote)
.foregroundStyle(.secondary)
if let snapshot = session.resourceSnapshot {
LabeledContent("Thermal state", value: snapshot.thermalLevel.label)
LabeledContent(
"Low Power Mode",
value: snapshot.lowPowerModeEnabled ? "On" : "Off"
)
if let availableMemory = snapshot.availableMemoryBytes {
LabeledContent(
"Available memory (advisory)",
value: ByteCountFormatter.string(
fromByteCount: Int64(clamping: availableMemory),
countStyle: .memory
)
)
}
}
if let warning = session.resourceWarning, !warning.isEmpty {
Label(warning, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.orange)
}
}
}
private var autonomySection: some View {
Section("Run limits") {
Stepper(
"Tool steps: \(settingsDraft.maxToolSteps)",
value: settingBinding(\.maxToolSteps),
in: AgentSettings.maxToolStepsRange
)
Stepper(
"Output budget: \(settingsDraft.maxNewTokens) tokens",
value: settingBinding(\.maxNewTokens),
in: AgentSettings.maxNewTokensRange,
step: 32
)
Stepper(
"Time limit: \(settingsDraft.maxRunSeconds) seconds",
value: settingBinding(\.maxRunSeconds),
in: AgentSettings.maxRunSecondsRange,
step: 30
)
Text("Tool and output limits are hard boundaries. At the time deadline, Dolphin requests cancellation; a Core ML prediction already in progress must return before the stop is observed.")
.font(.footnote)
.foregroundStyle(.secondary)
}
.disabled(!session.isWorkspaceReady || session.isRunning)
}
private var permissionsSection: some View {
Section("Permissions") {
Toggle(
"Require approval for local changes",
isOn: settingBinding(\.requireApprovalForLocalWrites)
)
Label(
"External network tools are disabled until connected-peer validation can be enforced.",
systemImage: "network.slash"
)
.font(.footnote)
.foregroundStyle(.secondary)
}
.disabled(!session.isWorkspaceReady || session.isRunning)
}
private var systemContextSection: some View {
Section("System context") {
LabeledContent("Calendar") {
Label(
calendarStatusText,
systemImage: calendarContextEnabled
? "checkmark.shield.fill"
: "calendar.badge.exclamationmark"
)
.foregroundStyle(
calendarContextEnabled ? Color.green : Color.secondary
)
}
if session.isCalendarAccessRequesting {
HStack(spacing: 10) {
ProgressView()
Text("Waiting for Calendar permission…")
.foregroundStyle(.secondary)
}
} else if calendarContextEnabled {
Button("Disable Calendar Context", systemImage: "calendar.badge.minus") {
session.disableCalendarContext()
}
} else {
calendarEnableAction
}
Text(
"Calendar is opt-in. Dolphin exposes only bounded event titles and times, only in the foreground, and asks you to approve each exact read. It never creates or edits events."
)
.font(.footnote)
.foregroundStyle(.secondary)
}
.disabled(
!session.isWorkspaceReady
|| session.isRunning
|| session.isCalendarAccessRequesting
)
}
@ViewBuilder
private var calendarEnableAction: some View {
switch session.calendarAuthorization {
case .notDetermined:
Button("Grant and Enable Calendar", systemImage: "calendar.badge.plus") {
Task { await session.requestAndEnableCalendarAccess() }
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("grant-calendar-access")
case .fullAccess:
Button("Enable Calendar Context", systemImage: "calendar.badge.plus") {
session.enableCalendarContext()
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("enable-calendar-context")
case .denied, .writeOnly:
Button("Open iOS Settings", systemImage: "gear") {
guard let url = URL(string: UIApplication.openSettingsURLString) else {
return
}
openURL(url)
}
.accessibilityIdentifier("open-calendar-settings")
case .restricted:
Label(
"Calendar access is restricted on this iPhone.",
systemImage: "lock.trianglebadge.exclamationmark"
)
.foregroundStyle(.secondary)
}
}
private var calendarContextEnabled: Bool {
session.workspace.settings.enabledSystemCapabilities.contains(.calendarRead)
&& session.calendarAuthorization == .fullAccess
}
private var calendarStatusText: String {
if calendarContextEnabled { return "Enabled" }
switch session.calendarAuthorization {
case .fullAccess: return "OS access granted; Dolphin disabled"
case .notDetermined: return "Not requested"
case .denied: return "Denied in iOS"
case .restricted: return "Restricted"
case .writeOnly: return "Write-only; read access required"
}
}
private var instructionSection: some View {
Section("Assistant instruction") {
TextEditor(text: settingBinding(\.systemPrompt))
.frame(minHeight: 150)
.font(.body.monospaced())
.disabled(session.isRunning)
Button("Restore Default") {
settingsDraft.systemPrompt = AgentSettings.defaultSystemPrompt
}
.disabled(session.isRunning)
}
.disabled(!session.isWorkspaceReady || session.isRunning)
}
private var privacySection: some View {
Section("Privacy and control") {
Label("Conversation, memories, tasks, and activity are stored locally.", systemImage: "iphone")
Label("Tool actions are recorded in Activity.", systemImage: "checkmark.shield")
Label("Closing or backgrounding the app stops an active run.", systemImage: "stop.circle")
}
.font(.footnote)
.foregroundStyle(.secondary)
}
private func settingBinding<Value>(
_ keyPath: WritableKeyPath<AgentSettings, Value>
) -> Binding<Value> {
Binding(
get: { settingsDraft[keyPath: keyPath] },
set: { settingsDraft[keyPath: keyPath] = $0 }
)
}
private var hasUnsavedSettings: Bool {
hasSynchronizedSettings
&& settingsDraft != session.workspace.settings
}
private func synchronizeSettingsIfNeeded() {
guard session.isWorkspaceReady, !hasSynchronizedSettings else { return }
synchronizeSettings(force: true)
}
private func synchronizeSettings(force: Bool) {
guard session.isWorkspaceReady else { return }
guard force || !hasUnsavedSettings else { return }
settingsDraft = session.workspace.settings
hasSynchronizedSettings = true
}
}
private struct ToolApprovalSheet: View {
let request: ToolApprovalRequest
let approve: () -> Void
let deny: () -> Void
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Label("Dolphin is waiting for you", systemImage: "hand.raised.fill")
.font(.title2.weight(.bold))
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 8) {
Text(request.toolName)
.font(.headline)
Text(request.reason)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
Text("Exact action")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(request.call.name)
.font(.body.monospaced().weight(.semibold))
Text(JSONValue.object(request.call.arguments).canonicalJSON)
.font(.footnote.monospaced())
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
}
Label(
request.isExpired()
? "This approval has expired. Deny it and run the request again."
: "Approval applies once, only to the exact action shown above, and expires at \(request.expiresAt.formatted(date: .omitted, time: .shortened)).",
systemImage: request.isExpired() ? "clock.badge.xmark" : "lock.shield"
)
.font(.footnote)
.foregroundStyle(request.isExpired() ? .red : .secondary)
VStack(spacing: 10) {
Button {
approve()
} label: {
Label("Approve Once", systemImage: "checkmark.shield.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(request.isExpired())
.accessibilityIdentifier("approve-tool")
Button("Deny", systemImage: "xmark", role: .destructive) {
deny()
}
.buttonStyle(.bordered)
.controlSize(.large)
.frame(maxWidth: .infinity)
.accessibilityIdentifier("deny-tool")
}
}
.padding(20)
.frame(maxWidth: 620)
.frame(maxWidth: .infinity)
}
.navigationTitle("Approve Action")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Deny", role: .cancel) {
deny()
}
}
}
}
.interactiveDismissDisabled()
.presentationDetents([.medium, .large])
}
}
private struct StatusPill: View {
let text: String
let color: Color
var body: some View {
Text(text)
.font(.caption2.weight(.semibold))
.foregroundStyle(color)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(color.opacity(0.12), in: Capsule())
}
}
private extension AgentEventStatus {
var label: String {
switch self {
case .information: "Info"
case .running: "Running"
case .awaitingApproval: "Approval"
case .succeeded: "Succeeded"
case .denied: "Denied"
case .failed: "Failed"
case .cancelled: "Cancelled"
}
}
var symbolName: String {
switch self {
case .information: "info.circle.fill"
case .running: "arrow.trianglehead.2.clockwise.rotate.90"
case .awaitingApproval: "hand.raised.fill"
case .succeeded: "checkmark.circle.fill"
case .denied: "hand.raised.slash.fill"
case .failed: "xmark.octagon.fill"
case .cancelled: "stop.circle.fill"
}
}
var tint: Color {
switch self {
case .information: .blue
case .running: .orange
case .awaitingApproval: .orange
case .succeeded: .green
case .denied: .secondary
case .failed: .red
case .cancelled: .secondary
}
}
}
private extension AgentEventKind {
var label: String {
switch self {
case .run: "Run"
case .model: "Model"
case .tool: "Tool"
case .approval: "Approval"
case .persistence: "Storage"
}
}
var symbolName: String {
switch self {
case .run: "play.circle"
case .model: "memorychip"
case .tool: "wrench.and.screwdriver"
case .approval: "hand.raised"
case .persistence: "externaldrive"
}
}
}
private extension AgentRunStatus {
var label: String {
switch self {
case .running: "Running"
case .cancellationRequested: "Stopping"
case .succeeded: "Succeeded"
case .failed: "Failed"
case .cancelled: "Cancelled"
case .interrupted: "Interrupted"
}
}
var tint: Color {
switch self {
case .running, .cancellationRequested: .orange
case .succeeded: .green
case .failed, .interrupted: .red
case .cancelled: .secondary
}
}
}
private extension AgentRunPhase {
var label: String {
switch self {
case .preparing: "Preparing"
case .routing: "Routing"
case .modelGeneration: "Model generation"
case .awaitingApproval: "Awaiting approval"
case .toolExecution: "Tool execution"
case .finalizing: "Finalizing"
}
}
var symbolName: String {
switch self {
case .preparing: "list.clipboard"
case .routing: "arrow.triangle.branch"
case .modelGeneration: "memorychip"
case .awaitingApproval: "hand.raised"
case .toolExecution: "wrench.and.screwdriver"
case .finalizing: "checkmark.seal"
}
}
}
private extension AgentRunStopReason {
var label: String {
switch self {
case .user: "Stopped by user"
case .background: "App backgrounded"
case .deadline: "Time limit reached"
case .storageFailure: "Storage failure"
case .processEnded: "App process ended"
}
}
}
private extension AssistantThermalLevel {
var label: String {
switch self {
case .nominal: "Nominal"
case .fair: "Fair"
case .serious: "Serious"
case .critical: "Critical"
case .unknown: "Unknown"
}
}
}