mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
3b3c037fce
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>
157 lines
6.9 KiB
Swift
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)"
|
|
}
|
|
}
|