From 7a833b6c5a059806f29c5183bd20ef5f9def11ab Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 1 May 2026 12:54:38 +0200 Subject: [PATCH] feat(hermes-v12): Cron workdir + Microsoft Teams + Yuanbao + read-only Kanban (Phase G) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mac-only Phase G surfaces. Three additions: Cron — `--workdir` flag (v0.12+): - HermesCronJob carries `workdir: String?` and `contextFrom: [String]?` fields (the latter is read-only from CLI today; YAML-only chaining). - FormState.workdir; CronJobEditor adds an absolute-path field; CronViewModel.createJob/updateJob forward `--workdir` when set, omit it when blank so v0.11 hosts (which don't know the flag) keep working unchanged. Platforms — Microsoft Teams + Yuanbao (v0.12+): - KnownPlatforms gains the two new platform identifiers + icons. - PlatformsView adds inline read-only setup panels for each since the full setup flow lives outside Scarf (OAuth dance for Yuanbao, plugin install for Teams). Both panels surface the type, the recommended setup command, and the current configured/connected status the existing connectivity probe already understands. Kanban — read-only list (v0.12+): - HermesKanbanTask Sendable Codable model mirroring `_task_to_dict` in hermes_cli/kanban.py. - KanbanViewModel polls `hermes kanban list --json` every 5s while the view is foregrounded; status filter dropdown maps to `--status`. Empty list and "no matching tasks" text outputs both render the empty state cleanly. - KanbanView: page header + status badges + meta chips (id/assignee/workspace/skills) per row. No create/claim/dispatch UI — multi-profile collaboration was reverted upstream while the design is reworked, so v2.6 ships read-only and defers the editor to v2.7+. - AppCoordinator.SidebarSection.kanban + ContentView routing. SidebarView's capability-aware `sections` filters out the row when `HermesCapabilities.hasKanban` is false. Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore/Models/HermesCronJob.swift | 21 ++- .../ScarfCore/Models/HermesKanbanTask.swift | 90 ++++++++++ .../Sources/ScarfCore/Models/HermesTool.swift | 9 + scarf/scarf/ContentView.swift | 1 + .../Cron/ViewModels/CronViewModel.swift | 10 +- .../scarf/Features/Cron/Views/CronView.swift | 11 +- .../Kanban/ViewModels/KanbanViewModel.swift | 111 ++++++++++++ .../Features/Kanban/Views/KanbanView.swift | 167 ++++++++++++++++++ .../Platforms/Views/PlatformsView.swift | 26 +++ scarf/scarf/Navigation/AppCoordinator.swift | 3 + scarf/scarf/Navigation/SidebarView.swift | 8 +- 11 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanTask.swift create mode 100644 scarf/scarf/Features/Kanban/ViewModels/KanbanViewModel.swift create mode 100644 scarf/scarf/Features/Kanban/Views/KanbanView.swift 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), ] }