From 6913fae47ae7e5bcaab7abe6be575ea1e24bde16 Mon Sep 17 00:00:00 2001 From: Nils Jannasch Date: Sun, 3 May 2026 12:22:26 +0200 Subject: [PATCH] feat: scheduler polish, notifications, chat UI, cost breakdown Scheduler polish: - MCP config injected for RunDirect (scheduled jobs + quick runs get vibecockpit MCP) - Browser + in-app notifications when agents complete/fail - Quick Run "Save as scheduled job" checkbox - Last output preview on scheduler job cards - Minute-level cron presets Cost enhancements: - Per-run cost estimation (matched via session workDir) - Average cost per scheduled job on Scheduler page - Costs page "Agent run costs" card with task/scheduled/quick breakdown Chat UI: - New Chat page with sidebar nav (chat bubble icon) - Chat list + conversational message view - Per-message tool/model override selectors - MCP servers configurable per chat (vibecockpit always included) - Chat history stored as JSON in ~/.config/vibecockpit/chats// - Agent spawned per message with full conversation context as prompt - Markdown rendering for assistant responses (code blocks, bold, headings, lists) - Agent runs in chat directory so artifacts persist Backend: - internal/chat/ package: Manager, Chat, Message, SendOpts - Public runner exports: ToolConfigFor, ResolveBin, BuildArgs, BuildEnvForChat, EnsureMCPConfigPublic - 5 new API endpoints: GET/POST /api/chats, GET/DELETE /api/chats/:id, POST /api/chats/:id/message - GET /api/notifications endpoint for agent completion events --- .mcp.json | 6 +- frontend/src/App.svelte | 44 +- frontend/src/app.css | 1 + frontend/src/components/AgentMonitor.svelte | 68 ++- frontend/src/components/ChatView.svelte | 500 ++++++++++++++++++ frontend/src/components/CostsDashboard.svelte | 43 ++ frontend/src/components/Dashboard.svelte | 20 +- frontend/src/components/Scheduler.svelte | 51 +- internal/chat/chat.go | 371 +++++++++++++ internal/runner/runner.go | 38 ++ internal/web/server.go | 195 ++++++- 11 files changed, 1301 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/ChatView.svelte create mode 100644 internal/chat/chat.go diff --git a/.mcp.json b/.mcp.json index 87f9ad1..0a6328c 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,8 +1,10 @@ { "mcpServers": { "vibecockpit": { - "command": "vibecockpit", - "args": ["--mcp"] + "args": [ + "--mcp" + ], + "command": "vibecockpit" } } } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 27bcc74..f53858b 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -23,10 +23,11 @@ import BoardView from "./components/BoardView.svelte"; import AgentMonitor from "./components/AgentMonitor.svelte"; import Scheduler from "./components/Scheduler.svelte"; + import ChatView from "./components/ChatView.svelte"; // ─── Reactive State (Svelte 5 runes) ─── - const validPages = ["dashboard", "planner", "agents", "scheduler", "sessions", "costs", "inventory", "settings"]; + const validPages = ["dashboard", "planner", "agents", "scheduler", "chat", "sessions", "costs", "inventory", "settings"]; const redirects = { stats: "dashboard", security: "inventory", mcp: "settings", boards: "planner" }; function pageFromPath() { const p = window.location.pathname.replace(/^\/+/, "").split("/")[0]; @@ -127,6 +128,7 @@ unsubGroup(); unsubActiveFilters(); stopAutoRefresh(); + if (notifTimer) clearInterval(notifTimer); window.removeEventListener("popstate", handlePopState); }); @@ -163,6 +165,24 @@ page = pageFromPath(); } + let notifTimer; + + function startNotificationPolling() { + notifTimer = setInterval(async () => { + try { + const r = await fetch("/api/notifications"); + if (!r.ok) return; + const notifs = await r.json(); + for (const n of notifs) { + showToast(n.message, n.type === "completed" ? "success" : "error"); + if (Notification.permission === "granted") { + new Notification(n.title, { body: n.message }); + } + } + } catch { /* ignore */ } + }, 5000); + } + onMount(async () => { window.addEventListener("popstate", handlePopState); await loadConfig(); @@ -174,6 +194,8 @@ } await refresh(false); startAutoRefresh(); + if (Notification.permission === "default") Notification.requestPermission(); + startNotificationPolling(); }); function wizardToggle(id) { @@ -731,6 +753,10 @@ Scheduler + + + {#if chats.length === 0 && !showNewChat} +
No chats yet
+ {/if} + + {#each chats as c (c.id)} + + {/each} + + +
+ {#if showNewChat} +
+

New Chat

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + Comma-separated. vibecockpit is always included. +
+
+ + +
+
+ {:else if activeChat} +
+
+ {activeChat.name} + {activeChat.tool}{activeChat.model ? " / " + activeChat.model : ""}{activeChat.project ? " · " + activeChat.project.split('/').pop() : ""} +
+
+ {#if chatFiles.length > 0} + + {/if} + +
+
+ + {#if showFiles && chatFiles.length > 0} +
+ {#if viewingFile} +
+
+ {viewingFile.name} + +
+
{viewingFile.content}
+
+ {:else} +
+ {#each chatFiles as f (f.name)} + + {/each} +
+ {/if} +
+ {/if} + +
+ {#if activeChat.messages.length === 0} +
Start the conversation. The agent has access to VibeCockpit MCP — ask it to create tasks, schedule jobs, check costs, etc.
+ {/if} + {#each activeChat.messages as msg, i (i)} +
+
+ {msg.role === 'user' ? 'You' : (msg.tool || activeChat.tool)} + {#if msg.model && msg.role === 'assistant'} + {msg.model} + {/if} +
+ {#if msg.role === "assistant"} +
{@html renderMarkdown(msg.content)}
+ {:else} +
{msg.content}
+ {/if} +
+ {/each} + {#if sending} +
+
{activeChat.tool}
+
Thinking...
+
+ {/if} +
+
+ +
+
+ + +
+
+ + + Override per message +
+
+ {:else} +
+

Select a chat or create a new one.

+

Chats have VibeCockpit MCP — ask the agent to create tickets, schedule jobs, check session costs, or work on files in a project.

+
+ {/if} +
+ + + diff --git a/frontend/src/components/CostsDashboard.svelte b/frontend/src/components/CostsDashboard.svelte index dbdd898..34022a4 100644 --- a/frontend/src/components/CostsDashboard.svelte +++ b/frontend/src/components/CostsDashboard.svelte @@ -1,8 +1,29 @@
-
-
-

Scheduler

- Run agents on a schedule — {jobs.length} job{jobs.length !== 1 ? 's' : ''} -
+
+ {jobs.length} job{jobs.length !== 1 ? 's' : ''}
@@ -223,8 +222,10 @@
{:else} + {@const totalJobPages = Math.ceil(jobs.length / jobPageSize)} + {@const pagedJobs = jobs.slice(jobPage * jobPageSize, (jobPage + 1) * jobPageSize)}
- {#each jobs as job (job.id)} + {#each pagedJobs as job (job.id)}
@@ -282,7 +283,11 @@
{/if} -
{job.prompt.length > 140 ? job.prompt.slice(0, 140) + '...' : job.prompt}
+ {#if job.lastOutput} +
{job.lastOutput}
+ {:else} +
{job.prompt.length > 140 ? job.prompt.slice(0, 140) + '...' : job.prompt}
+ {/if}
{/each} + + {#if totalJobPages > 1} +
+ + {jobPage + 1} / {totalJobPages} + +
+ {/if}
{/if}
@@ -484,18 +497,25 @@