diff --git a/scarf/docs/ROADMAP.md b/scarf/docs/ROADMAP.md new file mode 100644 index 0000000..3318b47 --- /dev/null +++ b/scarf/docs/ROADMAP.md @@ -0,0 +1,58 @@ +# Scarf — Feature Roadmap + +## Tier 1 — High Value, Data Already Available + +### 1. Insights Dashboard +Rich usage analytics pulled from the sessions and messages SQLite tables: +- Overview stats: sessions, messages, tool calls, tokens, active time, avg session duration +- Model breakdown: sessions and tokens per model +- Platform breakdown: CLI vs Telegram vs Discord usage +- Top tools chart: ranked tool usage with call counts and percentages +- Activity patterns: sessions by day-of-week, peak hours heatmap +- Notable sessions: longest, most messages, most tokens, most tool calls +- Time period selector: last 7/30/90 days + +### 2. Tool Management Panel +- List all toolsets with enabled/disabled status and descriptions +- Toggle switches to enable/disable tools (via `hermes tools enable/disable`) +- Per-platform tool configuration +- MCP tool status + +### 3. Session Management Enhancements +- Rename sessions from the Sessions browser (via `hermes sessions rename`) +- Delete sessions (via `hermes sessions delete`) +- Export sessions to JSONL (via `hermes sessions export`) +- Session stats card (total count, DB size, per-platform breakdown) + +## Tier 2 — Medium Value, New Service Code Required + +### 4. Skills Hub +- Search remote registries for new skills (6 sources) +- Install/uninstall skills from GUI +- Skill update indicator +- Trust level badges (builtin, local, hub) + +### 5. Gateway Control Center +- Start/stop/restart gateway from GUI +- Real-time status: PID, uptime, connected platforms +- Pairing management: view approved users, approve/revoke +- Platform status per messaging service + +### 6. System Health View +- Mirror `hermes status` and `hermes doctor` output +- API key validation, auth provider status, external tools +- Update available indicator + +## Tier 3 — Nice to Have + +### 7. Profile Management +- List/create/switch profiles (isolated Hermes instances) + +### 8. Plugin Management +- Install from Git, enable/disable, update + +### 9. MCP Server Management +- Add/remove/test MCP servers, toggle tools per server + +### 10. Config Editor +- Structured form editor for config.yaml with validation diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift index 895ab67..1f47e5b 100644 --- a/scarf/scarf/ContentView.swift +++ b/scarf/scarf/ContentView.swift @@ -16,6 +16,8 @@ struct ContentView: View { switch coordinator.selectedSection { case .dashboard: DashboardView() + case .insights: + InsightsView() case .sessions: SessionsView() case .activity: diff --git a/scarf/scarf/Core/Services/HermesDataService.swift b/scarf/scarf/Core/Services/HermesDataService.swift index f7f0793..fa5fbbb 100644 --- a/scarf/scarf/Core/Services/HermesDataService.swift +++ b/scarf/scarf/Core/Services/HermesDataService.swift @@ -183,6 +183,112 @@ actor HermesDataService { ) } + // MARK: - Insights Queries + + func fetchSessionsInPeriod(since: Date) -> [HermesSession] { + guard let db else { return [] } + let sql = """ + SELECT id, source, user_id, model, title, parent_session_id, + started_at, ended_at, end_reason, message_count, tool_call_count, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + estimated_cost_usd + FROM sessions + WHERE started_at >= ? + ORDER BY started_at DESC + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970) + + var sessions: [HermesSession] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + sessions.append(sessionFromRow(stmt!)) + } + return sessions + } + + func fetchUserMessageCount(since: Date) -> Int { + guard let db else { return 0 } + let sql = """ + SELECT COUNT(*) FROM messages m + JOIN sessions s ON m.session_id = s.id + WHERE m.role = 'user' AND s.started_at >= ? + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970) + guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 } + return Int(sqlite3_column_int(stmt, 0)) + } + + func fetchToolUsage(since: Date) -> [(name: String, count: Int)] { + guard let db else { return [] } + let sql = """ + SELECT m.tool_name, COUNT(*) as cnt + FROM messages m + JOIN sessions s ON m.session_id = s.id + WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.started_at >= ? + GROUP BY m.tool_name + ORDER BY cnt DESC + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970) + + var results: [(name: String, count: Int)] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + let name = columnText(stmt!, 0) + let count = Int(sqlite3_column_int(stmt!, 1)) + results.append((name: name, count: count)) + } + return results + } + + func fetchSessionStartHours(since: Date) -> [Int: Int] { + guard let db else { return [:] } + let sql = """ + SELECT started_at FROM sessions WHERE started_at >= ? + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970) + + var hours: [Int: Int] = [:] + let calendar = Calendar.current + while sqlite3_step(stmt) == SQLITE_ROW { + let ts = sqlite3_column_double(stmt!, 0) + let date = Date(timeIntervalSince1970: ts) + let hour = calendar.component(.hour, from: date) + hours[hour, default: 0] += 1 + } + return hours + } + + func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] { + guard let db else { return [:] } + let sql = """ + SELECT started_at FROM sessions WHERE started_at >= ? + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970) + + var days: [Int: Int] = [:] + let calendar = Calendar.current + while sqlite3_step(stmt) == SQLITE_ROW { + let ts = sqlite3_column_double(stmt!, 0) + let date = Date(timeIntervalSince1970: ts) + let weekday = (calendar.component(.weekday, from: date) + 5) % 7 // Mon=0 + days[weekday, default: 0] += 1 + } + return days + } + func stateDBModificationDate() -> Date? { let walPath = HermesPaths.stateDB + "-wal" let dbPath = HermesPaths.stateDB diff --git a/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift b/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift new file mode 100644 index 0000000..980f97c --- /dev/null +++ b/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift @@ -0,0 +1,234 @@ +import Foundation + +enum InsightsPeriod: String, CaseIterable, Identifiable { + case week = "7 Days" + case month = "30 Days" + case quarter = "90 Days" + case all = "All Time" + + var id: String { rawValue } + + var sinceDate: Date { + let calendar = Calendar.current + switch self { + case .week: return calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date() + case .month: return calendar.date(byAdding: .day, value: -30, to: Date()) ?? Date() + case .quarter: return calendar.date(byAdding: .day, value: -90, to: Date()) ?? Date() + case .all: return Date(timeIntervalSince1970: 0) + } + } +} + +struct ModelUsage: Identifiable { + var id: String { model } + let model: String + let sessions: Int + let inputTokens: Int + let outputTokens: Int + let cacheReadTokens: Int + let cacheWriteTokens: Int + var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens } +} + +struct PlatformUsage: Identifiable { + var id: String { platform } + let platform: String + let sessions: Int + let messages: Int + let tokens: Int +} + +struct ToolUsage: Identifiable { + var id: String { name } + let name: String + let count: Int + let percentage: Double +} + +struct NotableSession: Identifiable { + var id: String { session.id } + let label: String + let value: String + let session: HermesSession + let preview: String +} + +@Observable +final class InsightsViewModel { + private let dataService = HermesDataService() + + var period: InsightsPeriod = .month + var isLoading = true + + var sessions: [HermesSession] = [] + var sessionPreviews: [String: String] = [:] + var userMessageCount = 0 + var totalMessages = 0 + var totalToolCalls = 0 + var totalInputTokens = 0 + var totalOutputTokens = 0 + var totalCacheReadTokens = 0 + var totalCacheWriteTokens = 0 + var totalTokens = 0 + var activeTime: TimeInterval = 0 + var avgSessionDuration: TimeInterval = 0 + + var modelUsage: [ModelUsage] = [] + var platformUsage: [PlatformUsage] = [] + var toolUsage: [ToolUsage] = [] + var hourlyActivity: [Int: Int] = [:] + var dailyActivity: [Int: Int] = [:] + var notableSessions: [NotableSession] = [] + + func load() async { + isLoading = true + let opened = await dataService.open() + guard opened else { + isLoading = false + return + } + + let since = period.sinceDate + sessions = await dataService.fetchSessionsInPeriod(since: since) + sessionPreviews = await dataService.fetchSessionPreviews(limit: 500) + userMessageCount = await dataService.fetchUserMessageCount(since: since) + let tools = await dataService.fetchToolUsage(since: since) + hourlyActivity = await dataService.fetchSessionStartHours(since: since) + dailyActivity = await dataService.fetchSessionDaysOfWeek(since: since) + + await dataService.close() + + computeAggregates() + computeModelBreakdown() + computePlatformBreakdown() + computeToolBreakdown(tools) + computeNotableSessions() + isLoading = false + } + + func previewFor(_ session: HermesSession) -> String { + if let title = session.title, !title.isEmpty { return title } + if let preview = sessionPreviews[session.id], !preview.isEmpty { return preview } + return session.id + } + + private func computeAggregates() { + totalMessages = sessions.reduce(0) { $0 + $1.messageCount } + totalToolCalls = sessions.reduce(0) { $0 + $1.toolCallCount } + totalInputTokens = sessions.reduce(0) { $0 + $1.inputTokens } + totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens } + totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens } + totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens } + totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens + + var total: TimeInterval = 0 + var count = 0 + for session in sessions { + if let dur = session.duration, dur > 0 { + total += dur + count += 1 + } + } + activeTime = total + avgSessionDuration = count > 0 ? total / Double(count) : 0 + } + + private func computeModelBreakdown() { + var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int)] = [:] + for s in sessions { + let model = s.model ?? "unknown" + var entry = grouped[model, default: (0, 0, 0, 0, 0)] + entry.sessions += 1 + entry.input += s.inputTokens + entry.output += s.outputTokens + entry.cacheRead += s.cacheReadTokens + entry.cacheWrite += s.cacheWriteTokens + grouped[model] = entry + } + modelUsage = grouped.map { key, val in + ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input, + outputTokens: val.output, cacheReadTokens: val.cacheRead, + cacheWriteTokens: val.cacheWrite) + }.sorted { $0.totalTokens > $1.totalTokens } + } + + private func computePlatformBreakdown() { + var grouped: [String: (sessions: Int, messages: Int, tokens: Int)] = [:] + for s in sessions { + var entry = grouped[s.source, default: (0, 0, 0)] + entry.sessions += 1 + entry.messages += s.messageCount + entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens + grouped[s.source] = entry + } + platformUsage = grouped.map { key, val in + PlatformUsage(platform: key, sessions: val.sessions, messages: val.messages, tokens: val.tokens) + }.sorted { $0.sessions > $1.sessions } + } + + private func computeToolBreakdown(_ tools: [(name: String, count: Int)]) { + let total = tools.reduce(0) { $0 + $1.count } + toolUsage = tools.map { tool in + ToolUsage(name: tool.name, count: tool.count, + percentage: total > 0 ? Double(tool.count) / Double(total) * 100 : 0) + } + } + + private func computeNotableSessions() { + notableSessions = [] + + if let longest = sessions.filter({ $0.duration != nil }).max(by: { ($0.duration ?? 0) < ($1.duration ?? 0) }) { + notableSessions.append(NotableSession( + label: "Longest Session", + value: formatDuration(longest.duration ?? 0), + session: longest, + preview: previewFor(longest) + )) + } + + if let mostMsgs = sessions.max(by: { $0.messageCount < $1.messageCount }), mostMsgs.messageCount > 0 { + notableSessions.append(NotableSession( + label: "Most Messages", + value: "\(mostMsgs.messageCount) msgs", + session: mostMsgs, + preview: previewFor(mostMsgs) + )) + } + + if let mostTokens = sessions.max(by: { $0.totalTokens < $1.totalTokens }), mostTokens.totalTokens > 0 { + notableSessions.append(NotableSession( + label: "Most Tokens", + value: formatTokens(mostTokens.totalTokens), + session: mostTokens, + preview: previewFor(mostTokens) + )) + } + + if let mostTools = sessions.max(by: { $0.toolCallCount < $1.toolCallCount }), mostTools.toolCallCount > 0 { + notableSessions.append(NotableSession( + label: "Most Tool Calls", + value: "\(mostTools.toolCallCount) calls", + session: mostTools, + preview: previewFor(mostTools) + )) + } + } +} + +func formatDuration(_ interval: TimeInterval) -> String { + let hours = Int(interval) / 3600 + let minutes = (Int(interval) % 3600) / 60 + if hours > 0 { + return "\(hours)h \(minutes)m" + } + return "\(minutes)m" +} + +func formatTokens(_ count: Int) -> String { + if count >= 1_000_000 { + return String(format: "%.1fM", Double(count) / 1_000_000) + } else if count >= 1_000 { + return String(format: "%.1fK", Double(count) / 1_000) + } + return "\(count)" +} diff --git a/scarf/scarf/Features/Insights/Views/InsightsView.swift b/scarf/scarf/Features/Insights/Views/InsightsView.swift new file mode 100644 index 0000000..8615efd --- /dev/null +++ b/scarf/scarf/Features/Insights/Views/InsightsView.swift @@ -0,0 +1,317 @@ +import SwiftUI + +struct InsightsView: View { + @State private var viewModel = InsightsViewModel() + @Environment(AppCoordinator.self) private var coordinator + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + periodPicker + overviewSection + modelSection + platformSection + toolsSection + activitySection + notableSection + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .navigationTitle("Insights") + .task { await viewModel.load() } + .onChange(of: viewModel.period) { + Task { await viewModel.load() } + } + } + + private var periodPicker: some View { + Picker("Period", selection: $viewModel.period) { + ForEach(InsightsPeriod.allCases) { period in + Text(period.rawValue).tag(period) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 400) + } + + // MARK: - Overview + + private var overviewSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Overview") + .font(.headline) + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 12) { + InsightCard(label: "Sessions", value: "\(viewModel.sessions.count)") + InsightCard(label: "Messages", value: "\(viewModel.totalMessages)") + InsightCard(label: "User Messages", value: "\(viewModel.userMessageCount)") + InsightCard(label: "Tool Calls", value: "\(viewModel.totalToolCalls)") + InsightCard(label: "Input Tokens", value: formatTokens(viewModel.totalInputTokens)) + InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens)) + InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens)) + InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens)) + InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens)) + InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime)) + InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration)) + InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count))) + } + } + } + + // MARK: - Models + + private var modelSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Models") + .font(.headline) + if viewModel.modelUsage.isEmpty { + Text("No data") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.modelUsage) { model in + HStack { + Image(systemName: "cpu") + .foregroundStyle(.blue) + .frame(width: 20) + Text(model.model) + .font(.system(.body, design: .monospaced)) + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text("\(model.sessions) sessions") + .font(.caption) + Text(formatTokens(model.totalTokens) + " tokens") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(10) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + + // MARK: - Platforms + + private var platformSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Platforms") + .font(.headline) + if viewModel.platformUsage.isEmpty { + Text("No data") + .foregroundStyle(.secondary) + } else { + HStack(spacing: 12) { + ForEach(viewModel.platformUsage) { platform in + VStack(spacing: 6) { + Image(systemName: platformIcon(platform.platform)) + .font(.title2) + .foregroundStyle(Color.accentColor) + Text(platform.platform) + .font(.caption.bold()) + Text("\(platform.sessions) sessions") + .font(.caption) + .foregroundStyle(.secondary) + Text("\(platform.messages) msgs") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(12) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + } + + // MARK: - Tools + + private var toolsSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Top Tools") + .font(.headline) + if viewModel.toolUsage.isEmpty { + Text("No data") + .foregroundStyle(.secondary) + } else { + let maxCount = viewModel.toolUsage.first?.count ?? 1 + ForEach(viewModel.toolUsage.prefix(15)) { tool in + HStack(spacing: 10) { + Text(tool.name) + .font(.system(.caption, design: .monospaced)) + .frame(width: 140, alignment: .trailing) + GeometryReader { geo in + RoundedRectangle(cornerRadius: 3) + .fill(barColor(for: tool.name)) + .frame(width: max(4, geo.size.width * Double(tool.count) / Double(maxCount))) + } + .frame(height: 16) + Text("\(tool.count)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .frame(width: 40, alignment: .trailing) + Text(String(format: "%.1f%%", tool.percentage)) + .font(.caption) + .foregroundStyle(.tertiary) + .frame(width: 50, alignment: .trailing) + } + .frame(height: 20) + } + } + } + } + + // MARK: - Activity Patterns + + private var activitySection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Activity Patterns") + .font(.headline) + HStack(alignment: .top, spacing: 24) { + dayOfWeekChart + hourlyChart + } + } + } + + private var dayOfWeekChart: some View { + VStack(alignment: .leading, spacing: 4) { + Text("By Day") + .font(.caption.bold()) + .foregroundStyle(.secondary) + let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + let maxVal = max(1, viewModel.dailyActivity.values.max() ?? 1) + ForEach(0..<7, id: \.self) { day in + let count = viewModel.dailyActivity[day] ?? 0 + HStack(spacing: 6) { + Text(dayNames[day]) + .font(.caption.monospaced()) + .frame(width: 30, alignment: .trailing) + RoundedRectangle(cornerRadius: 2) + .fill(Color.accentColor.opacity(0.7)) + .frame(width: max(0, CGFloat(count) / CGFloat(maxVal) * 120), height: 14) + if count > 0 { + Text("\(count)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + } + + private var hourlyChart: some View { + VStack(alignment: .leading, spacing: 4) { + Text("By Hour") + .font(.caption.bold()) + .foregroundStyle(.secondary) + let maxVal = max(1, viewModel.hourlyActivity.values.max() ?? 1) + HStack(alignment: .bottom, spacing: 2) { + ForEach(0..<24, id: \.self) { hour in + let count = viewModel.hourlyActivity[hour] ?? 0 + VStack(spacing: 2) { + RoundedRectangle(cornerRadius: 2) + .fill(count > 0 ? Color.accentColor.opacity(0.7) : Color.secondary.opacity(0.15)) + .frame(width: 12, height: max(4, CGFloat(count) / CGFloat(maxVal) * 80)) + if hour % 6 == 0 { + Text("\(hour)") + .font(.system(size: 8)) + .foregroundStyle(.secondary) + } else { + Text("") + .font(.system(size: 8)) + } + } + } + } + } + } + + // MARK: - Notable Sessions + + private var notableSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Notable Sessions") + .font(.headline) + if viewModel.notableSessions.isEmpty { + Text("No data") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.notableSessions) { notable in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(notable.label) + .font(.caption.bold()) + .foregroundStyle(.secondary) + Text(notable.preview) + .lineLimit(1) + } + Spacer() + Text(notable.value) + .font(.system(.body, design: .monospaced, weight: .semibold)) + Button { + coordinator.selectedSessionId = notable.session.id + coordinator.selectedSection = .sessions + } label: { + Image(systemName: "arrow.right.circle") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + .help("Open session") + } + .padding(10) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + + // MARK: - Helpers + + private func platformIcon(_ platform: String) -> String { + switch platform { + case "cli": return "terminal" + case "telegram": return "paperplane" + case "discord": return "bubble.left.and.bubble.right" + case "slack": return "number" + case "email": return "envelope" + default: return "bubble.left" + } + } + + private func barColor(for toolName: String) -> Color { + switch toolName { + case "terminal": return .orange + case "read_file", "search_files": return .green + case "write_file", "patch": return .blue + case "web_search", "web_extract": return .purple + case _ where toolName.hasPrefix("browser"): return .indigo + case "memory": return .pink + case "vision", "image_gen": return .mint + default: return Color.accentColor + } + } +} + +struct InsightCard: View { + let label: String + let value: String + + var body: some View { + VStack(spacing: 4) { + Text(value) + .font(.system(.title3, design: .monospaced, weight: .semibold)) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(10) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/scarf/scarf/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index 97724a7..b9b4995 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -2,6 +2,7 @@ import Foundation enum SidebarSection: String, CaseIterable, Identifiable { case dashboard = "Dashboard" + case insights = "Insights" case sessions = "Sessions" case activity = "Activity" case chat = "Chat" @@ -16,6 +17,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { var icon: String { switch self { case .dashboard: return "gauge.with.dots.needle.33percent" + case .insights: return "chart.bar" case .sessions: return "bubble.left.and.bubble.right" case .activity: return "bolt.horizontal" case .chat: return "text.bubble" diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift index e72a305..372292f 100644 --- a/scarf/scarf/Navigation/SidebarView.swift +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -7,7 +7,7 @@ struct SidebarView: View { @Bindable var coordinator = coordinator List(selection: $coordinator.selectedSection) { Section("Monitor") { - ForEach([SidebarSection.dashboard, .sessions, .activity]) { section in + ForEach([SidebarSection.dashboard, .insights, .sessions, .activity]) { section in Label(section.rawValue, systemImage: section.icon) .tag(section) }