mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat(ios): Mac-style page backgrounds + Dashboard + Chat redesign
iOS now uses ScarfColor.backgroundPrimary throughout instead of the default iOS systemGroupedBackground. List-based screens add .scrollContentBackground(.hidden) + the rust background underneath; list rows use ScarfColor.backgroundSecondary as their card surface. Applied to: Projects, Memory, Cron, Settings, Skills/Installed, and the Servers root. Dashboard rewritten in Mac-style cards (no more native iOS list): - ScrollView + VStack with rust background - Activity stat grid (2-col LazyVGrid) with bordered rust-tinted cards: Sessions / Messages / Tool Calls / Tokens (with in/out sub- label). - Recent sessions card — bordered, ScarfColor.backgroundSecondary, inline session rows with 1px dividers, "See all" nav to Sessions sub-tab. - Error banner styled with ScarfColor.warning tinted card per Mac. - Sessions sub-tab keeps a List view but renders against rust background with ScarfColor.backgroundSecondary row backgrounds. Chat redesigned to match the Mac chat reference: - User bubble: rust accent fill with ScarfColor.onAccent text and uneven rounded corners (top/bottom-leading + top-trailing 14px; bottom-trailing 4px) — visually pinches to the sender side, same as Mac. - Assistant bubble: rust gradient sparkles avatar tile (24x24) alongside a ScarfColor.backgroundSecondary bordered card. - ToolCallCard: kind-tinted border + uppercase tracked label (READ/EDIT/EXECUTE/FETCH/BROWSER) using ScarfColor.success/info/ warning/Tool.web/Tool.search; expanded JSON in a bordered ScarfColor.backgroundSecondary panel. - ReasoningDisclosure: warning-tinted card with REASONING uppercase label. Both Mac (scarf) and iOS (scarf mobile) schemes build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1068,14 +1068,28 @@ private struct MessageBubble: View {
|
|||||||
if message.isUser {
|
if message.isUser {
|
||||||
Text(message.content)
|
Text(message.content)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 10)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(ScarfColor.onAccent)
|
||||||
.background(Color.accentColor)
|
.background(
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
UnevenRoundedRectangle(cornerRadii:
|
||||||
|
.init(topLeading: 14, bottomLeading: 14, bottomTrailing: 4, topTrailing: 14))
|
||||||
|
.fill(ScarfColor.accent)
|
||||||
|
)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.contextMenu { messageContextMenu }
|
.contextMenu { messageContextMenu }
|
||||||
} else {
|
} else {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
// Assistant avatar — rust gradient sparkles tile,
|
||||||
|
// matches the Mac side and the ScarfChatView reference.
|
||||||
|
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||||
|
.fill(ScarfGradient.brand)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
)
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(Array(ChatContentFormatter.segments(for: message.content).enumerated()), id: \.offset) { _, segment in
|
ForEach(Array(ChatContentFormatter.segments(for: message.content).enumerated()), id: \.offset) { _, segment in
|
||||||
switch segment {
|
switch segment {
|
||||||
@@ -1089,13 +1103,20 @@ private struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 10)
|
||||||
.foregroundStyle(Color.primary)
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
.background(ScarfColor.backgroundSecondary)
|
.background(
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
|
||||||
|
.fill(ScarfColor.backgroundSecondary)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
|
||||||
|
.strokeBorder(ScarfColor.border, lineWidth: 1)
|
||||||
|
)
|
||||||
.contextMenu { messageContextMenu }
|
.contextMenu { messageContextMenu }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared context-menu actions for user + assistant bubbles.
|
/// Shared context-menu actions for user + assistant bubbles.
|
||||||
/// Copy is the most-used action; Share hands off to the system
|
/// Copy is the most-used action; Share hands off to the system
|
||||||
@@ -1206,64 +1227,118 @@ private struct ReasoningDisclosure: View {
|
|||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Thinking…", systemImage: "brain")
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: "brain")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
Text("REASONING")
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.tracking(0.5)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 6)
|
.foregroundStyle(ScarfColor.warning)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.fill(ScarfColor.warning.opacity(0.10))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.strokeBorder(ScarfColor.warning.opacity(0.30), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expanding card for a single `HermesToolCall` — shows function name
|
/// Expanding card for a single `HermesToolCall` — kind-tinted with
|
||||||
/// + summary collapsed; full JSON arguments expanded.
|
/// uppercase tracked label, matches the Mac ToolCallCard treatment.
|
||||||
private struct ToolCallCard: View {
|
private struct ToolCallCard: View {
|
||||||
let call: HermesToolCall
|
let call: HermesToolCall
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() }
|
withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() }
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: iconName)
|
HStack(spacing: 4) {
|
||||||
.foregroundStyle(.tint)
|
Image(systemName: call.toolKind.icon)
|
||||||
|
.foregroundStyle(toolColor)
|
||||||
|
.font(.caption2)
|
||||||
|
Text(toolLabel)
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.tracking(0.4)
|
||||||
|
.foregroundStyle(toolColor)
|
||||||
|
}
|
||||||
Text(call.functionName)
|
Text(call.functionName)
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.primary)
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
Text(call.argumentsSummary.prefix(60))
|
Text(call.argumentsSummary.prefix(60))
|
||||||
.font(.caption)
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer()
|
.truncationMode(.middle)
|
||||||
|
Spacer(minLength: 4)
|
||||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.fill(toolColor.opacity(0.10))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.strokeBorder(toolColor.opacity(0.30), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
if isExpanded {
|
if isExpanded {
|
||||||
Text(call.arguments)
|
Text(call.arguments)
|
||||||
.font(.caption2.monospaced())
|
.font(.caption2.monospaced())
|
||||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.padding(.top, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(8)
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: 7)
|
||||||
.fill(Color(.tertiarySystemBackground))
|
.fill(ScarfColor.backgroundSecondary)
|
||||||
)
|
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: 7)
|
||||||
.strokeBorder(Color(.separator), lineWidth: 0.5)
|
.strokeBorder(ScarfColor.border, lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var iconName: String {
|
private var toolLabel: String {
|
||||||
call.toolKind.icon
|
switch call.toolKind {
|
||||||
|
case .read: return "READ"
|
||||||
|
case .edit: return "EDIT"
|
||||||
|
case .execute: return "EXECUTE"
|
||||||
|
case .fetch: return "FETCH"
|
||||||
|
case .browser: return "BROWSER"
|
||||||
|
case .other: return "TOOL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var toolColor: Color {
|
||||||
|
switch call.toolKind {
|
||||||
|
case .read: return ScarfColor.success
|
||||||
|
case .edit: return ScarfColor.info
|
||||||
|
case .execute: return ScarfColor.warning
|
||||||
|
case .fetch: return ScarfColor.Tool.web
|
||||||
|
case .browser: return ScarfColor.Tool.search
|
||||||
|
case .other: return ScarfColor.foregroundMuted
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ struct CronListView: View {
|
|||||||
if let err = vm.lastError {
|
if let err = vm.lastError {
|
||||||
Section {
|
Section {
|
||||||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(ScarfColor.warning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +62,8 @@ struct CronListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scarfGoListDensity()
|
.scarfGoListDensity()
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(ScarfColor.backgroundPrimary)
|
||||||
.navigationTitle("Cron jobs")
|
.navigationTitle("Cron jobs")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import ScarfCore
|
|||||||
import ScarfIOS
|
import ScarfIOS
|
||||||
import ScarfDesign
|
import ScarfDesign
|
||||||
|
|
||||||
/// iOS Dashboard — shows session count, token usage, cost, and the
|
/// iOS Dashboard — adopts the Mac-style card layout (status row +
|
||||||
/// last 5 sessions pulled from the remote Hermes SQLite snapshot.
|
/// stats grid + recent-sessions card) instead of a native iOS list.
|
||||||
/// Every data source routes through `ServerContext → CitadelServerTransport`
|
/// Sessions sub-tab keeps a List view for scrolling density but
|
||||||
/// so the same services that drive the Mac Dashboard power this one.
|
/// renders against the rust page background.
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
let config: IOSServerConfig
|
let config: IOSServerConfig
|
||||||
let key: SSHKeyBundle
|
let key: SSHKeyBundle
|
||||||
@@ -16,17 +16,8 @@ struct DashboardView: View {
|
|||||||
@State private var selectedSection: Section = .overview
|
@State private var selectedSection: Section = .overview
|
||||||
@State private var sessionProjectFilter: String? = nil
|
@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 }
|
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
|
|
||||||
/// yields the same ID (important for the snapshot cache dir).
|
|
||||||
private static let contextID: ServerID = ServerID(
|
private static let contextID: ServerID = ServerID(
|
||||||
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
||||||
)!
|
)!
|
||||||
@@ -48,18 +39,18 @@ struct DashboardView: View {
|
|||||||
Text("Sessions").tag(Section.sessions)
|
Text("Sessions").tag(Section.sessions)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, ScarfSpace.s4)
|
||||||
.padding(.top, 8)
|
.padding(.top, ScarfSpace.s2)
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, ScarfSpace.s1)
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
switch selectedSection {
|
switch selectedSection {
|
||||||
case .overview: overviewList
|
case .overview: overviewContent
|
||||||
case .sessions: sessionsList
|
case .sessions: sessionsList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scarfGoListDensity()
|
.background(ScarfColor.backgroundPrimary.ignoresSafeArea())
|
||||||
.navigationTitle(config.displayName)
|
.navigationTitle(config.displayName)
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.refreshable { await vm.refresh() }
|
.refreshable { await vm.refresh() }
|
||||||
@@ -68,62 +59,159 @@ struct DashboardView: View {
|
|||||||
ProgressView("Loading dashboard…")
|
ProgressView("Loading dashboard…")
|
||||||
.padding()
|
.padding()
|
||||||
.background(.regularMaterial)
|
.background(.regularMaterial)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await vm.load() }
|
.task { await vm.load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Overview
|
// MARK: - Overview (Mac-style cards)
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var overviewList: some View {
|
private var overviewContent: some View {
|
||||||
List {
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: ScarfSpace.s5) {
|
||||||
if let err = vm.lastError {
|
if let err = vm.lastError {
|
||||||
SwiftUI.Section {
|
errorBanner(err)
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
}
|
||||||
Label("Connection issue", systemImage: "exclamationmark.triangle.fill")
|
|
||||||
|
statsSection
|
||||||
|
|
||||||
|
if !vm.recentSessions.isEmpty {
|
||||||
|
recentSessionsSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, ScarfSpace.s4)
|
||||||
|
.padding(.vertical, ScarfSpace.s4)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
.background(ScarfColor.backgroundPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorBanner(_ err: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: ScarfSpace.s2) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(ScarfColor.warning)
|
.foregroundStyle(ScarfColor.warning)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Connection issue")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
Text(err)
|
Text(err)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Button("Retry") {
|
Button("Retry") {
|
||||||
Task { await vm.refresh() }
|
Task { await vm.refresh() }
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(ScarfSpace.s3)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||||
|
.fill(ScarfColor.warning.opacity(0.10))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||||
|
.strokeBorder(ScarfColor.warning.opacity(0.30), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||||
|
Text("Activity")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
|
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [GridItem(.flexible(), spacing: ScarfSpace.s3),
|
||||||
|
GridItem(.flexible(), spacing: ScarfSpace.s3)],
|
||||||
|
spacing: ScarfSpace.s3
|
||||||
|
) {
|
||||||
|
statCard(label: "Sessions", value: "\(vm.stats.totalSessions)")
|
||||||
|
statCard(label: "Messages", value: "\(vm.stats.totalMessages)")
|
||||||
|
statCard(label: "Tool Calls", value: "\(vm.stats.totalToolCalls)")
|
||||||
|
statCard(
|
||||||
|
label: "Tokens",
|
||||||
|
value: formatTokens(vm.stats.totalInputTokens + vm.stats.totalOutputTokens),
|
||||||
|
sub: tokenSub
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SwiftUI.Section("Activity") {
|
private var tokenSub: String? {
|
||||||
statRow("Total sessions", value: "\(vm.stats.totalSessions)")
|
let inT = vm.stats.totalInputTokens
|
||||||
statRow("Total messages", value: "\(vm.stats.totalMessages)")
|
let outT = vm.stats.totalOutputTokens
|
||||||
statRow("Tool calls", value: "\(vm.stats.totalToolCalls)")
|
guard inT + outT > 0 else { return nil }
|
||||||
|
return "\(formatTokens(inT)) in · \(formatTokens(outT)) out"
|
||||||
}
|
}
|
||||||
|
|
||||||
SwiftUI.Section("Tokens") {
|
private func statCard(label: String, value: String, sub: String? = nil) -> some View {
|
||||||
statRow("Input", value: formatTokens(vm.stats.totalInputTokens))
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
statRow("Output", value: formatTokens(vm.stats.totalOutputTokens))
|
Text(label)
|
||||||
statRow("Reasoning", value: formatTokens(vm.stats.totalReasoningTokens))
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.tracking(0.5)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 22, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
if let sub {
|
||||||
|
Text(sub)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(ScarfSpace.s3)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||||
|
.fill(ScarfColor.backgroundSecondary)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||||
|
.strokeBorder(ScarfColor.border, lineWidth: 1)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !vm.recentSessions.isEmpty {
|
private var recentSessionsSection: some View {
|
||||||
SwiftUI.Section {
|
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||||
ForEach(vm.recentSessions) { session in
|
|
||||||
sessionRow(session)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("Recent sessions")
|
Text("Recent sessions")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("See all") { selectedSection = .sessions }
|
Button("See all") { selectedSection = .sessions }
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.textCase(nil)
|
.foregroundStyle(ScarfColor.accent)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(Array(vm.recentSessions.enumerated()), id: \.element.id) { idx, session in
|
||||||
|
sessionRow(session)
|
||||||
|
.padding(.horizontal, ScarfSpace.s3)
|
||||||
|
.padding(.vertical, ScarfSpace.s2 + 2)
|
||||||
|
if idx < vm.recentSessions.count - 1 {
|
||||||
|
Rectangle()
|
||||||
|
.fill(ScarfColor.border)
|
||||||
|
.frame(height: 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||||
|
.fill(ScarfColor.backgroundSecondary)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||||
|
.strokeBorder(ScarfColor.border, lineWidth: 1)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,8 +222,8 @@ struct DashboardView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if !vm.allProjects.isEmpty {
|
if !vm.allProjects.isEmpty {
|
||||||
filterBar
|
filterBar
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, ScarfSpace.s3)
|
||||||
.padding(.bottom, 8)
|
.padding(.vertical, ScarfSpace.s2)
|
||||||
}
|
}
|
||||||
|
|
||||||
List {
|
List {
|
||||||
@@ -149,20 +237,20 @@ struct DashboardView: View {
|
|||||||
: "No sessions for that project yet. Try another filter or start a chat in that project.")
|
: "No sessions for that project yet. Try another filter or start a chat in that project.")
|
||||||
)
|
)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
} else {
|
} else {
|
||||||
ForEach(filtered) { session in
|
ForEach(filtered) { session in
|
||||||
sessionRow(session)
|
sessionRow(session)
|
||||||
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(ScarfColor.backgroundPrimary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
@ViewBuilder
|
||||||
private var filterBar: some View {
|
private var filterBar: some View {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -191,16 +279,16 @@ struct DashboardView: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(ScarfColor.accent)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(.tint.opacity(0.1), in: Capsule())
|
.background(ScarfColor.accentTint, in: Capsule())
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Row helpers
|
// MARK: - Row helper
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func sessionRow(_ session: HermesSession) -> some View {
|
private func sessionRow(_ session: HermesSession) -> some View {
|
||||||
@@ -211,7 +299,7 @@ struct DashboardView: View {
|
|||||||
Text(session.displayTitle)
|
Text(session.displayTitle)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Label(session.source, systemImage: session.sourceIcon)
|
Label(session.source, systemImage: session.sourceIcon)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -230,11 +318,11 @@ struct DashboardView: View {
|
|||||||
if let projectName = vm.projectName(for: session) {
|
if let projectName = vm.projectName(for: session) {
|
||||||
Label(projectName, systemImage: "folder.fill")
|
Label(projectName, systemImage: "folder.fill")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(ScarfColor.accent)
|
||||||
.labelStyle(.titleAndIcon)
|
.labelStyle(.titleAndIcon)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.background(.tint.opacity(0.12), in: Capsule())
|
.background(ScarfColor.accentTint, in: Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
@@ -244,18 +332,6 @@ struct DashboardView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func statRow(_ label: String, value: String) -> some View {
|
|
||||||
LabeledContent(label) {
|
|
||||||
Text(value)
|
|
||||||
.monospacedDigit()
|
|
||||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 {
|
private func formatTokens(_ count: Int) -> String {
|
||||||
if count >= 1_000_000 {
|
if count >= 1_000_000 {
|
||||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
return String(format: "%.1fM", Double(count) / 1_000_000)
|
||||||
|
|||||||
@@ -24,16 +24,22 @@ struct MemoryListView: View {
|
|||||||
Section {
|
Section {
|
||||||
memoryRow(.memory, context: ctx)
|
memoryRow(.memory, context: ctx)
|
||||||
.scarfGoCompactListRow()
|
.scarfGoCompactListRow()
|
||||||
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
memoryRow(.user, context: ctx)
|
memoryRow(.user, context: ctx)
|
||||||
.scarfGoCompactListRow()
|
.scarfGoCompactListRow()
|
||||||
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
memoryRow(.soul, context: ctx)
|
memoryRow(.soul, context: ctx)
|
||||||
.scarfGoCompactListRow()
|
.scarfGoCompactListRow()
|
||||||
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("MEMORY.md and USER.md live under `~/.hermes/memories/`. SOUL.md lives at `~/.hermes/SOUL.md`.")
|
Text("MEMORY.md and USER.md live under `~/.hermes/memories/`. SOUL.md lives at `~/.hermes/SOUL.md`.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scarfGoListDensity()
|
.scarfGoListDensity()
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(ScarfColor.backgroundPrimary)
|
||||||
.navigationTitle("Memory")
|
.navigationTitle("Memory")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ struct ProjectsListView: View {
|
|||||||
Section {
|
Section {
|
||||||
ForEach(topLevel) { project in
|
ForEach(topLevel) { project in
|
||||||
projectRow(project)
|
projectRow(project)
|
||||||
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,11 +75,14 @@ struct ProjectsListView: View {
|
|||||||
Section(folder) {
|
Section(folder) {
|
||||||
ForEach(visibleProjects.filter { $0.folder == folder }) { project in
|
ForEach(visibleProjects.filter { $0.folder == folder }) { project in
|
||||||
projectRow(project)
|
projectRow(project)
|
||||||
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scarfGoListDensity()
|
.scarfGoListDensity()
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(ScarfColor.backgroundPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func projectRow(_ project: ProjectEntry) -> some View {
|
private func projectRow(_ project: ProjectEntry) -> some View {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ struct ServerListView: View {
|
|||||||
Section {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Something went wrong", systemImage: "exclamationmark.triangle.fill")
|
Label("Something went wrong", systemImage: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(ScarfColor.warning)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(err)
|
Text(err)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
@@ -60,6 +60,8 @@ struct ServerListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scarfGoListDensity()
|
.scarfGoListDensity()
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(ScarfColor.backgroundPrimary)
|
||||||
.navigationTitle("Servers")
|
.navigationTitle("Servers")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ struct SettingsView: View {
|
|||||||
if let err = vm.lastError {
|
if let err = vm.lastError {
|
||||||
Section {
|
Section {
|
||||||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(ScarfColor.warning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +49,8 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scarfGoListDensity()
|
.scarfGoListDensity()
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(ScarfColor.backgroundPrimary)
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.refreshable { await vm.load() }
|
.refreshable { await vm.load() }
|
||||||
|
|||||||
@@ -45,11 +45,14 @@ struct InstalledSkillsListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scarfGoCompactListRow()
|
.scarfGoCompactListRow()
|
||||||
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scarfGoListDensity()
|
.scarfGoListDensity()
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(ScarfColor.backgroundPrimary)
|
||||||
.searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always))
|
.searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user