diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift index 16754c5..11c671e 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift @@ -19,6 +19,15 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { public nonisolated let timeoutType: String? public nonisolated let timeoutSeconds: Int? public nonisolated let silent: Bool? + /// Hermes v0.12+ — the directory the job runs from. Hermes injects + /// AGENTS.md / CLAUDE.md / .cursorrules from this dir and uses it + /// as cwd for terminal/file/code_exec tools. `nil` preserves the + /// pre-v0.12 behaviour (no project context files). + public nonisolated let workdir: String? + /// Hermes v0.12+ — chain another cron job's last output into this + /// job's prompt. YAML-only field today (no `--context-from` CLI + /// flag yet) — Scarf displays it but doesn't write it. + public nonisolated let contextFrom: [String]? public enum CodingKeys: String, CodingKey { case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent @@ -30,6 +39,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { case lastDeliveryError = "last_delivery_error" case timeoutType = "timeout_type" case timeoutSeconds = "timeout_seconds" + case workdir + case contextFrom = "context_from" } /// Memberwise init. Swift doesn't synthesize one for us because @@ -53,7 +64,9 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { lastDeliveryError: String? = nil, timeoutType: String? = nil, timeoutSeconds: Int? = nil, - silent: Bool? = nil + silent: Bool? = nil, + workdir: String? = nil, + contextFrom: [String]? = nil ) { self.id = id self.name = name @@ -73,6 +86,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { self.timeoutType = timeoutType self.timeoutSeconds = timeoutSeconds self.silent = silent + self.workdir = workdir + self.contextFrom = contextFrom } public nonisolated init(from decoder: any Decoder) throws { @@ -95,6 +110,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { self.timeoutType = try c.decodeIfPresent(String.self, forKey: .timeoutType) self.timeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .timeoutSeconds) self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent) + self.workdir = try c.decodeIfPresent(String.self, forKey: .workdir) + self.contextFrom = try c.decodeIfPresent([String].self, forKey: .contextFrom) } public nonisolated func encode(to encoder: any Encoder) throws { @@ -117,6 +134,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { try c.encodeIfPresent(timeoutType, forKey: .timeoutType) try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) try c.encodeIfPresent(silent, forKey: .silent) + try c.encodeIfPresent(workdir, forKey: .workdir) + try c.encodeIfPresent(contextFrom, forKey: .contextFrom) } public nonisolated var stateIcon: String { diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanTask.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanTask.swift new file mode 100644 index 0000000..6f4529c --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanTask.swift @@ -0,0 +1,90 @@ +import Foundation + +/// One task from `hermes kanban list --json` (v0.12+). +/// +/// Hermes ships a SQLite-backed task board under `~/.hermes/kanban.db` +/// — multi-profile collaboration was reverted upstream while the +/// design is reworked, so Scarf v2.6 surfaces this as a read-only +/// list. Create / claim / dispatch / dependency-link UI is deferred +/// until upstream stabilizes. +public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable { + public let id: String + public let title: String + public let body: String? + public let assignee: String? + public let status: String // archived | blocked | done | ready | running | todo | triage + public let priority: Int? + public let tenant: String? + public let workspaceKind: String? // scratch | worktree | dir + public let workspacePath: String? + public let createdBy: String? + public let createdAt: String? // ISO timestamp + public let startedAt: String? + public let completedAt: String? + public let result: String? + public let skills: [String] + + public init( + id: String, + title: String, + body: String? = nil, + assignee: String? = nil, + status: String, + priority: Int? = nil, + tenant: String? = nil, + workspaceKind: String? = nil, + workspacePath: String? = nil, + createdBy: String? = nil, + createdAt: String? = nil, + startedAt: String? = nil, + completedAt: String? = nil, + result: String? = nil, + skills: [String] = [] + ) { + self.id = id + self.title = title + self.body = body + self.assignee = assignee + self.status = status + self.priority = priority + self.tenant = tenant + self.workspaceKind = workspaceKind + self.workspacePath = workspacePath + self.createdBy = createdBy + self.createdAt = createdAt + self.startedAt = startedAt + self.completedAt = completedAt + self.result = result + self.skills = skills + } + + enum CodingKeys: String, CodingKey { + case id, title, body, assignee, status, priority, tenant + case workspaceKind = "workspace_kind" + case workspacePath = "workspace_path" + case createdBy = "created_by" + case createdAt = "created_at" + case startedAt = "started_at" + case completedAt = "completed_at" + case result, skills + } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.id = try c.decode(String.self, forKey: .id) + self.title = try c.decode(String.self, forKey: .title) + self.body = try c.decodeIfPresent(String.self, forKey: .body) + self.assignee = try c.decodeIfPresent(String.self, forKey: .assignee) + self.status = try c.decodeIfPresent(String.self, forKey: .status) ?? "unknown" + self.priority = try c.decodeIfPresent(Int.self, forKey: .priority) + self.tenant = try c.decodeIfPresent(String.self, forKey: .tenant) + self.workspaceKind = try c.decodeIfPresent(String.self, forKey: .workspaceKind) + self.workspacePath = try c.decodeIfPresent(String.self, forKey: .workspacePath) + self.createdBy = try c.decodeIfPresent(String.self, forKey: .createdBy) + self.createdAt = try c.decodeIfPresent(String.self, forKey: .createdAt) + self.startedAt = try c.decodeIfPresent(String.self, forKey: .startedAt) + self.completedAt = try c.decodeIfPresent(String.self, forKey: .completedAt) + self.result = try c.decodeIfPresent(String.self, forKey: .result) + self.skills = try c.decodeIfPresent([String].self, forKey: .skills) ?? [] + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesTool.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesTool.swift index db9c04b..36d5848 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesTool.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesTool.swift @@ -53,6 +53,13 @@ public enum KnownPlatforms { HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"), HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"), HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"), + // -- v0.12 additions --------------------------------------------- + // Yuanbao is a native gateway adapter (18th platform); Microsoft + // Teams ships as a plugin (19th). PlatformDetail surfaces the + // distinction in the setup copy. Names match Hermes's gateway + // platform identifiers. + HermesToolPlatform(name: "yuanbao", displayName: "Yuanbao 元宝", icon: "bubble.left.and.bubble.right.fill"), + HermesToolPlatform(name: "microsoft-teams", displayName: "Microsoft Teams", icon: "person.2.fill"), ] public static func icon(for platform: String) -> String { @@ -70,6 +77,8 @@ public enum KnownPlatforms { case "feishu": return "message.badge.circle" case "mattermost": return "bubble.left.and.exclamationmark.bubble.right" case "imessage": return "message.fill" + case "yuanbao": return "bubble.left.and.bubble.right.fill" + case "microsoft-teams": return "person.2.fill" default: return "bubble.left" } } diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift index c28fda2..39a85db 100644 --- a/scarf/scarf/ContentView.swift +++ b/scarf/scarf/ContentView.swift @@ -74,6 +74,7 @@ struct ContentView: View { case .mcpServers: MCPServersView(context: serverContext) case .gateway: GatewayView(context: serverContext) case .cron: CronView(context: serverContext) + case .kanban: KanbanView(context: serverContext) case .health: HealthView(context: serverContext) case .logs: LogsView(context: serverContext) case .settings: SettingsView(context: serverContext) diff --git a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift index 7fce827..025ae7b 100644 --- a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift +++ b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift @@ -131,19 +131,24 @@ final class CronViewModel { } } - func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String) { + func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String, workdir: String = "") { var args = ["cron", "create"] if !name.isEmpty { args += ["--name", name] } if !deliver.isEmpty { args += ["--deliver", deliver] } if !repeatCount.isEmpty { args += ["--repeat", repeatCount] } for skill in skills where !skill.isEmpty { args += ["--skill", skill] } if !script.isEmpty { args += ["--script", script] } + // v0.12+: --workdir injects AGENTS.md/CLAUDE.md context and pins + // cwd for terminal/file/code_exec tools. Hermes pre-v0.12 doesn't + // know the flag — argparse rejects unknown args, so the form + // omits the flag when the field is empty. + if !workdir.isEmpty { args += ["--workdir", workdir] } args.append(schedule) if !prompt.isEmpty { args.append(prompt) } runAndReload(args, success: "Job created") } - func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?) { + func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?, workdir: String? = nil) { var args = ["cron", "edit", id] if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] } if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] } @@ -156,6 +161,7 @@ final class CronViewModel { for skill in newSkills where !skill.isEmpty { args += ["--skill", skill] } } if let script { args += ["--script", script] } + if let workdir, !workdir.isEmpty { args += ["--workdir", workdir] } runAndReload(args, success: "Updated") } diff --git a/scarf/scarf/Features/Cron/Views/CronView.swift b/scarf/scarf/Features/Cron/Views/CronView.swift index 092d632..acb707d 100644 --- a/scarf/scarf/Features/Cron/Views/CronView.swift +++ b/scarf/scarf/Features/Cron/Views/CronView.swift @@ -40,7 +40,8 @@ struct CronView: View { deliver: form.deliver, skills: form.skills, script: form.script, - repeatCount: form.repeatCount + repeatCount: form.repeatCount, + workdir: form.workdir ) viewModel.showCreateSheet = false } onCancel: { @@ -58,7 +59,8 @@ struct CronView: View { repeatCount: form.repeatCount, newSkills: form.skills, clearSkills: form.clearSkills, - script: form.script + script: form.script, + workdir: form.workdir ) viewModel.editingJob = nil } onCancel: { @@ -468,6 +470,9 @@ struct CronJobEditor: View { var skills: [String] = [] var clearSkills: Bool = false var script: String = "" + /// v0.12+ workdir flag — fills `--workdir `. Empty string + /// preserves the v0.11 behaviour of running with no cwd hint. + var workdir: String = "" } let mode: Mode @@ -506,6 +511,7 @@ struct CronJobEditor: View { formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true) formField("Repeat", text: $form.repeatCount, placeholder: "Optional count") formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true) + formField("Workdir", text: $form.workdir, placeholder: "Absolute path; pulls AGENTS.md/CLAUDE.md context (v0.12+)", mono: true) if !availableSkills.isEmpty { VStack(alignment: .leading, spacing: 4) { Text("Skills") @@ -564,6 +570,7 @@ struct CronJobEditor: View { form.deliver = job.deliver ?? "" form.skills = job.skills ?? [] form.script = job.preRunScript ?? "" + form.workdir = job.workdir ?? "" } } } diff --git a/scarf/scarf/Features/Kanban/ViewModels/KanbanViewModel.swift b/scarf/scarf/Features/Kanban/ViewModels/KanbanViewModel.swift new file mode 100644 index 0000000..f6ae882 --- /dev/null +++ b/scarf/scarf/Features/Kanban/ViewModels/KanbanViewModel.swift @@ -0,0 +1,111 @@ +import Foundation +import Observation +import ScarfCore +import os + +/// Read-only view of `hermes kanban list --json`. Multi-profile +/// collaboration was reverted upstream while the design is reworked, +/// so v2.6 ships read-only on Mac and defers create/claim/dispatch UI +/// to v2.7+. +/// +/// Polls every 5s while foregrounded so dispatcher progress is visible +/// without manual refresh; the polling task is suspended when the view +/// disappears so background windows don't keep hammering SSH. +@Observable +@MainActor +final class KanbanViewModel { + private let logger = Logger(subsystem: "com.scarf", category: "KanbanViewModel") + + let context: ServerContext + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.fileService = HermesFileService(context: context) + } + + var tasks: [HermesKanbanTask] = [] + var isLoading = false + var lastError: String? + var statusFilter: StatusFilter = .all + + /// Subset Hermes accepts on `--status`. `.all` skips the flag. + enum StatusFilter: String, CaseIterable, Identifiable { + case all + case triage + case todo + case ready + case running + case blocked + case done + case archived + + var id: String { rawValue } + + var label: String { + switch self { + case .all: return "All" + default: return rawValue.capitalized + } + } + } + + private var pollTask: Task? + + func startPolling() { + stopPolling() + pollTask = Task { [weak self] in + while !Task.isCancelled { + await self?.load() + try? await Task.sleep(nanoseconds: 5_000_000_000) + } + } + } + + func stopPolling() { + pollTask?.cancel() + pollTask = nil + } + + func load() async { + isLoading = true + let svc = fileService + let filter = statusFilter + let result = await Task.detached { () -> (data: Data?, exitCode: Int32, stderr: String) in + var args = ["kanban", "list", "--json"] + if filter != .all { + args.append(contentsOf: ["--status", filter.rawValue]) + } + let r = svc.runHermesCLI(args: args, timeout: 15) + return (r.output.data(using: .utf8), r.exitCode, r.output) + }.value + + defer { isLoading = false } + + guard result.exitCode == 0, let data = result.data else { + lastError = result.stderr.isEmpty + ? "kanban list failed (\(result.exitCode))" + : result.stderr + tasks = [] + return + } + + do { + let decoded = try JSONDecoder().decode([HermesKanbanTask].self, from: data) + tasks = decoded + lastError = nil + } catch { + // Hermes may print a "no matching tasks" line as text instead of + // empty JSON; handle gracefully so the UI shows an empty list + // without raising an error banner. + if String(data: data, encoding: .utf8)?.contains("no matching tasks") == true { + tasks = [] + lastError = nil + return + } + logger.warning("kanban JSON decode failed: \(error.localizedDescription, privacy: .public)") + lastError = "Couldn't parse kanban list output" + tasks = [] + } + } +} diff --git a/scarf/scarf/Features/Kanban/Views/KanbanView.swift b/scarf/scarf/Features/Kanban/Views/KanbanView.swift new file mode 100644 index 0000000..5a3269f --- /dev/null +++ b/scarf/scarf/Features/Kanban/Views/KanbanView.swift @@ -0,0 +1,167 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// Mac UI for `hermes kanban list` (v0.12+). Read-only — create / claim +/// / dispatch / dependency-link UI is deferred until upstream +/// stabilizes the multi-profile collaboration design. +/// +/// Capability-gated upstream: AppCoordinator only routes to this view +/// when `HermesCapabilities.hasKanban` is true. +struct KanbanView: View { + @State private var viewModel: KanbanViewModel + + init(context: ServerContext) { + _viewModel = State(initialValue: KanbanViewModel(context: context)) + } + + var body: some View { + VStack(spacing: 0) { + ScarfPageHeader( + "Kanban", + subtitle: "Hermes v0.12+ task board (read-only)" + ) { + HStack(spacing: ScarfSpace.s2) { + Picker("Status", selection: $viewModel.statusFilter) { + ForEach(KanbanViewModel.StatusFilter.allCases) { f in + Text(f.label).tag(f) + } + } + .pickerStyle(.menu) + .frame(width: 120) + Button { + Task { await viewModel.load() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(ScarfGhostButton()) + } + } + Divider() + + if let err = viewModel.lastError { + errorBanner(err) + } + + ScrollView { + if viewModel.tasks.isEmpty && !viewModel.isLoading { + emptyState + } else { + taskTable + } + } + } + .background(ScarfColor.backgroundPrimary) + .onChange(of: viewModel.statusFilter) { _, _ in + Task { await viewModel.load() } + } + .onAppear { viewModel.startPolling() } + .onDisappear { viewModel.stopPolling() } + } + + private var taskTable: some View { + VStack(spacing: 0) { + ForEach(viewModel.tasks) { task in + taskRow(task) + Divider() + } + } + .padding(ScarfSpace.s3) + } + + private func taskRow(_ task: HermesKanbanTask) -> some View { + HStack(alignment: .top, spacing: ScarfSpace.s3) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: ScarfSpace.s2) { + statusBadge(for: task.status) + Text(task.title) + .scarfStyle(.bodyEmph) + .foregroundStyle(ScarfColor.foregroundPrimary) + .lineLimit(1) + } + HStack(spacing: 12) { + metaChip(systemImage: "number", value: task.id.prefix(8) + "") + if let assignee = task.assignee, !assignee.isEmpty { + metaChip(systemImage: "person.fill", value: assignee) + } + if let workspace = task.workspaceKind { + metaChip(systemImage: "folder", value: workspace) + } + if !task.skills.isEmpty { + metaChip(systemImage: "lightbulb", value: task.skills.joined(separator: ", ")) + } + Spacer(minLength: 0) + } + } + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: 2) { + if let createdAt = task.createdAt { + Text(createdAt) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundFaint) + } + if let priority = task.priority { + Text("p\(priority)") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + } + } + } + .padding(.vertical, ScarfSpace.s2) + } + + private func statusBadge(for status: String) -> some View { + let kind: ScarfBadgeKind + switch status.lowercased() { + case "done": kind = .success + case "running": kind = .info + case "ready": kind = .info + case "blocked": kind = .warning + case "archived": kind = .neutral + default: kind = .neutral + } + return ScarfBadge(status, kind: kind) + } + + private func metaChip(systemImage: String, value: String) -> some View { + HStack(spacing: 3) { + Image(systemName: systemImage) + .font(.system(size: 10)) + Text(value) + .font(ScarfFont.monoSmall) + } + .foregroundStyle(ScarfColor.foregroundMuted) + } + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "rectangle.split.3x1") + .font(.system(size: 36)) + .foregroundStyle(ScarfColor.foregroundFaint) + Text("No kanban tasks") + .scarfStyle(.headline) + .foregroundStyle(ScarfColor.foregroundPrimary) + Text("Create one with `hermes kanban create \"task title\"`. Tasks dispatched by the gateway show up here automatically.") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .multilineTextAlignment(.center) + .frame(maxWidth: 460) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 60) + } + + private func errorBanner(_ message: String) -> some View { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ScarfColor.warning) + Text(message) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundPrimary) + } + .padding(.horizontal, ScarfSpace.s3) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ScarfColor.warning.opacity(0.12)) + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformsView.swift b/scarf/scarf/Features/Platforms/Views/PlatformsView.swift index 928270b..1775428 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformsView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformsView.swift @@ -147,6 +147,8 @@ struct PlatformsView: View { case "imessage": IMessageSetupView(context: ctx) case "homeassistant": HomeAssistantSetupView(context: ctx) case "webhook": WebhookSetupView(context: ctx) + case "yuanbao": yuanbaoPanel + case "microsoft-teams": microsoftTeamsPanel default: SettingsSection(title: LocalizedStringKey(viewModel.selected.displayName), icon: KnownPlatforms.icon(for: viewModel.selected.name)) { ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.") @@ -154,6 +156,30 @@ struct PlatformsView: View { } } + /// Hermes v0.12 — Yuanbao 元宝 ships as a native gateway adapter + /// (the 18th platform). Setup is YAML-driven; we surface the + /// shell command and a docs link rather than a per-field form + /// because the auth dance is OAuth-style and lives outside Scarf. + private var yuanbaoPanel: some View { + SettingsSection(title: "Yuanbao 元宝", icon: KnownPlatforms.icon(for: "yuanbao")) { + ReadOnlyRow(label: "Type", value: "Native gateway adapter (v0.12+)") + ReadOnlyRow(label: "Setup", value: "Run `hermes setup` and select Yuanbao to walk the OAuth flow.") + ReadOnlyRow(label: "Multi-image", value: "Supported via the gateway's centralized media routing.") + ReadOnlyRow(label: "Configured", value: viewModel.hasConfigBlock(for: viewModel.selected) ? "Yes" : "No") + } + } + + /// Hermes v0.12 — Microsoft Teams ships as a plugin (the 19th + /// platform). Surface that explicitly so users know the setup + /// path differs from the native adapters. + private var microsoftTeamsPanel: some View { + SettingsSection(title: "Microsoft Teams", icon: KnownPlatforms.icon(for: "microsoft-teams")) { + ReadOnlyRow(label: "Type", value: "Plugin-shipped gateway platform (v0.12+)") + ReadOnlyRow(label: "Setup", value: "Install the plugin from the Plugins tab, then run `hermes setup` to register the bot.") + ReadOnlyRow(label: "Configured", value: viewModel.hasConfigBlock(for: viewModel.selected) ? "Yes" : "No") + } + } + private var cliPanel: some View { SettingsSection(title: "CLI", icon: "terminal") { ReadOnlyRow(label: "Scope", value: "Local terminal sessions") diff --git a/scarf/scarf/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index b32b5f9..7934c37 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -26,6 +26,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case mcpServers = "MCP Servers" case gateway = "Gateway" case cron = "Cron" + case kanban = "Kanban" case health = "Health" case logs = "Logs" case settings = "Settings" @@ -54,6 +55,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case .mcpServers: return "MCP Servers" case .gateway: return "Messaging Gateway" case .cron: return "Cron" + case .kanban: return "Kanban" case .health: return "Health" case .logs: return "Logs" case .settings: return "Settings" @@ -82,6 +84,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case .mcpServers: return "puzzlepiece.extension" case .gateway: return "antenna.radiowaves.left.and.right" case .cron: return "clock.arrow.2.circlepath" + case .kanban: return "rectangle.split.3x1" case .health: return "stethoscope" case .logs: return "doc.text" case .settings: return "gearshape" diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift index 57771af..4832665 100644 --- a/scarf/scarf/Navigation/SidebarView.swift +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -29,12 +29,18 @@ struct SidebarView: View { } interact.append(.skills) + var manage: [SidebarSection] = [.tools, .mcpServers, .gateway, .cron] + if caps?.hasKanban ?? false { + manage.append(.kanban) + } + manage.append(contentsOf: [.health, .logs, .settings]) + return [ Section(title: "Monitor", items: [.dashboard, .insights, .sessions, .activity]), Section(title: "Projects", items: [.projects]), Section(title: "Interact", items: interact), Section(title: "Configure", items: [.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]), - Section(title: "Manage", items: [.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]), + Section(title: "Manage", items: manage), ] }