From ef53ac1c93e028c4b105dd4070823d8bccbd22e8 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 2 Apr 2026 12:03:50 -0400 Subject: [PATCH] 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) --- README.md | 9 +- scarf/docs/DASHBOARD_SCHEMA.md | 2 +- .../Projects/Views/ProjectsView.swift | 124 +++++++++++------- .../Views/Widgets/WebviewWidgetView.swift | 29 ++++ 4 files changed, 116 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 64807ff..5ffc387 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/scarf/docs/DASHBOARD_SCHEMA.md b/scarf/docs/DASHBOARD_SCHEMA.md index fa4250c..963a6ba 100644 --- a/scarf/docs/DASHBOARD_SCHEMA.md +++ b/scarf/docs/DASHBOARD_SCHEMA.md @@ -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 diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index 43a272d..c84b451 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -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) } } diff --git a/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift index 99ba506..c1d68ac 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift @@ -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 {