3 Commits
Author SHA1 Message Date
Algis DumbrisandClaude Opus 4.6 3b97429f20 fix: DM sidebar shows conversation partners, not owned agents
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
- New API: GET /api/dm/partners — returns DM conversation partners
  ordered by most recent message, with unread counts
- Sidebar DM section now shows actual conversation partners (agents
  you've exchanged messages with) instead of owned agents
- Each partner shows name, unread badge, clickable to /dm/{name}
- Fixes issue where all DMs were shown mixed in one view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:19:22 +02:00
Algis DumbrisandClaude Opus 4.6 7848911a5f fix: ignore system DMs in reactor — prevent stalemate notification cascade
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
The StaleWorker sends DMs from 'system' to all channel members when
workflow messages are stuck in 'proposed' state. These DMs were
triggering reactive agent runs, which couldn't action the stale
messages, burning daily budget on wasted K8s Jobs.

Now: reactor silently ignores all messages from 'system' sender.
System notifications are for human review, not agent action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:56:37 +02:00
Algis DumbrisandClaude Opus 4.6 4b8c574096 fix: Agent Runs page stuck on Loading — use $effect instead of onMount
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
The onMount + async pattern wasn't triggering Svelte 5 reactivity
properly. Switched to $effect with $user dependency (same pattern
used by Sidebar and other components). Also waits for auth before
loading data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:30:36 +02:00
7 changed files with 159 additions and 43 deletions
+104
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -581,6 +582,109 @@ func (h *MessagesHandler) DMMessages(w http.ResponseWriter, r *http.Request) {
})
}
// DMPartners returns a list of agents the user has DM conversations with,
// ordered by most recent message. Each entry includes the peer agent name,
// last message preview, timestamp, and unread count.
func (h *MessagesHandler) DMPartners(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
return
}
if len(ownedAgents) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"partners": []any{}})
return
}
ownedNames := make(map[string]bool, len(ownedAgents))
for _, a := range ownedAgents {
ownedNames[a.Name] = true
}
// Get recent DMs for all owned agents
type partner struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
LastMessage string `json:"last_message"`
LastTime string `json:"last_time"`
Unread int `json:"unread"`
}
partnerMap := make(map[string]*partner)
for _, agent := range ownedAgents {
opts := messaging.ReadOptions{
Limit: 200,
IncludeRead: true,
}
result, err := h.msgService.ReadInbox(r.Context(), agent.Name, opts)
if err != nil {
continue
}
for _, msg := range result.Messages {
if msg.ChannelID != nil {
continue // skip channel messages
}
// Determine the peer (the other party in the DM)
peer := msg.FromAgent
if ownedNames[peer] {
peer = msg.ToAgent
}
if peer == "" || ownedNames[peer] {
continue // skip self-to-self
}
existing, exists := partnerMap[peer]
if !exists {
partnerMap[peer] = &partner{
Name: peer,
DisplayName: peer,
LastMessage: truncateStr(msg.Body, 80),
LastTime: msg.CreatedAt.Format(time.RFC3339),
Unread: 0,
}
existing = partnerMap[peer]
}
// Track newest message
lt, _ := time.Parse(time.RFC3339, existing.LastTime)
if msg.CreatedAt.After(lt) {
existing.LastMessage = truncateStr(msg.Body, 80)
existing.LastTime = msg.CreatedAt.Format(time.RFC3339)
}
// Count unread (pending messages TO owned agents)
if ownedNames[msg.ToAgent] && (msg.Status == "pending" || msg.Status == "processing") {
existing.Unread++
}
}
}
// Resolve display names
for peer, p := range partnerMap {
if a, err := h.agentService.GetAgent(r.Context(), peer); err == nil {
p.DisplayName = a.DisplayName
}
}
// Sort by last_time descending
partners := make([]*partner, 0, len(partnerMap))
for _, p := range partnerMap {
partners = append(partners, p)
}
sort.Slice(partners, func(i, j int) bool {
return partners[i].LastTime > partners[j].LastTime
})
writeJSON(w, http.StatusOK, map[string]any{"partners": partners})
}
func (h *MessagesHandler) isAgentOwnedBy(r *http.Request, agentName string, ownerID int64) bool {
if agentName == "" {
return false
+1
View File
@@ -131,6 +131,7 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Delete("/api/agents/{name}", agentsHandler.DeleteAgent)
r.Post("/api/agents/{name}/revoke-key", agentsHandler.RevokeKey)
r.Get("/api/agents/{name}/messages", messagesHandler.DMMessages)
r.Get("/api/dm/partners", messagesHandler.DMPartners)
// Notifications
r.Get("/api/notifications/unread", notificationsHandler.UnreadCounts)
+7
View File
@@ -75,6 +75,13 @@ func (r *Reactor) Dispatch(ctx context.Context, event dispatcher.MessageEvent) e
// evaluateTrigger runs the decision chain for a single agent.
func (r *Reactor) evaluateTrigger(ctx context.Context, agentName string, event dispatcher.MessageEvent) error {
// 0. Ignore system messages — stalemate worker notifications, retention warnings,
// and other automated DMs should NOT trigger reactive runs. They're notifications
// meant for the human owner, not actionable work for agents.
if event.FromAgent == "system" {
return nil
}
// 1. Get agent config
agent, err := r.agentStore.GetAgentByName(ctx, agentName)
if err != nil {
+6 -6
View File
@@ -11,30 +11,30 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="/_app/immutable/entry/start.DZCPQR8C.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C-zifBrA.js" rel="modulepreload">
<link href="/_app/immutable/entry/start.DkfAG9pH.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Ll39S8uO.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BK7DUW2U.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CslSvznw.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C_dJMdcr.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Du3f5uIc.js" rel="modulepreload">
<link href="/_app/immutable/chunks/B3RSY5nb.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.BsYzd6w8.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.koRx5eH6.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_3d3whq = {
__sveltekit_v7zte8 = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.DZCPQR8C.js"),
import("/_app/immutable/entry/app.BsYzd6w8.js")
import("/_app/immutable/entry/start.DkfAG9pH.js"),
import("/_app/immutable/entry/app.koRx5eH6.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
+5
View File
@@ -83,6 +83,11 @@ export const conversations = {
get: (id: number) => request<{ conversation: any; messages: any[] }>('GET', `/api/conversations/${id}`)
};
// DM Partners
export const dmPartners = {
list: () => request<{ partners: any[] }>('GET', '/api/dm/partners')
};
// Agents
export const agents = {
list: () => request<{ agents: any[] }>('GET', '/api/agents'),
+17 -28
View File
@@ -3,12 +3,13 @@
import { goto } from '$app/navigation';
import { user, logout } from '$lib/stores/auth';
import { notifications } from '$lib/stores/notifications';
import { channels as channelsApi, agents as agentsApi, deadLetters as deadLettersApi } from '$lib/api/client';
import { channels as channelsApi, agents as agentsApi, deadLetters as deadLettersApi, dmPartners as dmPartnersApi } from '$lib/api/client';
let { open = false, onclose = () => {} }: { open?: boolean; onclose?: () => void } = $props();
let channelList = $state<any[]>([]);
let agentList = $state<any[]>([]);
let dmPartnerList = $state<any[]>([]);
let deadLetterCount = $state(0);
let channelsExpanded = $state(true);
@@ -25,14 +26,16 @@
async function loadSidebarData() {
try {
const [chRes, agRes, dlRes] = await Promise.all([
const [chRes, agRes, dlRes, dmRes] = await Promise.all([
channelsApi.list(),
agentsApi.list(),
deadLettersApi.count().catch(() => ({ count: 0 }))
deadLettersApi.count().catch(() => ({ count: 0 })),
dmPartnersApi.list().catch(() => ({ partners: [] }))
]);
channelList = chRes.channels ?? [];
agentList = agRes.agents ?? [];
deadLetterCount = dlRes.count ?? 0;
dmPartnerList = dmRes.partners ?? [];
} catch {
// handled
}
@@ -55,15 +58,8 @@
return count > 99 ? '99+' : String(count);
}
// Filter DM list: only show human agents + AI agents with unread DMs.
// This hides agent-to-agent internal conversations from the sidebar.
let dmAgentList = $derived(
agentList.filter(agent => {
if (agent.type !== 'ai') return true;
const unread = $notifications.dms.get(agent.name) ?? 0;
return unread > 0;
})
);
// DM partners list is loaded from the API — shows agents you have
// actual conversations with, ordered by most recent message.
const adminLinks = [
{ href: '/agents', label: 'Agents' },
@@ -219,30 +215,23 @@
</button>
{#if dmsExpanded}
<div class="mt-0.5">
{#if dmAgentList.length === 0}
<p class="px-3 py-1 text-xs text-text-secondary italic">No agents</p>
{#if dmPartnerList.length === 0}
<p class="px-3 py-1 text-xs text-text-secondary italic">No conversations</p>
{:else}
{#each dmAgentList as agent}
{@const dmUnread = $notifications.dms.get(agent.name) ?? 0}
{#each dmPartnerList as partner}
<a
href="/dm/{agent.name}"
class="sidebar-item {isActive('/dm/' + agent.name) ? 'sidebar-item-active' : ''}"
href="/dm/{partner.name}"
class="sidebar-item {isActive('/dm/' + partner.name) ? 'sidebar-item-active' : ''}"
onclick={handleNavClick}
>
<span class="relative flex-shrink-0">
<span class="w-5 h-5 rounded-full bg-bg-tertiary flex items-center justify-center text-[10px] font-bold text-text-secondary">
{(agent.display_name || agent.name).charAt(0).toUpperCase()}
{(partner.display_name || partner.name).charAt(0).toUpperCase()}
</span>
<span
class="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-secondary {agent.status === 'active' ? 'bg-accent-green' : 'bg-text-secondary'}"
></span>
</span>
<span class="truncate {dmUnread > 0 ? 'font-bold text-text-primary' : ''}">{agent.display_name || agent.name}</span>
<span class="text-[9px] text-text-secondary flex-shrink-0">(you)</span>
{#if dmUnread > 0}
<span class="ml-auto text-[10px] font-bold text-white bg-accent-red px-1.5 py-0.5 rounded-full min-w-[18px] text-center flex-shrink-0">{badgeText(dmUnread)}</span>
{:else if agent.type === 'ai'}
<span class="ml-auto text-[9px] font-mono text-accent-purple bg-accent-purple/10 px-1 rounded flex-shrink-0">AI</span>
<span class="truncate {partner.unread > 0 ? 'font-bold text-text-primary' : ''}">{partner.display_name || partner.name}</span>
{#if partner.unread > 0}
<span class="ml-auto text-[10px] font-bold text-white bg-accent-red px-1.5 py-0.5 rounded-full min-w-[18px] text-center flex-shrink-0">{badgeText(partner.unread)}</span>
{/if}
</a>
{/each}
+19 -9
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { runs } from '$lib/api/client';
import { onMount } from 'svelte';
import { user } from '$lib/stores/auth';
let runsList = $state<any[]>([]);
let total = $state(0);
@@ -9,11 +9,21 @@
let filterStatus = $state('');
let loading = $state(true);
let expandedRun = $state<number | null>(null);
let _intervalId: ReturnType<typeof setInterval> | null = null;
let _initialized = $state(false);
onMount(() => {
loadData();
const interval = setInterval(loadData, 10000);
return () => clearInterval(interval);
$effect(() => {
if (!_initialized && $user) {
_initialized = true;
loadData();
_intervalId = setInterval(loadData, 10000);
}
return () => {
if (_intervalId) {
clearInterval(_intervalId);
_intervalId = null;
}
};
});
async function loadData() {
@@ -25,8 +35,8 @@
runsList = runsRes.runs ?? [];
total = runsRes.total;
reactiveAgents = agentsRes.agents ?? [];
} catch {
// handled
} catch (e) {
console.error('Failed to load runs data:', e);
}
loading = false;
}
@@ -121,13 +131,13 @@
<!-- Filters -->
<div class="filters">
<select bind:value={filterAgent} onchange={loadData}>
<select bind:value={filterAgent} onchange={() => loadData()}>
<option value="">All agents</option>
{#each reactiveAgents as agent}
<option value={agent.name}>{agent.name}</option>
{/each}
</select>
<select bind:value={filterStatus} onchange={loadData}>
<select bind:value={filterStatus} onchange={() => loadData()}>
<option value="">All statuses</option>
<option value="running">Running</option>
<option value="succeeded">Succeeded</option>