Files
scarf/scarf/Scarf iOS/Dashboard/DashboardView.swift
T
Alan Wizemann 3b3c037fce M9 #4.5 (pass-2): project context surfaced in Chat nav + Dashboard rows
Pass-2 UX feedback: "When selecting a per-project chat, we should
update the chat interface to show that we are 'in a project' — and
label them in the sessions list so the user can see the session
and understand what project it belongs to."

Two related changes:

**In-chat indicator** — ChatController gains `currentProjectName`,
set by `resetAndStartInProject` (direct: we have the ProjectEntry)
and by `startResuming` (resolved via SessionAttributionService +
project registry lookup). ChatView's toolbar uses a `.principal`
ToolbarItem with a VStack: "Chat" title on top, `Label(name, systemImage: "folder.fill")`
subtitle underneath when attributed. Mirrors Mac's SessionInfoBar
project-chip pattern but fits the iOS nav-bar real estate instead
of eating a full-width horizontal row.

**Dashboard row labels** — `IOSDashboardViewModel.load()` now does
one additional SFTP read per refresh: pulls the session→project
sidecar + project registry, maps session id → project display name
into `sessionProjectNames`. Row renders a small tinted folder
capsule when attributed. Batched so row renders are O(1) dict
lookups — no extra SFTP traffic per cell. Silent on failure
(attribution is cosmetic).

Not in scope for this commit: Mac's global Sessions list doesn't
currently show project attribution either — that gap exists on
both platforms, but wiring Mac's ProjectsSidebar + SessionsView
for per-row labels is a bigger surgery. Scoped as a post-TestFlight
followup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:38:02 +02:00

157 lines
6.9 KiB
Swift

import SwiftUI
import ScarfCore
import ScarfIOS
/// iOS Dashboard shows session count, token usage, cost, and the
/// last 5 sessions pulled from the remote Hermes SQLite snapshot.
/// Every data source routes through `ServerContext CitadelServerTransport`
/// so the same services that drive the Mac Dashboard power this one.
struct DashboardView: View {
let config: IOSServerConfig
let key: SSHKeyBundle
@Environment(\.scarfGoCoordinator) private var coordinator
@State private var vm: IOSDashboardViewModel
/// Stable ID used when building the `ServerContext` tied to the
/// config's host+user tuple so re-launching the app without reset
/// yields the same ID (important for the snapshot cache dir).
private static let contextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1"
)!
init(
config: IOSServerConfig,
key: SSHKeyBundle
) {
self.config = config
self.key = key
let ctx = config.toServerContext(id: Self.contextID)
_vm = State(initialValue: IOSDashboardViewModel(context: ctx))
}
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)
}
}
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") {
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()
}
.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 {
LabeledContent(label) {
Text(value)
.monospacedDigit()
.foregroundStyle(.secondary)
}
}
/// Mirror of `ScarfCore.formatTokens` inlined here rather than
/// exported from ScarfCore because it's currently wrapped in
/// `#if canImport(SQLite3)` (from the M0d InsightsViewModel move).
private 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)"
}
}