mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
Replace webview split layout with tabbed Dashboard/Site interface
Dashboards with a webview widget now show a tab bar: Dashboard tab renders all normal widgets, Site tab displays the web content full-canvas with even margins. Cleaner UX than the split layout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,7 @@ Project Dashboards turn Scarf into a customizable monitoring hub for all your pr
|
||||
- **Deployment monitors** — deploy history tables, uptime stats, error rate charts
|
||||
- **Research dashboards** — experiment results, key findings, paper status checklists
|
||||
- **Agent activity views** — cron job results, content generation stats, task completion rates
|
||||
- **Embedded web apps** — local dev servers, HTML reports, Grafana dashboards, any web-based tool your agent generates
|
||||
- **Any project status** — if your agent can measure it, Scarf can display it
|
||||
|
||||
### Quick Start
|
||||
@@ -229,7 +230,9 @@ Select your project in the Projects sidebar — the dashboard renders immediatel
|
||||
| `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) |
|
||||
| `webview` | Embedded web browser | `url`, `height` (default 400) |
|
||||
|
||||
The `webview` widget embeds a live web browser directly in your dashboard — perfect for displaying local dev servers, HTML reports, or any web-based tool your agent generates. When a section contains a webview alongside other widgets, Scarf automatically splits the layout: widgets on the left, webview on the right. If the section only has a webview, it takes the full width.
|
||||
The `webview` widget embeds a live web browser directly in your dashboard — perfect for displaying local dev servers, HTML reports, or any web-based tool your agent generates.
|
||||
|
||||
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows your normal widgets, **Site** shows the web content full-canvas with clean margins — using the entire available space in the app. This gives you the best of both worlds: compact metrics at a glance, and a full embedded browser when you need it.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -241,7 +244,7 @@ The `webview` widget embeds a live web browser directly in your dashboard — pe
|
||||
```
|
||||
|
||||
- `url`: Any URL — typically a local server (`http://localhost:...`) or file path
|
||||
- `height`: Height in points (default: 400)
|
||||
- `height`: Height in points when displayed as an inline widget card (default: 400). The Site tab always uses full available space regardless of this setting.
|
||||
|
||||
**Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray
|
||||
|
||||
@@ -251,7 +254,7 @@ The `webview` widget embeds a live web browser directly in your dashboard — pe
|
||||
|
||||
The real power is letting your Hermes agent build and update dashboards automatically. Add instructions like this to your agent's context:
|
||||
|
||||
> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, and lists for task tracking. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
|
||||
> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, lists for task tracking, and a webview widget if the project has a local web server or HTML reports. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
|
||||
|
||||
Your agent can update the dashboard as part of cron jobs, after builds, or whenever project state changes. Since Scarf watches the file, updates appear in real-time.
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ Create `.scarf/dashboard.json` in your project root:
|
||||
- `url`: Any URL — local servers, file paths, or remote pages
|
||||
- `height`: Height in points (optional, default: 400)
|
||||
|
||||
When a section contains a webview alongside other widgets, Scarf splits the layout automatically: grid widgets on the left, webview on the right. If the section contains only a webview, it uses the full width.
|
||||
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows all normal widgets, **Site** displays the web content full-canvas. The webview widget is automatically filtered out of the Dashboard tab's grid layout.
|
||||
|
||||
## Agent Instructions
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
private enum DashboardTab: String, CaseIterable {
|
||||
case dashboard = "Dashboard"
|
||||
case site = "Site"
|
||||
}
|
||||
|
||||
struct ProjectsView: View {
|
||||
@State private var viewModel = ProjectsViewModel()
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@State private var showingAddSheet = false
|
||||
@State private var selectedTab: DashboardTab = .dashboard
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
@@ -76,18 +82,36 @@ struct ProjectsView: View {
|
||||
|
||||
// MARK: - Dashboard Area
|
||||
|
||||
/// First webview widget found across all sections, if any.
|
||||
private var siteWidget: DashboardWidget? {
|
||||
viewModel.dashboard?.sections
|
||||
.flatMap(\.widgets)
|
||||
.first { $0.type == "webview" }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var dashboardArea: some View {
|
||||
if let dashboard = viewModel.dashboard {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
dashboardHeader(dashboard)
|
||||
ForEach(dashboard.sections) { section in
|
||||
DashboardSectionView(section: section)
|
||||
VStack(spacing: 0) {
|
||||
dashboardHeader(dashboard)
|
||||
.padding(.horizontal)
|
||||
.padding(.top)
|
||||
.padding(.bottom, 8)
|
||||
if siteWidget != nil {
|
||||
tabBar
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
switch selectedTab {
|
||||
case .dashboard:
|
||||
widgetsTab(dashboard)
|
||||
case .site:
|
||||
if let widget = siteWidget {
|
||||
siteTab(widget)
|
||||
} else {
|
||||
widgetsTab(dashboard)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
} else if let error = viewModel.dashboardError {
|
||||
ContentUnavailableView {
|
||||
@@ -112,6 +136,48 @@ struct ProjectsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var tabBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(DashboardTab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
selectedTab = tab
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
|
||||
.font(.caption)
|
||||
Text(tab.rawValue)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear)
|
||||
.foregroundStyle(selectedTab == tab ? .primary : .secondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func widgetsTab(_ dashboard: ProjectDashboard) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
ForEach(dashboard.sections) { section in
|
||||
DashboardSectionView(section: section)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
private func siteTab(_ widget: DashboardWidget) -> some View {
|
||||
WebviewWidgetView(widget: widget, fullCanvas: true)
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func dashboardHeader(_ dashboard: ProjectDashboard) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -152,51 +218,21 @@ struct ProjectsView: View {
|
||||
struct DashboardSectionView: View {
|
||||
let section: DashboardSection
|
||||
|
||||
private var gridWidgets: [DashboardWidget] {
|
||||
/// Filter out webview widgets — those are rendered in the Site tab instead.
|
||||
private var displayWidgets: [DashboardWidget] {
|
||||
section.widgets.filter { $0.type != "webview" }
|
||||
}
|
||||
|
||||
private var webviewWidgets: [DashboardWidget] {
|
||||
section.widgets.filter { $0.type == "webview" }
|
||||
}
|
||||
|
||||
private var hasWebview: Bool { !webviewWidgets.isEmpty }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(section.title)
|
||||
.font(.headline)
|
||||
if hasWebview && !gridWidgets.isEmpty {
|
||||
// Split layout: widgets on left, webview on right
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: max(1, section.columnCount / 2)),
|
||||
spacing: 12
|
||||
) {
|
||||
ForEach(gridWidgets) { widget in
|
||||
WidgetView(widget: widget)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
VStack(spacing: 12) {
|
||||
ForEach(webviewWidgets) { widget in
|
||||
WebviewWidgetView(widget: widget)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
} else if hasWebview {
|
||||
// Webview only — full width
|
||||
ForEach(webviewWidgets) { widget in
|
||||
WebviewWidgetView(widget: widget)
|
||||
}
|
||||
} else {
|
||||
// Standard grid
|
||||
if !displayWidgets.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(section.title)
|
||||
.font(.headline)
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount),
|
||||
spacing: 12
|
||||
) {
|
||||
ForEach(gridWidgets) { widget in
|
||||
ForEach(displayWidgets) { widget in
|
||||
WidgetView(widget: widget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import WebKit
|
||||
|
||||
struct WebviewWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
var fullCanvas: Bool = false
|
||||
|
||||
private var webURL: URL? {
|
||||
guard let urlString = widget.url else { return nil }
|
||||
@@ -14,6 +15,34 @@ struct WebviewWidgetView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if fullCanvas {
|
||||
fullCanvasView
|
||||
} else {
|
||||
cardView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Full Canvas (Site tab)
|
||||
|
||||
private var fullCanvasView: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let url = webURL {
|
||||
WebViewRepresentable(url: url)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
} else {
|
||||
ContentUnavailableView {
|
||||
Label("Invalid URL", systemImage: "globe")
|
||||
} description: {
|
||||
Text(widget.url ?? "No URL provided")
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Card (inline widget)
|
||||
|
||||
private var cardView: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
if let icon = widget.icon {
|
||||
|
||||
Reference in New Issue
Block a user