mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,11 +41,45 @@ 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).
|
||||
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)
|
||||
|
||||
Group {
|
||||
switch selectedSection {
|
||||
case .overview: overviewList
|
||||
case .sessions: sessionsList
|
||||
}
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
.navigationTitle(config.displayName)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.refreshable { await vm.refresh() }
|
||||
.overlay {
|
||||
if vm.isLoading, vm.recentSessions.isEmpty {
|
||||
ProgressView("Loading dashboard…")
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
|
||||
// MARK: - Overview
|
||||
|
||||
@ViewBuilder
|
||||
private var overviewList: some View {
|
||||
List {
|
||||
if let err = vm.lastError {
|
||||
Section {
|
||||
SwiftUI.Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Connection issue", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
@@ -52,26 +96,114 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Activity") {
|
||||
SwiftUI.Section("Activity") {
|
||||
statRow("Total sessions", value: "\(vm.stats.totalSessions)")
|
||||
statRow("Total messages", value: "\(vm.stats.totalMessages)")
|
||||
statRow("Tool calls", value: "\(vm.stats.totalToolCalls)")
|
||||
}
|
||||
|
||||
Section("Tokens") {
|
||||
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 {
|
||||
Section("Recent sessions") {
|
||||
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 {
|
||||
// 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) {
|
||||
@@ -89,13 +221,6 @@ struct DashboardView: View {
|
||||
.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)
|
||||
@@ -112,26 +237,6 @@ struct DashboardView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
.navigationTitle(config.displayName)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.refreshable {
|
||||
await vm.refresh()
|
||||
}
|
||||
.overlay {
|
||||
if vm.isLoading, vm.recentSessions.isEmpty {
|
||||
ProgressView("Loading dashboard…")
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statRow(_ label: String, value: String) -> some View {
|
||||
|
||||
Reference in New Issue
Block a user