mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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
|
- **Deployment monitors** — deploy history tables, uptime stats, error rate charts
|
||||||
- **Research dashboards** — experiment results, key findings, paper status checklists
|
- **Research dashboards** — experiment results, key findings, paper status checklists
|
||||||
- **Agent activity views** — cron job results, content generation stats, task completion rates
|
- **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
|
- **Any project status** — if your agent can measure it, Scarf can display it
|
||||||
|
|
||||||
### Quick Start
|
### 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) |
|
| `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) |
|
||||||
| `webview` | Embedded web browser | `url`, `height` (default 400) |
|
| `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
|
```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
|
- `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
|
**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:
|
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.
|
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
|
- `url`: Any URL — local servers, file paths, or remote pages
|
||||||
- `height`: Height in points (optional, default: 400)
|
- `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
|
## Agent Instructions
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private enum DashboardTab: String, CaseIterable {
|
||||||
|
case dashboard = "Dashboard"
|
||||||
|
case site = "Site"
|
||||||
|
}
|
||||||
|
|
||||||
struct ProjectsView: View {
|
struct ProjectsView: View {
|
||||||
@State private var viewModel = ProjectsViewModel()
|
@State private var viewModel = ProjectsViewModel()
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
@State private var showingAddSheet = false
|
@State private var showingAddSheet = false
|
||||||
|
@State private var selectedTab: DashboardTab = .dashboard
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HSplitView {
|
HSplitView {
|
||||||
@@ -76,18 +82,36 @@ struct ProjectsView: View {
|
|||||||
|
|
||||||
// MARK: - Dashboard Area
|
// 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
|
@ViewBuilder
|
||||||
private var dashboardArea: some View {
|
private var dashboardArea: some View {
|
||||||
if let dashboard = viewModel.dashboard {
|
if let dashboard = viewModel.dashboard {
|
||||||
ScrollView {
|
VStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
|
||||||
dashboardHeader(dashboard)
|
dashboardHeader(dashboard)
|
||||||
ForEach(dashboard.sections) { section in
|
.padding(.horizontal)
|
||||||
DashboardSectionView(section: section)
|
.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 {
|
} else if let error = viewModel.dashboardError {
|
||||||
ContentUnavailableView {
|
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 {
|
private func dashboardHeader(_ dashboard: ProjectDashboard) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -152,51 +218,21 @@ struct ProjectsView: View {
|
|||||||
struct DashboardSectionView: View {
|
struct DashboardSectionView: View {
|
||||||
let section: DashboardSection
|
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" }
|
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 {
|
||||||
|
if !displayWidgets.isEmpty {
|
||||||
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(gridWidgets) { widget in
|
ForEach(displayWidgets) { widget in
|
||||||
WidgetView(widget: widget)
|
WidgetView(widget: widget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import WebKit
|
|||||||
|
|
||||||
struct WebviewWidgetView: View {
|
struct WebviewWidgetView: View {
|
||||||
let widget: DashboardWidget
|
let widget: DashboardWidget
|
||||||
|
var fullCanvas: Bool = false
|
||||||
|
|
||||||
private var webURL: URL? {
|
private var webURL: URL? {
|
||||||
guard let urlString = widget.url else { return nil }
|
guard let urlString = widget.url else { return nil }
|
||||||
@@ -14,6 +15,34 @@ struct WebviewWidgetView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some 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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
if let icon = widget.icon {
|
if let icon = widget.icon {
|
||||||
|
|||||||
Reference in New Issue
Block a user