// // HistoryView.swift // Zyquo Atlas // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Full-text history (⌘Y): grouped by day, searchable, with delete controls and // "ask AI about my history" — the model receives only titles/hosts (never page // content), on explicit user action, via the shared provider layer. // import SwiftUI struct HistoryView: View { let onOpen: (String) -> Void @EnvironmentObject private var env: AppEnvironment @EnvironmentObject private var themeEngine: ThemeEngine @Environment(\.dismiss) private var dismiss private var theme: AtlasTheme { themeEngine.theme } @State private var query = "" @State private var askText = "" @StateObject private var ai = AIService() var body: some View { VStack(spacing: 0) { header Divider().overlay(theme.border) askBar Divider().overlay(theme.border) list } .frame(width: 660, height: 620) .background(theme.backgroundColor) } private var header: some View { HStack { Label("History", systemImage: "clock").font(ZyquoFont.title).foregroundStyle(theme.textPrimary) Spacer() searchField Menu { Button("Clear last hour") { env.history.deleteRange(since: Date().addingTimeInterval(-3600)) } Button("Clear today") { env.history.deleteRange(since: Calendar.current.startOfDay(for: Date())) } Button("Clear all", role: .destructive) { env.history.clearAll() } } label: { Image(systemName: "trash") } .menuStyle(.borderlessButton).frame(width: 44).foregroundStyle(theme.textSecondary) Button("Done") { dismiss() }.foregroundStyle(theme.accent) } .padding(ZyquoSpacing.md) .background(theme.surface) } private var searchField: some View { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: "magnifyingglass").font(.system(size: 11)).foregroundStyle(theme.textTertiary) TextField("Search history", text: $query).textFieldStyle(.plain).font(ZyquoFont.control) .foregroundStyle(theme.textPrimary).frame(width: 180) } .padding(.horizontal, ZyquoSpacing.sm).frame(height: 28) .background(RoundedRectangle(cornerRadius: ZyquoRadius.small).fill(theme.surfaceSecondary)) } private var askBar: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: "sparkles").foregroundStyle(theme.accentIndigo) TextField("Ask AI about your history (e.g. “that article about maps I read last week”)", text: $askText) .textFieldStyle(.plain).font(ZyquoFont.body()) .foregroundStyle(theme.textPrimary) .onSubmit(runAsk) if ai.isStreaming { Button("Stop") { ai.cancel() }.font(ZyquoFont.caption).foregroundStyle(theme.danger) } else { Button("Ask", action: runAsk).font(ZyquoFont.caption).foregroundStyle(theme.accent) } } if !ai.output.isEmpty || ai.errorText != nil { ScrollView { Text(ai.errorText ?? ai.output) .font(ZyquoFont.body()).foregroundStyle(ai.errorText != nil ? theme.danger : theme.textPrimary) .frame(maxWidth: .infinity, alignment: .leading).textSelection(.enabled) } .frame(maxHeight: 120) } } .padding(ZyquoSpacing.md) } private var list: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { ForEach(env.history.grouped(query), id: \.day) { group in Section { ForEach(group.items) { entry in row(entry) Divider().overlay(theme.border) } } header: { Text(dayLabel(group.day)).font(ZyquoFont.caption).foregroundStyle(theme.textTertiary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, ZyquoSpacing.md).padding(.vertical, ZyquoSpacing.xs) .background(theme.backgroundColor) } } if env.history.entries.isEmpty { Text("No history yet").font(ZyquoFont.caption).foregroundStyle(theme.textTertiary) .frame(maxWidth: .infinity).padding(ZyquoSpacing.xxl) } } } } private func row(_ entry: HistoryEntry) -> some View { HStack(spacing: ZyquoSpacing.sm) { Image(systemName: "globe").font(.system(size: 11)).foregroundStyle(theme.textTertiary) VStack(alignment: .leading, spacing: 1) { Text(entry.title).font(ZyquoFont.body(size: 12.5)).lineLimit(1).foregroundStyle(theme.textPrimary) Text(entry.host).font(ZyquoFont.caption).lineLimit(1).foregroundStyle(theme.textTertiary) } Spacer() Text(HistoryService.relative(entry.lastVisit)).font(ZyquoFont.caption).foregroundStyle(theme.textTertiary) Button { env.history.delete(entry.id) } label: { Image(systemName: "xmark") } .buttonStyle(.plain).foregroundStyle(theme.textTertiary) } .padding(.horizontal, ZyquoSpacing.md).padding(.vertical, ZyquoSpacing.xs) .contentShape(Rectangle()) .onTapGesture { onOpen(entry.url); dismiss() } } private func runAsk() { let q = askText.trimmingCharacters(in: .whitespacesAndNewlines) guard !q.isEmpty, let model = env.model(for: .standard) else { return } guard let key = try? env.vault.apiKey(for: model.provider) else { askText = ""; return } let context = env.history.aiContext() let prompt = """ Here is a list of the user's recent browsing history (title — site — when). \ Answer the user's question by pointing to the most relevant items. If none \ match, say so. Do not invent entries. QUESTION: \(q) HISTORY: \(context) """ ai.runRawPrompt(system: "You help the user find pages in their browsing history.", userText: prompt, model: model, apiKey: key) } private func dayLabel(_ day: Date) -> String { let cal = Calendar.current if cal.isDateInToday(day) { return "Today" } if cal.isDateInYesterday(day) { return "Yesterday" } let f = DateFormatter(); f.dateStyle = .medium return f.string(from: day) } }