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