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 { 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 = [] 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 { 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( _ keyPath: WritableKeyPath ) -> Binding { 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" } } }