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:
Alan Wizemann
2026-04-02 12:03:50 -04:00
parent 2a3e8b1422
commit ef53ac1c93
4 changed files with 116 additions and 48 deletions
+6 -3
View File
@@ -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.
+1 -1
View File
@@ -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) {
VStack(spacing: 0) {
dashboardHeader(dashboard)
ForEach(dashboard.sections) { section in
DashboardSectionView(section: section)
.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 {
if !displayWidgets.isEmpty {
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
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 {