6 Commits
Author SHA1 Message Date
Algis DumbrisandClaude Opus 4.6 6b65e0130f fix: login shows correct error + brute-force protection
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
- Wrong password now shows "Invalid username or password" (was "Session expired")
- Client API differentiates 401 on login page vs elsewhere
- Added per-IP login rate limiter: 3 failures → blocked 1 minute
- 429 status code returned with remaining seconds in message
- Rate limit cleared on successful login

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:28:19 +02:00
Algis DumbrisandClaude Opus 4.6 726479a57f fix: DM partners query includes owned agents as conversation partners
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
Removed the 'peer NOT IN (owned)' filter that excluded all owned agents.
Since all agents (algis, research-*, social-commenter) are owned by the
same user, the filter was hiding all inter-agent DMs. Now shows all
unique DM partners regardless of ownership.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:35:46 +02:00
Algis DumbrisandClaude Opus 4.6 5dd5a6f23c fix: DM partners query uses SQL over all messages, not just inbox
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 previous implementation only scanned inbox (pending messages),
so historical conversations with read/done messages were invisible.
New GetDMPartners() does a direct SQL query with window functions
to find all unique DM partners with most recent message preview
and unread count. Historical conversations now always show.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:30:10 +02:00
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
11 changed files with 319 additions and 46 deletions
+58
View File
@@ -581,6 +581,64 @@ 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. Queries ALL messages (not just inbox)
// so historical conversations always appear.
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
}
agentNames := make([]string, len(ownedAgents))
for i, a := range ownedAgents {
agentNames[i] = a.Name
}
partners, err := h.msgService.GetDMPartners(r.Context(), agentNames)
if err != nil {
h.logger.Error("get dm partners failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get DM partners"))
return
}
// Resolve display names
type partnerWithDisplay struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
LastMessage string `json:"last_message"`
LastTime string `json:"last_time"`
Unread int `json:"unread"`
}
result := make([]partnerWithDisplay, len(partners))
for i, p := range partners {
result[i] = partnerWithDisplay{
Name: p.Name,
DisplayName: p.Name,
LastMessage: p.LastMessage,
LastTime: p.LastTime,
Unread: p.Unread,
}
if a, err := h.agentService.GetAgent(r.Context(), p.Name); err == nil {
result[i].DisplayName = a.DisplayName
}
}
writeJSON(w, http.StatusOK, map[string]any{"partners": result})
}
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)
+78 -1
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/ory/fosite"
@@ -36,6 +37,63 @@ type Handlers struct {
config Config
agentLister AgentLister
logger *slog.Logger
loginLimiter *loginRateLimiter
}
// loginRateLimiter tracks failed login attempts per IP.
type loginRateLimiter struct {
mu sync.Mutex
attempts map[string]*loginAttempt
}
type loginAttempt struct {
failures int
blockedAt time.Time
}
const (
maxLoginFailures = 3
loginBlockTime = 1 * time.Minute
)
func newLoginRateLimiter() *loginRateLimiter {
return &loginRateLimiter{attempts: make(map[string]*loginAttempt)}
}
func (l *loginRateLimiter) isBlocked(ip string) (bool, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
a, ok := l.attempts[ip]
if !ok {
return false, 0
}
if a.failures >= maxLoginFailures && time.Since(a.blockedAt) < loginBlockTime {
remaining := loginBlockTime - time.Since(a.blockedAt)
return true, remaining
}
if time.Since(a.blockedAt) >= loginBlockTime {
delete(l.attempts, ip)
return false, 0
}
return false, 0
}
func (l *loginRateLimiter) recordFailure(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
a, ok := l.attempts[ip]
if !ok {
a = &loginAttempt{}
l.attempts[ip] = a
}
a.failures++
a.blockedAt = time.Now()
}
func (l *loginRateLimiter) clearFailures(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.attempts, ip)
}
// NewHandlers creates a new set of auth HTTP handlers.
@@ -53,6 +111,7 @@ func NewHandlers(
provider: provider,
config: config,
logger: slog.Default().With("component", "auth"),
loginLimiter: newLoginRateLimiter(),
}
}
@@ -116,6 +175,20 @@ func (h *Handlers) HandleLogin(w http.ResponseWriter, r *http.Request) {
return
}
// Brute-force protection: block IP after 3 failed attempts for 1 minute
ip := remoteIP(r)
if blocked, remaining := h.loginLimiter.isBlocked(ip); blocked {
secs := int(remaining.Seconds()) + 1
LogAuthEvent(r.Context(), h.logger, AuthEvent{
Type: EventLoginFailure,
Username: "(rate-limited)",
RemoteIP: ip,
})
writeError(w, http.StatusTooManyRequests, "rate_limited",
fmt.Sprintf("Too many login attempts. Try again in %d seconds.", secs))
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -128,15 +201,19 @@ func (h *Handlers) HandleLogin(w http.ResponseWriter, r *http.Request) {
user, err := h.userStore.VerifyPassword(r.Context(), req.Username, req.Password)
if err != nil {
h.loginLimiter.recordFailure(ip)
LogAuthEvent(r.Context(), h.logger, AuthEvent{
Type: EventLoginFailure,
Username: req.Username,
RemoteIP: remoteIP(r),
RemoteIP: ip,
})
writeError(w, http.StatusUnauthorized, "invalid_credentials", "Invalid username or password")
return
}
// Successful login — clear rate limit
h.loginLimiter.clearFailures(ip)
session, err := h.sessionStore.CreateSession(r.Context(), user.ID, h.config.SessionLifetime)
if err != nil {
h.logger.Error("create session failed", "error", err)
+5
View File
@@ -531,6 +531,11 @@ func (s *MessagingService) GetDMMessages(ctx context.Context, ownedAgents []stri
return messages, nil
}
// GetDMPartners returns all DM conversation partners for the owned agents.
func (s *MessagingService) GetDMPartners(ctx context.Context, ownedAgents []string) ([]DMPartner, error) {
return s.store.GetDMPartners(ctx, ownedAgents)
}
// GetDMUnreadCounts returns unread DM counts grouped by peer agent.
func (s *MessagingService) GetDMUnreadCounts(ctx context.Context, agentName string) ([]DMUnreadCount, error) {
return s.store.GetDMUnreadCounts(ctx, agentName)
+103
View File
@@ -29,6 +29,7 @@ type MessageStore interface {
GetChannelMessages(ctx context.Context, channelID int64, limit, offset int) ([]*Message, error)
CountChannelMessages(ctx context.Context, channelID int64) (int, error)
GetDMMessages(ctx context.Context, agents []string, peerAgent string, limit int) ([]*Message, error)
GetDMPartners(ctx context.Context, agents []string) ([]DMPartner, error)
AgentExists(ctx context.Context, agentName string) (bool, error)
CountPendingDMs(ctx context.Context, agentName string) (int64, error)
GetPendingDMs(ctx context.Context, agentName string, limit int) ([]*Message, error)
@@ -687,6 +688,108 @@ func (s *SQLiteMessageStore) GetDMMessages(ctx context.Context, agents []string,
return scanMessages(rows)
}
// GetDMPartners returns all unique DM conversation partners for the given human agent,
// with the most recent message preview and unread count. Queries ALL messages (not just
// inbox) so historical conversations always appear. The humanAgent is the primary viewer;
// allAgents includes all owned agents to capture DMs sent to/from any of them.
func (s *SQLiteMessageStore) GetDMPartners(ctx context.Context, allAgents []string) ([]DMPartner, error) {
if len(allAgents) == 0 {
return []DMPartner{}, nil
}
placeholders := make([]string, len(allAgents))
for i := range allAgents {
placeholders[i] = "?"
}
inClause := strings.Join(placeholders, ",")
// For each DM, the "peer" is the other party. When from_agent is owned,
// peer = to_agent. When to_agent is owned, peer = from_agent.
// We want to see ALL conversation partners, including other owned agents
// (e.g., research-mcpproxy sending DMs to algis — both owned by same user).
query := fmt.Sprintf(`
SELECT
peer,
body as last_body,
created_at as last_time,
0 as unread
FROM (
SELECT
CASE
WHEN from_agent IN (%s) THEN to_agent
ELSE from_agent
END as peer,
body,
created_at,
ROW_NUMBER() OVER (
PARTITION BY CASE WHEN from_agent IN (%s) THEN to_agent ELSE from_agent END
ORDER BY created_at DESC
) as rn
FROM messages
WHERE channel_id IS NULL
AND (from_agent IN (%s) OR to_agent IN (%s))
) sub
WHERE rn = 1 AND peer IS NOT NULL AND peer != ''
ORDER BY last_time DESC
LIMIT 50`,
inClause, inClause, inClause, inClause,
)
fullArgs := make([]any, 0, len(allAgents)*4)
for i := 0; i < 4; i++ {
for _, a := range allAgents {
fullArgs = append(fullArgs, a)
}
}
rows, err := s.db.QueryContext(ctx, query, fullArgs...)
if err != nil {
return nil, fmt.Errorf("get dm partners: %w", err)
}
defer rows.Close()
// Track owned agent names for unread counting
ownedSet := make(map[string]bool, len(allAgents))
for _, a := range allAgents {
ownedSet[a] = true
}
var partners []DMPartner
for rows.Next() {
var p DMPartner
var body sql.NullString
if err := rows.Scan(&p.Name, &body, &p.LastTime, &p.Unread); err != nil {
return nil, err
}
if body.Valid && len(body.String) > 80 {
p.LastMessage = body.String[:80] + "..."
} else if body.Valid {
p.LastMessage = body.String
}
partners = append(partners, p)
}
if partners == nil {
partners = []DMPartner{}
}
// Count unread messages per partner (pending DMs TO any owned agent FROM each partner)
for i, p := range partners {
if ownedSet[p.Name] {
continue // Skip unread count for inter-agent DMs
}
var count int
err := s.db.QueryRowContext(ctx,
fmt.Sprintf(`SELECT COUNT(*) FROM messages WHERE channel_id IS NULL AND from_agent = ? AND to_agent IN (%s) AND status IN ('pending', 'processing')`, inClause),
append([]any{p.Name}, fullArgs[:len(allAgents)]...)...,
).Scan(&count)
if err == nil {
partners[i].Unread = count
}
}
return partners, rows.Err()
}
func (s *SQLiteMessageStore) CountPendingDMs(ctx context.Context, agentName string) (int64, error) {
var count int64
err := s.db.QueryRowContext(ctx,
+8
View File
@@ -101,3 +101,11 @@ type DMUnreadCount struct {
UnreadCount int `json:"unread_count"`
LastMessageID int64 `json:"last_message_id"`
}
// DMPartner represents a DM conversation partner with summary info.
type DMPartner struct {
Name string `json:"name"`
LastMessage string `json:"last_message"`
LastTime string `json:"last_time"`
Unread int `json:"unread"`
}
+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.C7Nk8VcW.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BX6BYLlQ.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.BMHPwGGu.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_3d3whq = {
__sveltekit_wis49l = {
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.C7Nk8VcW.js"),
import("/_app/immutable/entry/app.BMHPwGGu.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
+17 -2
View File
@@ -25,13 +25,23 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
const res = await fetch(path, opts);
if (res.status === 401) {
// Redirect to login on auth failure
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
// On login page, pass through the actual error message
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/login')) {
const err = await res.json().catch(() => ({ error: 'unauthorized', message: 'Invalid username or password' }));
throw new ApiError(401, err.error || 'unauthorized', err.message || 'Invalid username or password');
}
// Elsewhere, redirect to login
if (typeof window !== 'undefined') {
window.location.href = `/login?return=${encodeURIComponent(window.location.pathname)}`;
}
throw new ApiError(401, 'unauthorized', 'Session expired');
}
if (res.status === 429) {
const err = await res.json().catch(() => ({ message: 'Too many attempts. Please wait and try again.' }));
throw new ApiError(429, 'rate_limited', err.message || 'Too many attempts. Please wait and try again.');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'unknown', message: res.statusText }));
throw new ApiError(res.status, err.error || 'unknown', err.message || err.error_description || res.statusText);
@@ -83,6 +93,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>