M9 #4.6 (pass-2): Dashboard Overview/Sessions split + chat project bar

Pass-2 feedback bundled into one architectural commit:

1. **Project indicator moved out of the nav-bar principal slot.** The
   iPhone nav bar's .principal area gets squeezed to icon-only when
   adjacent toolbar buttons exist — the result was a folder icon with
   no project-name text, which is worse than no indicator at all. New
   `projectContextBar` renders a full-width tinted strip BELOW the
   nav bar when a session is project-attributed: "Project chat"
   caption + folder icon + full project name. Scrolls away with the
   message list. Pattern cribbed from Slack's channel-topic header
   and Apple Mail's sender strip.

2. **Dashboard split into Overview + Sessions sub-tabs.** Segmented
   picker at the top. Overview = stats + 5 most-recent sessions for
   at-a-glance; Sessions = the deeper 25-session list with a project
   filter. `See all` button on Overview's Recent Sessions header
   switches tabs. Addresses pass-2 complaint: "The dashboard might
   need tabs to break it down better."

3. **Project filter on the Sessions sub-tab.** Menu picker (scales
   to N projects; segmented doesn't). "All projects" clears; each
   project entry filters to sessions attributed there. Uses the same
   attribution map loaded once in `IOSDashboardViewModel.load()`, so
   filtering is an O(n) in-memory pass over 25 sessions — no extra
   SFTP traffic. Addresses pass-2 complaint: "we should add a filter
   to the sessions selector in the dash to see by project."

4. **`IOSDashboardViewModel` exposes the wider surface:**
   - `allSessions` (25-session window, feeds the Sessions tab)
   - `allProjects` (project registry, drives the filter menu)
   - `sessions(filteredBy: String?)` helper — accepts a project name
     (nil = all), returns filtered subset.

Mac parity note from the earlier commit message still stands — Mac's
global Sessions list doesn't currently filter by project either.
That's a parallel post-TestFlight followup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 15:30:11 +02:00
parent 9a4473333b
commit 54a0797334
3 changed files with 254 additions and 101 deletions
@@ -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 sessionproject
// 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).
+36 -12
View File
@@ -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.51.5 s
/// on a warm network silent before this overlay existed, which
+190 -85
View File
@@ -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 34 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) {