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).