Add webview widget for embedded web browser in project dashboards

New widget type that renders any URL (local dev servers, HTML reports)
directly in the dashboard via WKWebView. Sections with webviews
automatically split layout: grid widgets left, webview right.
Configurable height, non-persistent data store, navigation error logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-02 10:29:05 -04:00
parent 563f5a702c
commit 2a3e8b1422
5 changed files with 170 additions and 9 deletions
+17 -2
View File
@@ -30,7 +30,7 @@
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke) - **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
- **Cron Manager** — View scheduled jobs, their status, prompts, and output - **Cron Manager** — View scheduled jobs, their status, prompts, and output
- **Log Viewer** — Real-time log tailing with level filtering and text search - **Log Viewer** — Real-time log tailing with level filtering and text search
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, and rich text in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically - **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
- **Settings** — Structured config editor for all Hermes settings - **Settings** — Structured config editor for all Hermes settings
- **Menu Bar** — Status icon showing Hermes running state with quick actions - **Menu Bar** — Status icon showing Hermes running state with quick actions
@@ -144,7 +144,7 @@ The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` an
## Project Dashboards ## Project Dashboards
Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, and rich text — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically. Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically.
### What You Can Build ### What You Can Build
@@ -227,6 +227,21 @@ Select your project in the Projects sidebar — the dashboard renders immediatel
| `table` | Data table with headers | `columns`, `rows` | | `table` | Data table with headers | `columns`, `rows` |
| `chart` | Line, bar, or pie chart | `chartType`, `series` (each with `name`, `color`, `data`) | | `chart` | Line, bar, or pie chart | `chartType`, `series` (each with `name`, `color`, `data`) |
| `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) | | `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.
```json
{
"type": "webview",
"title": "Project Report",
"url": "http://localhost:8000/dashboard",
"height": 500
}
```
- `url`: Any URL — typically a local server (`http://localhost:...`) or file path
- `height`: Height in points (default: 400)
**Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray **Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray
+17 -1
View File
@@ -141,13 +141,29 @@ Create `.scarf/dashboard.json` in your project root:
- `status`: "done" (checkmark), "active" (filled circle), "pending" (empty circle) - `status`: "done" (checkmark), "active" (filled circle), "pending" (empty circle)
### webview — Embedded web browser
```json
{
"type": "webview",
"title": "Project Dashboard",
"url": "http://localhost:8000",
"height": 500
}
```
- `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.
## Agent Instructions ## Agent Instructions
To have your Hermes agent generate a dashboard, include these instructions: To have your Hermes agent generate a dashboard, include these instructions:
> Analyze the project and create a `.scarf/dashboard.json` file with relevant metrics, > Analyze the project and create a `.scarf/dashboard.json` file with relevant metrics,
> status indicators, and visualizations. Use the Scarf dashboard schema with sections > status indicators, and visualizations. Use the Scarf dashboard schema with sections
> containing stat, progress, text, table, chart, and list widgets. Register the project > containing stat, progress, text, table, chart, list, and webview widgets. Register the project
> in `~/.hermes/scarf/projects.json` if not already registered. > in `~/.hermes/scarf/projects.json` if not already registered.
The agent can update the dashboard file at any time — Scarf watches for changes and re-renders automatically. The agent can update the dashboard file at any time — Scarf watches for changes and re-renders automatically.
@@ -69,6 +69,10 @@ struct DashboardWidget: Codable, Sendable, Identifiable {
// List // List
let items: [ListItem]? let items: [ListItem]?
// Webview
let url: String?
let height: Double?
} }
// MARK: - Widget Value (String or Number) // MARK: - Widget Value (String or Number)
@@ -152,20 +152,57 @@ struct ProjectsView: View {
struct DashboardSectionView: View { struct DashboardSectionView: View {
let section: DashboardSection let section: DashboardSection
private var gridWidgets: [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 { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(section.title) Text(section.title)
.font(.headline) .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( LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount), columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount),
spacing: 12 spacing: 12
) { ) {
ForEach(section.widgets) { widget in ForEach(gridWidgets) { widget in
WidgetView(widget: widget) WidgetView(widget: widget)
} }
} }
} }
} }
}
} }
// MARK: - Widget Dispatcher // MARK: - Widget Dispatcher
@@ -188,6 +225,8 @@ struct WidgetView: View {
ChartWidgetView(widget: widget) ChartWidgetView(widget: widget)
case "list": case "list":
ListWidgetView(widget: widget) ListWidgetView(widget: widget)
case "webview":
WebviewWidgetView(widget: widget)
default: default:
VStack { VStack {
Image(systemName: "questionmark.square.dashed") Image(systemName: "questionmark.square.dashed")
@@ -0,0 +1,87 @@
import SwiftUI
import WebKit
struct WebviewWidgetView: View {
let widget: DashboardWidget
private var webURL: URL? {
guard let urlString = widget.url else { return nil }
return URL(string: urlString)
}
private var viewHeight: CGFloat {
CGFloat(widget.height ?? 400)
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(.secondary)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if let urlString = widget.url {
Text(urlString)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
if let url = webURL {
WebViewRepresentable(url: url)
.frame(height: viewHeight)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
ContentUnavailableView {
Label("Invalid URL", systemImage: "globe")
} description: {
Text(widget.url ?? "No URL provided")
}
.frame(height: viewHeight)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - WKWebView Wrapper
private struct WebViewRepresentable: NSViewRepresentable {
let url: URL
func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
return webView
}
func updateNSView(_ webView: WKWebView, context: Context) {
if webView.url != url {
webView.load(URLRequest(url: url))
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, WKNavigationDelegate {
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
print("[Scarf] WebView navigation failed: \(error.localizedDescription)")
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
print("[Scarf] WebView failed to load: \(error.localizedDescription)")
}
}
}