ales27pm's picture
Add autonomous on-device iOS assistant
7b2dfc5 verified
Raw
History Blame Contribute Delete
37.7 kB
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
}
}
}