| import SwiftUI |
| import UIKit |
|
|
| struct KnowledgeView: View { |
| @Bindable var session: AssistantSession |
| @State private var selection: ContinuitySection = .knowledge |
| @State private var searchQuery = "" |
| @State private var isPresentingCapture = false |
| @State private var isPresentingHandoff = false |
| @State private var feedback: ContinuityFeedback? |
| @State private var searchResults: [KnowledgeItem] = [] |
| @State private var proactiveInsights: [ProactiveKnowledgeInsight] = [] |
|
|
| var body: some View { |
| NavigationStack { |
| VStack(spacing: 0) { |
| Picker("Continuity", selection: $selection) { |
| Text("Knowledge").tag(ContinuitySection.knowledge) |
| Text("Handoffs").tag(ContinuitySection.handoffs) |
| Text("Tasks").tag(ContinuitySection.tasks) |
| } |
| .pickerStyle(.segmented) |
| .padding(.horizontal) |
| .padding(.vertical, 12) |
|
|
| selectedContent |
| } |
| .navigationTitle("Continuity") |
| .navigationBarTitleDisplayMode(.inline) |
| .searchable( |
| text: $searchQuery, |
| placement: .navigationBarDrawer(displayMode: .automatic), |
| prompt: "Search saved knowledge" |
| ) |
| .toolbar { |
| ToolbarItem(placement: .topBarTrailing) { |
| Menu { |
| Button("Capture Knowledge", systemImage: "square.and.pencil") { |
| isPresentingCapture = true |
| } |
|
|
| Button("Create Handoff", systemImage: "arrow.triangle.branch") { |
| isPresentingHandoff = true |
| } |
| } label: { |
| Image(systemName: "plus.circle") |
| } |
| .disabled(!session.isWorkspaceReady || session.isRunning) |
| .accessibilityLabel("Add continuity item") |
| } |
| } |
| .onChange(of: selection) { _, newValue in |
| if newValue != .knowledge { |
| searchQuery = "" |
| } |
| } |
| .task(id: trimmedSearchQuery) { |
| await refreshSearchResults() |
| } |
| .task(id: insightInputSignature) { |
| await refreshProactiveInsights() |
| } |
| .sheet(isPresented: $isPresentingCapture) { |
| CaptureKnowledgeSheet { kind, title, content, tags in |
| session.captureKnowledge( |
| kind: kind, |
| title: title, |
| content: content, |
| tags: tags |
| ) |
| } |
| } |
| .sheet(isPresented: $isPresentingHandoff) { |
| CreateHandoffSheet { title, focus in |
| session.createHandoff(title: title, focus: focus) |
| } |
| } |
| .alert(item: $feedback) { feedback in |
| Alert( |
| title: Text(feedback.title), |
| message: Text(feedback.message), |
| dismissButton: .default(Text("OK")) |
| ) |
| } |
| } |
| } |
|
|
| @ViewBuilder |
| private var selectedContent: some View { |
| switch selection { |
| case .knowledge: |
| KnowledgeOverview( |
| session: session, |
| items: matchingKnowledge, |
| insights: proactiveInsights, |
| isFiltering: !trimmedSearchQuery.isEmpty, |
| capture: { isPresentingCapture = true } |
| ) |
| case .handoffs: |
| HandoffOverview( |
| session: session, |
| create: { isPresentingHandoff = true }, |
| restore: restoreHandoff, |
| deactivate: { session.deactivateHandoff() } |
| ) |
| case .tasks: |
| TaskOverview(session: session) |
| } |
| } |
|
|
| private var trimmedSearchQuery: String { |
| searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) |
| } |
|
|
| private var matchingKnowledge: [KnowledgeItem] { |
| if trimmedSearchQuery.isEmpty { |
| return session.workspace.knowledgeItems.sorted { lhs, rhs in |
| if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } |
| return lhs.id.uuidString < rhs.id.uuidString |
| } |
| } |
| return searchResults |
| } |
|
|
| private var activeHandoff: SessionHandoff? { |
| guard let activeID = session.workspace.activeHandoffID else { return nil } |
| return session.workspace.handoffs.first { $0.id == activeID } |
| } |
|
|
| private var insightInputSignature: String { |
| let completedTaskCount = session.workspace.tasks.filter(\.isCompleted).count |
| return [ |
| String(session.workspace.knowledgeItems.count), |
| String(session.workspace.tasks.count), |
| String(completedTaskCount), |
| session.workspace.activeHandoffID?.uuidString ?? "none", |
| session.workspace.handoffs.last?.restoredAt?.description ?? "never", |
| ].joined(separator: "|") |
| } |
|
|
| private func refreshSearchResults() async { |
| let query = trimmedSearchQuery |
| guard !query.isEmpty else { |
| searchResults = [] |
| return |
| } |
| searchResults = [] |
| do { |
| try await Task.sleep(for: .milliseconds(220)) |
| } catch { |
| return |
| } |
| let corpus = KnowledgeEngine.searchableKnowledge( |
| typedItems: session.workspace.knowledgeItems, |
| legacyMemories: session.workspace.memories |
| ) |
| let results = await Task.detached(priority: .userInitiated) { |
| KnowledgeEngine().search( |
| query: query, |
| in: corpus, |
| limit: KnowledgeEngine.Limits.maxSearchResults |
| ) |
| }.value |
| guard !Task.isCancelled, query == trimmedSearchQuery else { return } |
| searchResults = results |
| } |
|
|
| private func refreshProactiveInsights() async { |
| let knowledge = session.workspace.knowledgeItems |
| let tasks = session.workspace.tasks |
| let handoff = activeHandoff |
| let insights = await Task.detached(priority: .utility) { |
| KnowledgeEngine().deriveProactiveInsights( |
| knowledge: knowledge, |
| tasks: tasks, |
| handoff: handoff |
| ) |
| }.value |
| guard !Task.isCancelled else { return } |
| proactiveInsights = insights |
| } |
|
|
| private func restoreHandoff(_ handoff: SessionHandoff) { |
| guard session.restoreHandoff(id: handoff.id) else { |
| feedback = ContinuityFeedback( |
| title: "Handoff Not Restored", |
| message: "Dolphin could not restore this handoff. Try again when the current run is finished." |
| ) |
| return |
| } |
|
|
| UIAccessibility.post( |
| notification: .announcement, |
| argument: "Restored \(handoff.title)" |
| ) |
| } |
| } |
|
|
| private enum ContinuitySection: Hashable { |
| case knowledge |
| case handoffs |
| case tasks |
| } |
|
|
| private struct ContinuityFeedback: Identifiable { |
| let id = UUID() |
| let title: String |
| let message: String |
| } |
|
|
| private struct ContinuityDeletionTarget: Identifiable { |
| enum Kind { |
| case knowledge |
| case memory |
| case handoff |
| case task |
| } |
|
|
| let id = UUID() |
| let entityID: UUID |
| let kind: Kind |
| let title: String |
| let message: String |
| } |
|
|
| private struct KnowledgeOverview: View { |
| @Bindable var session: AssistantSession |
| let items: [KnowledgeItem] |
| let insights: [ProactiveKnowledgeInsight] |
| let isFiltering: Bool |
| let capture: () -> Void |
| @State private var pendingDeletion: ContinuityDeletionTarget? |
|
|
| var body: some View { |
| if hasNoContent { |
| ContentUnavailableView { |
| Label("No saved knowledge", systemImage: "books.vertical") |
| } description: { |
| Text("Capture a decision, preference, goal, or useful context for future conversations.") |
| } actions: { |
| Button("Capture Knowledge", action: capture) |
| .disabled(!session.isWorkspaceReady || session.isRunning) |
| } |
| } else if isFiltering, items.isEmpty { |
| ContentUnavailableView.search |
| } else { |
| List { |
| if !isFiltering { |
| Section { |
| KnowledgeSummaryCard( |
| knowledgeCount: session.workspace.knowledgeItems.count, |
| handoffCount: session.workspace.handoffs.count, |
| openTaskCount: session.workspace.tasks.filter { !$0.isCompleted }.count |
| ) |
| } |
| .listRowBackground(Color.clear) |
| .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) |
|
|
| if !insights.isEmpty { |
| Section("Proactive insights") { |
| ForEach(insights) { insight in |
| ProactiveInsightRow(insight: insight) |
| } |
| } |
| } |
|
|
| if !session.workspace.knowledgeDigests.isEmpty { |
| Section("Synthesis") { |
| ForEach(sortedDigests) { digest in |
| KnowledgeDigestRow(digest: digest) |
| } |
| } |
| } |
| } |
|
|
| if !items.isEmpty { |
| Section { |
| ForEach(items) { item in |
| KnowledgeItemRow(item: item) |
| .swipeActions { |
| Button("Delete", systemImage: "trash", role: .destructive) { |
| pendingDeletion = deletionTarget(for: item) |
| } |
| .disabled(!session.isWorkspaceReady || session.isRunning) |
| } |
| } |
| } header: { |
| Text(isFiltering ? "Search results" : "Structured knowledge") |
| } footer: { |
| if isFiltering { |
| Text("Results use deterministic local hybrid lexical ranking, not embedding or semantic search.") |
| } |
| } |
| } |
|
|
| if !isFiltering, !session.workspace.memories.isEmpty { |
| Section { |
| ForEach(session.workspace.memories.reversed()) { memory in |
| LegacyMemoryRow(memory: memory) |
| .swipeActions { |
| Button("Delete", systemImage: "trash", role: .destructive) { |
| pendingDeletion = memoryDeletionTarget(memory) |
| } |
| .disabled(!session.isWorkspaceReady || session.isRunning) |
| } |
| } |
| } header: { |
| Text("Legacy memories") |
| } footer: { |
| Text("Existing memories remain available to Dolphin and can be removed here.") |
| } |
| } |
| } |
| .listStyle(.insetGrouped) |
| .alert(item: $pendingDeletion) { target in |
| Alert( |
| title: Text(target.title), |
| message: Text(target.message), |
| primaryButton: .destructive(Text("Delete")) { |
| performDeletion(target) |
| }, |
| secondaryButton: .cancel() |
| ) |
| } |
| } |
| } |
|
|
| private var hasNoContent: Bool { |
| !isFiltering |
| && session.workspace.knowledgeItems.isEmpty |
| && session.workspace.memories.isEmpty |
| && session.workspace.knowledgeDigests.isEmpty |
| && session.workspace.handoffs.isEmpty |
| && session.workspace.tasks.isEmpty |
| && insights.isEmpty |
| } |
|
|
| private var sortedDigests: [KnowledgeDigest] { |
| session.workspace.knowledgeDigests.sorted { lhs, rhs in |
| if lhs.createdAt != rhs.createdAt { return lhs.createdAt > rhs.createdAt } |
| return lhs.id.uuidString < rhs.id.uuidString |
| } |
| } |
|
|
| private func deletionTarget( |
| for item: KnowledgeItem |
| ) -> ContinuityDeletionTarget { |
| let isTyped = session.workspace.knowledgeItems.contains { $0.id == item.id } |
| if !isTyped, |
| let memory = session.workspace.memories.first(where: { $0.id == item.id }) |
| { |
| return memoryDeletionTarget(memory) |
| } |
| let cascade = KnowledgeEngine().deletionCascade( |
| deletingKnowledgeIDs: [item.id], |
| handoffs: session.workspace.handoffs, |
| digests: session.workspace.knowledgeDigests |
| ) |
| let provenanceNote = cascade.removesAllDigests |
| ? " All synthesis snapshots are included because schema v5 does not record which handoff focus each snapshot copied." |
| : "" |
| return ContinuityDeletionTarget( |
| entityID: item.id, |
| kind: .knowledge, |
| title: "Delete \(item.title)?", |
| message: "This removes the live record, \(cascade.handoffIDs.count) linked handoff(s), and \(cascade.digestIDs.count) synthesis snapshot(s).\(provenanceNote) A mirrored legacy memory with the same ID is also removed. Original chat and Activity receipts remain until you clear the conversation." |
| ) |
| } |
|
|
| private func memoryDeletionTarget( |
| _ memory: MemoryItem |
| ) -> ContinuityDeletionTarget { |
| ContinuityDeletionTarget( |
| entityID: memory.id, |
| kind: .memory, |
| title: "Delete this saved memory?", |
| message: "This removes the legacy memory, its typed mirror, and derived handoffs or synthesis snapshots that reference the mirror. If a linked handoff is removed, all synthesis snapshots are included because schema v5 does not record handoff provenance. Original chat and Activity receipts remain until you clear the conversation." |
| ) |
| } |
|
|
| private func performDeletion(_ target: ContinuityDeletionTarget) { |
| switch target.kind { |
| case .knowledge: |
| session.deleteKnowledge(id: target.entityID) |
| case .memory: |
| session.deleteMemory(id: target.entityID) |
| case .handoff, .task: |
| break |
| } |
| } |
| } |
|
|
| private struct KnowledgeSummaryCard: View { |
| let knowledgeCount: Int |
| let handoffCount: Int |
| let openTaskCount: Int |
|
|
| var body: some View { |
| VStack(alignment: .leading, spacing: 12) { |
| Label("Local continuity", systemImage: "lock.shield") |
| .font(.headline) |
|
|
| Text("Saved context stays in Dolphin’s protected app container and helps it continue without pretending a cloud action occurred.") |
| .font(.subheadline) |
| .foregroundStyle(.secondary) |
|
|
| HStack(spacing: 10) { |
| SummaryMetric(value: knowledgeCount, label: "Items") |
| SummaryMetric(value: handoffCount, label: "Handoffs") |
| SummaryMetric(value: openTaskCount, label: "Open tasks") |
| } |
| } |
| .padding(16) |
| .frame(maxWidth: .infinity, alignment: .leading) |
| .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| .accessibilityElement(children: .combine) |
| } |
| } |
|
|
| private struct SummaryMetric: View { |
| let value: Int |
| let label: String |
|
|
| var body: some View { |
| VStack(alignment: .leading, spacing: 2) { |
| Text(value, format: .number) |
| .font(.headline.monospacedDigit()) |
| Text(label) |
| .font(.caption) |
| .foregroundStyle(.secondary) |
| .lineLimit(1) |
| .minimumScaleFactor(0.8) |
| } |
| .frame(maxWidth: .infinity, alignment: .leading) |
| } |
| } |
|
|
| private struct KnowledgeItemRow: View { |
| let item: KnowledgeItem |
|
|
| var body: some View { |
| VStack(alignment: .leading, spacing: 8) { |
| HStack(alignment: .firstTextBaseline, spacing: 8) { |
| Label(item.kind.label, systemImage: item.kind.symbolName) |
| .font(.caption.weight(.semibold)) |
| .foregroundStyle(item.kind.tint) |
|
|
| Spacer(minLength: 8) |
| } |
|
|
| Text(item.title) |
| .font(.headline) |
| .textSelection(.enabled) |
|
|
| if item.content != item.title { |
| Text(item.content) |
| .font(.subheadline) |
| .foregroundStyle(.secondary) |
| .lineLimit(5) |
| .textSelection(.enabled) |
| } |
|
|
| if !item.tags.isEmpty { |
| ScrollView(.horizontal, showsIndicators: false) { |
| HStack(spacing: 6) { |
| ForEach(item.tags, id: \.self) { tag in |
| Text(tag) |
| .font(.caption2.weight(.medium)) |
| .padding(.horizontal, 8) |
| .padding(.vertical, 4) |
| .background(Color.secondary.opacity(0.12), in: Capsule()) |
| } |
| } |
| } |
| .accessibilityElement(children: .combine) |
| .accessibilityLabel("Tags: \(item.tags.joined(separator: ", "))") |
| } |
|
|
| HStack(spacing: 6) { |
| Text(item.source.label) |
| Text("·") |
| Text(item.updatedAt, format: .dateTime.month().day().year().hour().minute()) |
| } |
| .font(.caption) |
| .foregroundStyle(.tertiary) |
| } |
| .padding(.vertical, 5) |
| .accessibilityElement(children: .combine) |
| .accessibilityHint("Swipe left to delete this knowledge item.") |
| } |
| } |
|
|
| private struct LegacyMemoryRow: View { |
| let memory: MemoryItem |
|
|
| var body: some View { |
| VStack(alignment: .leading, spacing: 6) { |
| Label("Memory", systemImage: "brain.head.profile") |
| .font(.caption.weight(.semibold)) |
| .foregroundStyle(.secondary) |
| Text(memory.content) |
| .textSelection(.enabled) |
| Text(memory.createdAt, format: .dateTime.month().day().year().hour().minute()) |
| .font(.caption) |
| .foregroundStyle(.tertiary) |
| } |
| .padding(.vertical, 4) |
| .accessibilityElement(children: .combine) |
| } |
| } |
|
|
| private struct KnowledgeDigestRow: View { |
| let digest: KnowledgeDigest |
| @State private var isExpanded = false |
|
|
| var body: some View { |
| DisclosureGroup(isExpanded: $isExpanded) { |
| VStack(alignment: .leading, spacing: 10) { |
| Text(digest.summary) |
| .font(.subheadline) |
| .foregroundStyle(.secondary) |
| .textSelection(.enabled) |
|
|
| if !digest.priorities.isEmpty { |
| DigestList(title: "Priorities", items: digest.priorities) |
| } |
|
|
| if !digest.openQuestions.isEmpty { |
| DigestList(title: "Open questions", items: digest.openQuestions) |
| } |
| } |
| .padding(.top, 8) |
| } label: { |
| VStack(alignment: .leading, spacing: 4) { |
| Label("Context synthesis", systemImage: "sparkles") |
| .font(.subheadline.weight(.semibold)) |
| Text("\(digest.trigger) · \(digest.createdAt.formatted(date: .abbreviated, time: .shortened))") |
| .font(.caption) |
| .foregroundStyle(.secondary) |
| } |
| } |
| .padding(.vertical, 3) |
| } |
| } |
|
|
| private struct DigestList: View { |
| let title: String |
| let items: [String] |
|
|
| var body: some View { |
| VStack(alignment: .leading, spacing: 5) { |
| Text(title) |
| .font(.caption.weight(.semibold)) |
| ForEach(Array(items.enumerated()), id: \.offset) { _, item in |
| Label(item, systemImage: "circle.fill") |
| .font(.caption) |
| .labelStyle(BulletLabelStyle()) |
| } |
| } |
| } |
| } |
|
|
| private struct BulletLabelStyle: LabelStyle { |
| func makeBody(configuration: Configuration) -> some View { |
| HStack(alignment: .firstTextBaseline, spacing: 7) { |
| configuration.icon |
| .font(.system(size: 5)) |
| .foregroundStyle(.secondary) |
| configuration.title |
| } |
| } |
| } |
|
|
| private struct ProactiveInsightRow: View { |
| let insight: ProactiveKnowledgeInsight |
|
|
| var body: some View { |
| HStack(alignment: .top, spacing: 11) { |
| Image(systemName: insight.priority.symbolName) |
| .foregroundStyle(insight.priority.tint) |
| .frame(width: 24) |
| .accessibilityHidden(true) |
|
|
| VStack(alignment: .leading, spacing: 4) { |
| HStack(alignment: .firstTextBaseline) { |
| Text(insight.title) |
| .font(.subheadline.weight(.semibold)) |
| Spacer(minLength: 8) |
| Text(insight.priority.label) |
| .font(.caption2.weight(.semibold)) |
| .foregroundStyle(insight.priority.tint) |
| } |
| Text(insight.detail) |
| .font(.caption) |
| .foregroundStyle(.secondary) |
| } |
| } |
| .padding(.vertical, 3) |
| .accessibilityElement(children: .combine) |
| } |
| } |
|
|
| private struct HandoffOverview: View { |
| @Bindable var session: AssistantSession |
| let create: () -> Void |
| let restore: (SessionHandoff) -> Void |
| let deactivate: () -> Bool |
| @State private var pendingDeletion: ContinuityDeletionTarget? |
|
|
| var body: some View { |
| if sortedHandoffs.isEmpty { |
| ContentUnavailableView { |
| Label("No session handoffs", systemImage: "arrow.triangle.branch") |
| } description: { |
| Text("Create a local checkpoint with current context and open tasks, then restore it in a later session.") |
| } actions: { |
| Button("Create Handoff", action: create) |
| .disabled(!session.isWorkspaceReady || session.isRunning) |
| } |
| } else { |
| List { |
| if let activeHandoff { |
| Section("Active handoff") { |
| ActiveHandoffBanner( |
| handoff: activeHandoff, |
| isEnabled: session.isWorkspaceReady && !session.isRunning, |
| deactivate: deactivate |
| ) |
| } |
| } |
|
|
| Section { |
| ForEach(sortedHandoffs) { handoff in |
| HandoffRow( |
| handoff: handoff, |
| isActive: handoff.id == session.workspace.activeHandoffID, |
| isEnabled: session.isWorkspaceReady && !session.isRunning, |
| restore: { restore(handoff) } |
| ) |
| .swipeActions { |
| Button("Delete", systemImage: "trash", role: .destructive) { |
| pendingDeletion = ContinuityDeletionTarget( |
| entityID: handoff.id, |
| kind: .handoff, |
| title: "Delete \(handoff.title)?", |
| message: "This removes the checkpoint and all synthesis snapshots that might contain its focus. Original chat and Activity receipts remain until you clear the conversation." |
| ) |
| } |
| .disabled(!session.isWorkspaceReady || session.isRunning) |
| } |
| } |
| } header: { |
| Text("Saved handoffs") |
| } footer: { |
| Text("Restoring marks a handoff as the active local context. It does not contact a cloud service or alter external data.") |
| } |
| } |
| .listStyle(.insetGrouped) |
| .alert(item: $pendingDeletion) { target in |
| Alert( |
| title: Text(target.title), |
| message: Text(target.message), |
| primaryButton: .destructive(Text("Delete")) { |
| session.deleteHandoff(id: target.entityID) |
| }, |
| secondaryButton: .cancel() |
| ) |
| } |
| } |
| } |
|
|
| private var sortedHandoffs: [SessionHandoff] { |
| session.workspace.handoffs.sorted { lhs, rhs in |
| if lhs.createdAt != rhs.createdAt { return lhs.createdAt > rhs.createdAt } |
| return lhs.id.uuidString < rhs.id.uuidString |
| } |
| } |
|
|
| private var activeHandoff: SessionHandoff? { |
| guard let activeID = session.workspace.activeHandoffID else { return nil } |
| return session.workspace.handoffs.first { $0.id == activeID } |
| } |
| } |
|
|
| private struct ActiveHandoffBanner: View { |
| let handoff: SessionHandoff |
| let isEnabled: Bool |
| let deactivate: () -> Bool |
|
|
| var body: some View { |
| HStack(alignment: .top, spacing: 12) { |
| Image(systemName: "checkmark.seal.fill") |
| .foregroundStyle(.green) |
| .font(.title3) |
| .accessibilityHidden(true) |
| VStack(alignment: .leading, spacing: 4) { |
| Text(handoff.title) |
| .font(.headline) |
| if !handoff.focus.isEmpty { |
| Text(handoff.focus) |
| .font(.subheadline) |
| .foregroundStyle(.secondary) |
| .lineLimit(3) |
| } |
| Button("Deactivate", systemImage: "pause.circle") { |
| if deactivate() { |
| UIAccessibility.post( |
| notification: .announcement, |
| argument: "Handoff deactivated" |
| ) |
| } |
| } |
| .buttonStyle(.bordered) |
| .controlSize(.small) |
| .disabled(!isEnabled) |
| .accessibilityHint("Keeps this checkpoint saved but stops adding it to new requests.") |
| } |
| } |
| .padding(.vertical, 4) |
| .accessibilityElement(children: .contain) |
| .accessibilityValue("Active") |
| } |
| } |
|
|
| private struct HandoffRow: View { |
| let handoff: SessionHandoff |
| let isActive: Bool |
| let isEnabled: Bool |
| let restore: () -> Void |
| @State private var isExpanded = false |
|
|
| var body: some View { |
| VStack(alignment: .leading, spacing: 9) { |
| HStack(alignment: .firstTextBaseline, spacing: 8) { |
| Text(handoff.title) |
| .font(.headline) |
|
|
| Spacer(minLength: 8) |
|
|
| if isActive { |
| Label("Active", systemImage: "checkmark.circle.fill") |
| .font(.caption.weight(.semibold)) |
| .foregroundStyle(.green) |
| } |
| } |
|
|
| if !handoff.focus.isEmpty { |
| Text(handoff.focus) |
| .font(.subheadline) |
| .foregroundStyle(.secondary) |
| .lineLimit(isExpanded ? nil : 3) |
| .textSelection(.enabled) |
| } |
|
|
| if isExpanded { |
| if !handoff.summary.isEmpty { |
| Text(handoff.summary) |
| .font(.caption) |
| .foregroundStyle(.secondary) |
| .textSelection(.enabled) |
| } |
|
|
| if !handoff.nextSteps.isEmpty { |
| DigestList(title: "Next steps", items: handoff.nextSteps) |
| } |
| } |
|
|
| HStack(spacing: 7) { |
| Text(handoff.createdAt, format: .dateTime.month().day().year().hour().minute()) |
| Text("·") |
| Text("\(handoff.knowledgeItemIDs.count) items") |
| Text("·") |
| Text("\(handoff.taskIDs.count) tasks") |
| } |
| .font(.caption2) |
| .foregroundStyle(.tertiary) |
|
|
| HStack { |
| Button(isExpanded ? "Show Less" : "Details") { |
| withAnimation(.easeInOut(duration: 0.18)) { |
| isExpanded.toggle() |
| } |
| } |
| .buttonStyle(.borderless) |
|
|
| Spacer() |
|
|
| if !isActive { |
| Button("Restore", systemImage: "arrow.counterclockwise", action: restore) |
| .buttonStyle(.bordered) |
| .controlSize(.small) |
| .disabled(!isEnabled) |
| } |
| } |
| } |
| .padding(.vertical, 5) |
| } |
| } |
|
|
| private struct TaskOverview: View { |
| @Bindable var session: AssistantSession |
| @State private var pendingDeletion: ContinuityDeletionTarget? |
|
|
| var body: some View { |
| if sortedTasks.isEmpty { |
| ContentUnavailableView( |
| "No tasks", |
| systemImage: "checklist", |
| description: Text("Ask Dolphin to add a task. Completed tasks remain visible until deleted.") |
| ) |
| } else { |
| List { |
| Section { |
| ForEach(sortedTasks) { task in |
| Button { |
| toggleCompletion(of: task) |
| } label: { |
| HStack(alignment: .top, spacing: 12) { |
| Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") |
| .foregroundStyle(task.isCompleted ? Color.green : Color.secondary) |
| .accessibilityHidden(true) |
|
|
| VStack(alignment: .leading, spacing: 5) { |
| Text(task.title) |
| .strikethrough(task.isCompleted) |
| .foregroundStyle(task.isCompleted ? Color.secondary : Color.primary) |
| Text(task.createdAt, format: .dateTime.month().day().year().hour().minute()) |
| .font(.caption) |
| .foregroundStyle(.secondary) |
| } |
|
|
| Spacer(minLength: 8) |
|
|
| Text(task.isCompleted ? "Reopen" : "Complete") |
| .font(.caption.weight(.semibold)) |
| .foregroundStyle(.tint) |
| } |
| } |
| .buttonStyle(.plain) |
| .contentShape(Rectangle()) |
| .padding(.vertical, 4) |
| .disabled(!session.isWorkspaceReady || session.isRunning) |
| .accessibilityLabel(task.title) |
| .accessibilityValue(task.isCompleted ? "Completed" : "Incomplete") |
| .accessibilityHint( |
| task.isCompleted |
| ? "Double tap to reopen this task." |
| : "Double tap to mark this task complete." |
| ) |
| .swipeActions { |
| Button("Delete", systemImage: "trash", role: .destructive) { |
| let handoffCount = session.workspace.handoffs.filter { |
| $0.taskIDs.contains(task.id) |
| }.count |
| pendingDeletion = ContinuityDeletionTarget( |
| entityID: task.id, |
| kind: .task, |
| title: "Delete \(task.title)?", |
| message: "This removes the task, \(handoffCount) linked handoff(s), and all synthesis snapshots that might contain its title. Original chat and Activity receipts remain until you clear the conversation." |
| ) |
| } |
| .disabled(!session.isWorkspaceReady || session.isRunning) |
| } |
| } |
| } header: { |
| Text("\(openTaskCount) open · \(sortedTasks.count) total") |
| } |
| } |
| .listStyle(.insetGrouped) |
| .alert(item: $pendingDeletion) { target in |
| Alert( |
| title: Text(target.title), |
| message: Text(target.message), |
| primaryButton: .destructive(Text("Delete")) { |
| session.deleteTask(id: target.entityID) |
| }, |
| secondaryButton: .cancel() |
| ) |
| } |
| } |
| } |
|
|
| private var sortedTasks: [AssistantTaskItem] { |
| session.workspace.tasks.sorted { lhs, rhs in |
| if lhs.isCompleted != rhs.isCompleted { return !lhs.isCompleted } |
| if lhs.createdAt != rhs.createdAt { return lhs.createdAt > rhs.createdAt } |
| return lhs.id.uuidString < rhs.id.uuidString |
| } |
| } |
|
|
| private var openTaskCount: Int { |
| sortedTasks.filter { !$0.isCompleted }.count |
| } |
|
|
| private func toggleCompletion(of task: AssistantTaskItem) { |
| let newValue = !task.isCompleted |
| guard session.setTaskCompletion(id: task.id, isCompleted: newValue) else { |
| return |
| } |
| UIAccessibility.post( |
| notification: .announcement, |
| argument: newValue |
| ? "\(task.title), completed" |
| : "\(task.title), reopened" |
| ) |
| } |
| } |
|
|
| private struct CaptureKnowledgeSheet: View { |
| @Environment(\.dismiss) private var dismiss |
| @State private var kind: KnowledgeKind = .context |
| @State private var title = "" |
| @State private var content = "" |
| @State private var tags = "" |
| @State private var isShowingFailure = false |
| @FocusState private var focusedField: Field? |
|
|
| let save: (KnowledgeKind, String, String, [String]) -> Bool |
|
|
| var body: some View { |
| NavigationStack { |
| Form { |
| Section("Classification") { |
| Picker("Kind", selection: $kind) { |
| ForEach(KnowledgeKind.allCases, id: \.self) { kind in |
| Label(kind.label, systemImage: kind.symbolName) |
| .tag(kind) |
| } |
| } |
|
|
| TextField("Title (optional)", text: $title) |
| .textInputAutocapitalization(.sentences) |
| .focused($focusedField, equals: .title) |
|
|
| Text("\(trimmedTitle.count)/\(KnowledgeEngine.Limits.maxItemTitleCharacters)") |
| .font(.caption.monospacedDigit()) |
| .foregroundStyle(titleIsValid ? Color.secondary : Color.red) |
| .frame(maxWidth: .infinity, alignment: .trailing) |
| } |
|
|
| Section("Knowledge") { |
| TextEditor(text: $content) |
| .frame(minHeight: 150) |
| .focused($focusedField, equals: .content) |
| .accessibilityLabel("Knowledge content") |
|
|
| Text("\(trimmedContent.count)/\(KnowledgeEngine.Limits.maxItemContentCharacters)") |
| .font(.caption.monospacedDigit()) |
| .foregroundStyle(contentIsValid ? Color.secondary : Color.red) |
| .frame(maxWidth: .infinity, alignment: .trailing) |
| } |
|
|
| Section { |
| TextField("project, decision, iOS", text: $tags) |
| .textInputAutocapitalization(.never) |
| .autocorrectionDisabled() |
| .focused($focusedField, equals: .tags) |
| } header: { |
| Text("Tags") |
| } footer: { |
| Text("Separate tags with commas. Up to \(KnowledgeEngine.Limits.maxTagCount) tags and \(KnowledgeEngine.Limits.maxTagCharacters) characters per tag. Current: \(parsedTags.count). This typed entry is saved locally for deterministic retrieval.") |
| .foregroundStyle(tagsAreValid ? Color.secondary : Color.red) |
| } |
| } |
| .navigationTitle("Capture Knowledge") |
| .navigationBarTitleDisplayMode(.inline) |
| .toolbar { |
| ToolbarItem(placement: .cancellationAction) { |
| Button("Cancel") { dismiss() } |
| } |
| ToolbarItem(placement: .confirmationAction) { |
| Button("Save") { submit() } |
| .disabled(!isValid) |
| } |
| } |
| .alert("Knowledge Not Saved", isPresented: $isShowingFailure) { |
| Button("OK", role: .cancel) {} |
| } message: { |
| Text("Dolphin could not save this item. Check local storage and try again after the current run finishes.") |
| } |
| .onAppear { |
| focusedField = .title |
| } |
| } |
| } |
|
|
| private var trimmedContent: String { |
| content.trimmingCharacters(in: .whitespacesAndNewlines) |
| } |
|
|
| private var trimmedTitle: String { |
| title.trimmingCharacters(in: .whitespacesAndNewlines) |
| } |
|
|
| private var parsedTags: [String] { |
| tags.split(separator: ",", omittingEmptySubsequences: true).map { tag in |
| tag.trimmingCharacters(in: .whitespacesAndNewlines) |
| } |
| .filter { !$0.isEmpty } |
| } |
|
|
| private var titleIsValid: Bool { |
| trimmedTitle.count <= KnowledgeEngine.Limits.maxItemTitleCharacters |
| } |
|
|
| private var contentIsValid: Bool { |
| !trimmedContent.isEmpty |
| && trimmedContent.count <= KnowledgeEngine.Limits.maxItemContentCharacters |
| } |
|
|
| private var tagsAreValid: Bool { |
| parsedTags.count <= KnowledgeEngine.Limits.maxTagCount |
| && parsedTags.allSatisfy { |
| $0.count <= KnowledgeEngine.Limits.maxTagCharacters |
| } |
| } |
|
|
| private var isValid: Bool { |
| titleIsValid && contentIsValid && tagsAreValid |
| } |
|
|
| private func submit() { |
| if save(kind, title, trimmedContent, parsedTags) { |
| UIAccessibility.post(notification: .announcement, argument: "Knowledge saved") |
| dismiss() |
| } else { |
| isShowingFailure = true |
| } |
| } |
|
|
| private enum Field: Hashable { |
| case title |
| case content |
| case tags |
| } |
| } |
|
|
| private struct CreateHandoffSheet: View { |
| @Environment(\.dismiss) private var dismiss |
| @State private var title = "Session handoff" |
| @State private var focus = "" |
| @State private var isShowingFailure = false |
| @FocusState private var focusedField: Field? |
|
|
| let save: (String, String) -> Bool |
|
|
| var body: some View { |
| NavigationStack { |
| Form { |
| Section("Checkpoint") { |
| TextField("Title", text: $title) |
| .textInputAutocapitalization(.sentences) |
| .focused($focusedField, equals: .title) |
|
|
| Text("\(trimmedTitle.count)/\(KnowledgeEngine.Limits.maxItemTitleCharacters)") |
| .font(.caption.monospacedDigit()) |
| .foregroundStyle(titleIsValid ? Color.secondary : Color.red) |
| .frame(maxWidth: .infinity, alignment: .trailing) |
|
|
| TextEditor(text: $focus) |
| .frame(minHeight: 150) |
| .focused($focusedField, equals: .focus) |
| .accessibilityLabel("Current focus") |
|
|
| Text("\(trimmedFocus.count)/\(KnowledgeEngine.Limits.maxHandoffFocusCharacters)") |
| .font(.caption.monospacedDigit()) |
| .foregroundStyle(focusIsValid ? Color.secondary : Color.red) |
| .frame(maxWidth: .infinity, alignment: .trailing) |
| } |
|
|
| Section { |
| Label("Structured knowledge", systemImage: "books.vertical") |
| Label("Open tasks", systemImage: "checklist") |
| Label("Recent local conversation context", systemImage: "bubble.left.and.bubble.right") |
| } header: { |
| Text("Included in the snapshot") |
| } footer: { |
| Text("The handoff is a local continuity checkpoint. It does not synchronize with a team or external service.") |
| } |
| } |
| .navigationTitle("Create Handoff") |
| .navigationBarTitleDisplayMode(.inline) |
| .toolbar { |
| ToolbarItem(placement: .cancellationAction) { |
| Button("Cancel") { dismiss() } |
| } |
| ToolbarItem(placement: .confirmationAction) { |
| Button("Create") { submit() } |
| .disabled(!isValid) |
| } |
| } |
| .alert("Handoff Not Created", isPresented: $isShowingFailure) { |
| Button("OK", role: .cancel) {} |
| } message: { |
| Text("Dolphin could not create this checkpoint. Check local storage and try again after the current run finishes.") |
| } |
| .onAppear { |
| focusedField = .focus |
| } |
| } |
| } |
|
|
| private var trimmedFocus: String { |
| focus.trimmingCharacters(in: .whitespacesAndNewlines) |
| } |
|
|
| private var trimmedTitle: String { |
| title.trimmingCharacters(in: .whitespacesAndNewlines) |
| } |
|
|
| private var titleIsValid: Bool { |
| !trimmedTitle.isEmpty |
| && trimmedTitle.count <= KnowledgeEngine.Limits.maxItemTitleCharacters |
| } |
|
|
| private var focusIsValid: Bool { |
| !trimmedFocus.isEmpty |
| && trimmedFocus.count <= KnowledgeEngine.Limits.maxHandoffFocusCharacters |
| } |
|
|
| private var isValid: Bool { |
| titleIsValid && focusIsValid |
| } |
|
|
| private func submit() { |
| if save(trimmedTitle, trimmedFocus) { |
| UIAccessibility.post(notification: .announcement, argument: "Session handoff created") |
| dismiss() |
| } else { |
| isShowingFailure = true |
| } |
| } |
|
|
| private enum Field: Hashable { |
| case title |
| case focus |
| } |
| } |
|
|
| private extension KnowledgeKind { |
| var label: String { |
| switch self { |
| case .fact: "Fact" |
| case .preference: "Preference" |
| case .decision: "Decision" |
| case .goal: "Goal" |
| case .code: "Code" |
| case .context: "Context" |
| case .insight: "Insight" |
| } |
| } |
|
|
| var symbolName: String { |
| switch self { |
| case .fact: "checkmark.circle" |
| case .preference: "slider.horizontal.3" |
| case .decision: "arrow.triangle.branch" |
| case .goal: "target" |
| case .code: "chevron.left.forwardslash.chevron.right" |
| case .context: "doc.text.magnifyingglass" |
| case .insight: "lightbulb" |
| } |
| } |
|
|
| var tint: Color { |
| switch self { |
| case .fact: .blue |
| case .preference: .purple |
| case .decision: .orange |
| case .goal: .green |
| case .code: .indigo |
| case .context: .teal |
| case .insight: .yellow |
| } |
| } |
| } |
|
|
| private extension KnowledgeSource { |
| var label: String { |
| switch self { |
| case .user: "User" |
| case .assistant: "Dolphin" |
| case .synthesis: "Synthesis" |
| case .import: "Import" |
| } |
| } |
| } |
|
|
| private extension KnowledgeInsightPriority { |
| var label: String { |
| rawValue.capitalized |
| } |
|
|
| var symbolName: String { |
| switch self { |
| case .high: "exclamationmark.triangle.fill" |
| case .medium: "lightbulb.fill" |
| case .low: "info.circle.fill" |
| } |
| } |
|
|
| var tint: Color { |
| switch self { |
| case .high: .red |
| case .medium: .orange |
| case .low: .blue |
| } |
| } |
| } |
|
|