diff --git a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/IOSDashboardViewModel.swift b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/IOSDashboardViewModel.swift index d28a3e2..f1d14f5 100644 --- a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/IOSDashboardViewModel.swift +++ b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/IOSDashboardViewModel.swift @@ -28,7 +28,12 @@ public final class IOSDashboardViewModel { // MARK: - Published state public var stats: HermesDataService.SessionStats = .empty + /// Recent 5 sessions for the Overview sub-tab (glance-only surface). public var recentSessions: [HermesSession] = [] + /// Deeper session list for the Sessions sub-tab — larger window + + /// filterable by project. Default 25; enough to cover "what did I + /// work on this week" without paging. + public var allSessions: [HermesSession] = [] public var sessionPreviews: [String: String] = [:] public var isLoading: Bool = true @@ -39,6 +44,10 @@ public final class IOSDashboardViewModel { /// sessions on screen are attributed. public private(set) var sessionProjectNames: [String: String] = [:] + /// Every configured project, for the filter picker in the + /// Sessions sub-tab. Populated alongside `sessionProjectNames`. + public private(set) var allProjects: [ProjectEntry] = [] + /// Surfaced when the SQLite snapshot or DB open fails. Shown in a /// yellow banner above the stats with a "Retry" button. `nil` means /// the last load was healthy. @@ -63,7 +72,8 @@ public final class IOSDashboardViewModel { stats = await dataService.fetchStats() recentSessions = await dataService.fetchSessions(limit: 5) - sessionPreviews = await dataService.fetchSessionPreviews(limit: 5) + allSessions = await dataService.fetchSessions(limit: 25) + sessionPreviews = await dataService.fetchSessionPreviews(limit: 25) // Attribution lookup (pass-2 UX): load the session→project // sidecar + project registry once so Dashboard rows can show @@ -72,7 +82,7 @@ public final class IOSDashboardViewModel { // cell. Failure is silent — the absence of project labels is // a cosmetic degradation, not a data-loss problem. let ctx = context - let attributions: [String: String] = await Task.detached { + let bundle: (names: [String: String], projects: [ProjectEntry]) = await Task.detached { let attribution = SessionAttributionService(context: ctx) let projectRegistry = ProjectDashboardService(context: ctx).loadRegistry() let pathToName = Dictionary( @@ -85,14 +95,28 @@ public final class IOSDashboardViewModel { result[sessionID] = name } } - return result + return (names: result, projects: projectRegistry.projects) }.value - sessionProjectNames = attributions + sessionProjectNames = bundle.names + allProjects = bundle.projects await dataService.close() isLoading = false } + /// Sessions matching the given project filter. `nil` returns + /// all 25 recent sessions (no filtering). `projectName` is the + /// ProjectEntry.name that's the key in `sessionProjectNames`, so + /// the filter is an O(n) dict lookup per session — cheap at our + /// 25-session window. Sorting is preserved (newest first) from + /// the upstream `fetchSessions(limit:)` query. + public func sessions(filteredBy projectName: String?) -> [HermesSession] { + guard let projectName, !projectName.isEmpty else { return allSessions } + return allSessions.filter { session in + sessionProjectNames[session.id] == projectName + } + } + /// Helper used by DashboardView rows. Returns the project display /// name a session is attributed to, or nil for unattributed /// sessions (CLI-started, or started before v2.3). diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index 96ba5d6..b2d89d5 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -42,6 +42,7 @@ struct ChatView: View { var body: some View { VStack(spacing: 0) { errorBanner + projectContextBar messageList Divider() composer @@ -52,18 +53,6 @@ struct ChatView: View { // Principal: "Chat" title + small folder chip below when // the current session is project-attributed. iOS-native // equivalent of Mac's SessionInfoBar project-chip pattern. - ToolbarItem(placement: .principal) { - VStack(spacing: 1) { - Text("Chat") - .font(.headline) - if let projectName = controller.currentProjectName, !projectName.isEmpty { - Label(projectName, systemImage: "folder.fill") - .font(.caption2) - .foregroundStyle(.tint) - .lineLimit(1) - } - } - } ToolbarItem(placement: .topBarTrailing) { Button { showProjectPicker = true @@ -348,6 +337,41 @@ struct ChatView: View { } } + /// Contextual header rendered BELOW the navigation bar when the + /// current session is scoped to a Scarf project. Sits full-width + /// so the project name has room to breathe (the nav bar's + /// `.principal` slot gets squeezed to icon-only by adjacent + /// toolbar buttons on iPhone — exactly the pass-2 bug). Drawn as + /// a subtle tinted strip so it doesn't dominate but is clearly + /// informational. + @ViewBuilder + private var projectContextBar: some View { + if let projectName = controller.currentProjectName, + !projectName.isEmpty + { + HStack(spacing: 8) { + Image(systemName: "folder.fill") + .foregroundStyle(.tint) + .font(.caption) + VStack(alignment: .leading, spacing: 1) { + Text("Project chat") + .font(.caption2) + .foregroundStyle(.secondary) + Text(projectName) + .font(.callout.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.tint.opacity(0.1)) + } + } + /// Shown while we're opening the SSH exec channel + spawning /// `hermes acp` + creating the ACP session. Typically ~0.5–1.5 s /// on a warm network — silent before this overlay existed, which diff --git a/scarf/Scarf iOS/Dashboard/DashboardView.swift b/scarf/Scarf iOS/Dashboard/DashboardView.swift index 3d57b3b..cdfbd10 100644 --- a/scarf/Scarf iOS/Dashboard/DashboardView.swift +++ b/scarf/Scarf iOS/Dashboard/DashboardView.swift @@ -12,6 +12,16 @@ struct DashboardView: View { @Environment(\.scarfGoCoordinator) private var coordinator @State private var vm: IOSDashboardViewModel + @State private var selectedSection: Section = .overview + @State private var sessionProjectFilter: String? = nil + + /// Two top-level surfaces in the Dashboard. Overview = stats + + /// 5 most-recent sessions for glance. Sessions = the 25-session + /// deeper list with a project filter. Split added in pass-2 per + /// user feedback — the old single-List layout grew too busy + /// once we started adding project badges, and users wanted a + /// way to slice by project. + enum Section: Hashable { case overview, sessions } /// Stable ID used when building the `ServerContext` — tied to the /// config's host+user tuple so re-launching the app without reset @@ -31,97 +41,27 @@ struct DashboardView: View { } var body: some View { - // TabView root already wraps this in a NavigationStack; don't - // nest (causes duplicate nav bars + broken back swipes). - List { - if let err = vm.lastError { - Section { - VStack(alignment: .leading, spacing: 8) { - Label("Connection issue", systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - .font(.headline) - Text(err) - .font(.callout) - .foregroundStyle(.secondary) - Button("Retry") { - Task { await vm.refresh() } - } - .buttonStyle(.bordered) - } - .padding(.vertical, 4) - } - } + VStack(spacing: 0) { + Picker("View", selection: $selectedSection) { + Text("Overview").tag(Section.overview) + Text("Sessions").tag(Section.sessions) + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 4) - Section("Activity") { - statRow("Total sessions", value: "\(vm.stats.totalSessions)") - statRow("Total messages", value: "\(vm.stats.totalMessages)") - statRow("Tool calls", value: "\(vm.stats.totalToolCalls)") + Group { + switch selectedSection { + case .overview: overviewList + case .sessions: sessionsList } - - Section("Tokens") { - statRow("Input", value: formatTokens(vm.stats.totalInputTokens)) - statRow("Output", value: formatTokens(vm.stats.totalOutputTokens)) - statRow("Reasoning", value: formatTokens(vm.stats.totalReasoningTokens)) - } - - if !vm.recentSessions.isEmpty { - Section("Recent sessions") { - ForEach(vm.recentSessions) { session in - Button { - // Route to Chat tab with a resume - // request for this session id. Chat - // will pick it up from the coordinator - // on next appear (M9 #4.1). - coordinator?.resumeSession(session.id) - } label: { - VStack(alignment: .leading, spacing: 4) { - Text(session.displayTitle) - .font(.body) - .lineLimit(2) - .foregroundStyle(.primary) - HStack(spacing: 12) { - Label(session.source, systemImage: session.sourceIcon) - .font(.caption) - .foregroundStyle(.secondary) - if let started = session.startedAt { - Text(started, format: .relative(presentation: .numeric)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - // Project chip — only shows for - // attributed sessions. Small + tinted - // so it pops without dominating the - // row. Pass-2 UX recommendation: - // users wanted to see at a glance - // which project each session - // belongs to. - if let projectName = vm.projectName(for: session) { - Label(projectName, systemImage: "folder.fill") - .font(.caption2) - .foregroundStyle(.tint) - .labelStyle(.titleAndIcon) - .padding(.vertical, 2) - .padding(.horizontal, 6) - .background(.tint.opacity(0.12), in: Capsule()) - } - } - .padding(.vertical, 2) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - } - } - + } } .scarfGoListDensity() .navigationTitle(config.displayName) .navigationBarTitleDisplayMode(.large) - .refreshable { - await vm.refresh() - } + .refreshable { await vm.refresh() } .overlay { if vm.isLoading, vm.recentSessions.isEmpty { ProgressView("Loading dashboard…") @@ -133,6 +73,171 @@ struct DashboardView: View { .task { await vm.load() } } + // MARK: - Overview + + @ViewBuilder + private var overviewList: some View { + List { + if let err = vm.lastError { + SwiftUI.Section { + VStack(alignment: .leading, spacing: 8) { + Label("Connection issue", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.headline) + Text(err) + .font(.callout) + .foregroundStyle(.secondary) + Button("Retry") { + Task { await vm.refresh() } + } + .buttonStyle(.bordered) + } + .padding(.vertical, 4) + } + } + + SwiftUI.Section("Activity") { + statRow("Total sessions", value: "\(vm.stats.totalSessions)") + statRow("Total messages", value: "\(vm.stats.totalMessages)") + statRow("Tool calls", value: "\(vm.stats.totalToolCalls)") + } + + SwiftUI.Section("Tokens") { + statRow("Input", value: formatTokens(vm.stats.totalInputTokens)) + statRow("Output", value: formatTokens(vm.stats.totalOutputTokens)) + statRow("Reasoning", value: formatTokens(vm.stats.totalReasoningTokens)) + } + + if !vm.recentSessions.isEmpty { + SwiftUI.Section { + ForEach(vm.recentSessions) { session in + sessionRow(session) + } + } header: { + HStack { + Text("Recent sessions") + Spacer() + Button("See all") { selectedSection = .sessions } + .font(.caption) + .textCase(nil) + } + } + } + } + } + + // MARK: - Sessions sub-tab + + @ViewBuilder + private var sessionsList: some View { + VStack(spacing: 0) { + if !vm.allProjects.isEmpty { + filterBar + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + + List { + let filtered = vm.sessions(filteredBy: sessionProjectFilter) + if filtered.isEmpty { + ContentUnavailableView( + "No sessions", + systemImage: "clock.badge.questionmark", + description: Text(sessionProjectFilter == nil + ? "No sessions to show yet — start a chat from the Chat tab." + : "No sessions for that project yet. Try another filter or start a chat in that project.") + ) + .listRowSeparator(.hidden) + } else { + ForEach(filtered) { session in + sessionRow(session) + } + } + } + } + } + + /// Project filter control rendered above the Sessions list. Uses + /// a Menu instead of a segmented Picker because there can be many + /// projects — segments don't scale past 3–4 options on a phone. + /// Shows the active filter as the button label (tappable to + /// change); an explicit "All projects" entry clears the filter. + @ViewBuilder + private var filterBar: some View { + HStack { + Menu { + Button { + sessionProjectFilter = nil + } label: { + Label("All projects", systemImage: "tray.full") + } + Divider() + ForEach(vm.allProjects.sorted { $0.name < $1.name }) { project in + Button { + sessionProjectFilter = project.name + } label: { + Label(project.name, systemImage: "folder.fill") + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: sessionProjectFilter == nil + ? "line.3.horizontal.decrease.circle" + : "line.3.horizontal.decrease.circle.fill") + Text(sessionProjectFilter ?? "All projects") + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.caption2) + } + .font(.caption) + .foregroundStyle(.tint) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.tint.opacity(0.1), in: Capsule()) + } + Spacer() + } + } + + // MARK: - Row helpers + + @ViewBuilder + private func sessionRow(_ session: HermesSession) -> some View { + Button { + coordinator?.resumeSession(session.id) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(session.displayTitle) + .font(.body) + .lineLimit(2) + .foregroundStyle(.primary) + HStack(spacing: 12) { + Label(session.source, systemImage: session.sourceIcon) + .font(.caption) + .foregroundStyle(.secondary) + if let started = session.startedAt { + Text(started, format: .relative(presentation: .numeric)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + if let projectName = vm.projectName(for: session) { + Label(projectName, systemImage: "folder.fill") + .font(.caption2) + .foregroundStyle(.tint) + .labelStyle(.titleAndIcon) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background(.tint.opacity(0.12), in: Capsule()) + } + } + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + @ViewBuilder private func statRow(_ label: String, value: String) -> some View { LabeledContent(label) {