| 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" |
| } |
| } |
| } |
|
|