diff --git a/README.md b/README.md index f077b48..64807ff 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ - **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 - **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 - **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 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 @@ -227,6 +227,21 @@ Select your project in the Projects sidebar — the dashboard renders immediatel | `table` | Data table with headers | `columns`, `rows` | | `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) | +| `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 diff --git a/scarf/docs/DASHBOARD_SCHEMA.md b/scarf/docs/DASHBOARD_SCHEMA.md index 65427ea..fa4250c 100644 --- a/scarf/docs/DASHBOARD_SCHEMA.md +++ b/scarf/docs/DASHBOARD_SCHEMA.md @@ -141,13 +141,29 @@ Create `.scarf/dashboard.json` in your project root: - `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 To have your Hermes agent generate a dashboard, include these instructions: > Analyze the project and create a `.scarf/dashboard.json` file with relevant metrics, > 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. The agent can update the dashboard file at any time — Scarf watches for changes and re-renders automatically. diff --git a/scarf/scarf/Core/Models/ProjectDashboard.swift b/scarf/scarf/Core/Models/ProjectDashboard.swift index 681908c..8d8aa86 100644 --- a/scarf/scarf/Core/Models/ProjectDashboard.swift +++ b/scarf/scarf/Core/Models/ProjectDashboard.swift @@ -69,6 +69,10 @@ struct DashboardWidget: Codable, Sendable, Identifiable { // List let items: [ListItem]? + + // Webview + let url: String? + let height: Double? } // MARK: - Widget Value (String or Number) diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index e2bbfc5..43a272d 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -152,16 +152,53 @@ struct ProjectsView: View { struct DashboardSectionView: View { 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 { VStack(alignment: .leading, spacing: 8) { Text(section.title) .font(.headline) - LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount), - spacing: 12 - ) { - ForEach(section.widgets) { widget in - WidgetView(widget: widget) + 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 + WidgetView(widget: widget) + } } } } @@ -188,6 +225,8 @@ struct WidgetView: View { ChartWidgetView(widget: widget) case "list": ListWidgetView(widget: widget) + case "webview": + WebviewWidgetView(widget: widget) default: VStack { Image(systemName: "questionmark.square.dashed") diff --git a/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift new file mode 100644 index 0000000..99ba506 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift @@ -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)") + } + } +}