feat: add Slack-like channel and DM messaging UX

- Channel pages now show message feed with compose bar instead of
  just member lists. Messages display with agent avatars, names,
  and timestamps. Collapsible info panel shows channel details.
- New /dm/[name] route for direct message conversations between
  agents with from→to indicators and status badges.
- Sidebar DM links now navigate to /dm/{name} instead of agent
  edit forms.
- Backend: add GetChannelMessages and GetDMMessages store/service
  methods with corresponding API handlers and routes.
- Makefile: build target now depends on web target so binary
  always embeds latest UI assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-03-14 06:46:07 +02:00
co-authored by Claude Opus 4.6
parent cad0138337
commit f5ae132ef4
13 changed files with 704 additions and 110 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ LDFLAGS := -s -w
CGO_ENABLED := 0
build:
build: web
CGO_ENABLED=$(CGO_ENABLED) go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY) ./cmd/synapbus
test:
@@ -17,7 +17,7 @@ dev:
CGO_ENABLED=$(CGO_ENABLED) go run ./cmd/synapbus serve
web:
cd web && npm install && npm run build
cd web && npm install --legacy-peer-deps && npm run build
rm -rf internal/web/dist
cp -r web/build internal/web/dist
+36
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
@@ -198,6 +199,41 @@ func (h *ChannelsHandler) JoinChannel(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "joined"})
}
// ChannelMessages handles GET /api/channels/{name}/messages.
func (h *ChannelsHandler) ChannelMessages(w http.ResponseWriter, r *http.Request) {
_, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
name := chi.URLParam(r, "name")
ch, err := h.channelService.GetChannelByName(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Channel not found"))
return
}
limit := 100
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
msgs, err := h.msgService.GetChannelMessages(r.Context(), ch.ID, limit)
if err != nil {
h.logger.Error("get channel messages failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get messages"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"messages": msgs,
"total": len(msgs),
})
}
// LeaveChannel handles POST /api/channels/{name}/leave.
func (h *ChannelsHandler) LeaveChannel(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
+45
View File
@@ -425,6 +425,51 @@ func (h *MessagesHandler) GetReplies(w http.ResponseWriter, r *http.Request) {
})
}
// DMMessages handles GET /api/agents/{name}/messages — returns DM messages with a specific agent.
func (h *MessagesHandler) DMMessages(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
peerAgent := chi.URLParam(r, "name")
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil {
h.logger.Error("list agents failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
return
}
if len(ownedAgents) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"messages": []*messaging.Message{}, "total": 0})
return
}
agentNames := make([]string, len(ownedAgents))
for i, a := range ownedAgents {
agentNames[i] = a.Name
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 100
}
msgs, err := h.msgService.GetDMMessages(r.Context(), agentNames, peerAgent, limit)
if err != nil {
h.logger.Error("get dm messages failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get messages"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"messages": msgs,
"total": len(msgs),
})
}
func (h *MessagesHandler) isAgentOwnedBy(r *http.Request, agentName string, ownerID int64) bool {
if agentName == "" {
return false
+2
View File
@@ -100,6 +100,7 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Post("/api/agents", agentsHandler.RegisterAgent)
r.Delete("/api/agents/{name}", agentsHandler.DeleteAgent)
r.Post("/api/agents/{name}/revoke-key", agentsHandler.RevokeKey)
r.Get("/api/agents/{name}/messages", messagesHandler.DMMessages)
})
// API Keys
@@ -124,6 +125,7 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Get("/api/channels", channelsHandler.ListChannels)
r.Get("/api/channels/{name}", channelsHandler.GetChannel)
r.Post("/api/channels", channelsHandler.CreateChannel)
r.Get("/api/channels/{name}/messages", channelsHandler.ChannelMessages)
r.Post("/api/channels/{name}/join", channelsHandler.JoinChannel)
r.Post("/api/channels/{name}/leave", channelsHandler.LeaveChannel)
})
+18
View File
@@ -336,6 +336,24 @@ func (s *MessagingService) GetReplies(ctx context.Context, messageID int64) ([]*
return replies, nil
}
// GetChannelMessages returns messages posted to a channel.
func (s *MessagingService) GetChannelMessages(ctx context.Context, channelID int64, limit int) ([]*Message, error) {
messages, err := s.store.GetChannelMessages(ctx, channelID, limit)
if err != nil {
return nil, fmt.Errorf("get channel messages: %w", err)
}
return messages, nil
}
// GetDMMessages returns direct messages between owned agents and a peer agent.
func (s *MessagingService) GetDMMessages(ctx context.Context, ownedAgents []string, peerAgent string, limit int) ([]*Message, error) {
messages, err := s.store.GetDMMessages(ctx, ownedAgents, peerAgent, limit)
if err != nil {
return nil, fmt.Errorf("get dm messages: %w", err)
}
return messages, nil
}
// GetConversation returns a conversation and its messages.
func (s *MessagingService) GetConversation(ctx context.Context, id int64) (*Conversation, []*Message, error) {
conv, err := s.store.GetConversation(ctx, id)
+65
View File
@@ -23,6 +23,8 @@ type MessageStore interface {
GetConversation(ctx context.Context, id int64) (*Conversation, error)
GetConversationMessages(ctx context.Context, conversationID int64) ([]*Message, error)
GetReplies(ctx context.Context, messageID int64) ([]*Message, error)
GetChannelMessages(ctx context.Context, channelID int64, limit int) ([]*Message, error)
GetDMMessages(ctx context.Context, agents []string, peerAgent string, limit int) ([]*Message, error)
AgentExists(ctx context.Context, agentName string) (bool, error)
}
@@ -443,6 +445,69 @@ func (s *SQLiteMessageStore) GetReplies(ctx context.Context, messageID int64) ([
return scanMessages(rows)
}
func (s *SQLiteMessageStore) GetChannelMessages(ctx context.Context, channelID int64, limit int) ([]*Message, error) {
if limit <= 0 {
limit = 50
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at, reply_to
FROM messages WHERE channel_id = ?
ORDER BY created_at ASC
LIMIT ?`, channelID, limit,
)
if err != nil {
return nil, fmt.Errorf("get channel messages: %w", err)
}
defer rows.Close()
return scanMessages(rows)
}
func (s *SQLiteMessageStore) GetDMMessages(ctx context.Context, agents []string, peerAgent string, limit int) ([]*Message, error) {
if limit <= 0 {
limit = 50
}
if len(agents) == 0 {
return []*Message{}, nil
}
// Build placeholders for owned agents
placeholders := make([]string, len(agents))
args := make([]any, 0, len(agents)*2+2)
for i, a := range agents {
placeholders[i] = "?"
args = append(args, a)
}
inClause := strings.Join(placeholders, ",")
// Messages where (from_agent IN owned AND to_agent = peer) OR (from_agent = peer AND to_agent IN owned)
// and channel_id IS NULL (DMs only)
query := fmt.Sprintf(
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at, reply_to
FROM messages
WHERE channel_id IS NULL
AND ((from_agent IN (%s) AND to_agent = ?) OR (from_agent = ? AND to_agent IN (%s)))
ORDER BY created_at ASC
LIMIT ?`,
inClause, inClause,
)
args = append(args, peerAgent, peerAgent)
for _, a := range agents {
args = append(args, a)
}
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("get dm messages: %w", err)
}
defer rows.Close()
return scanMessages(rows)
}
func (s *SQLiteMessageStore) AgentExists(ctx context.Context, agentName string) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx,
+11 -11
View File
@@ -8,29 +8,29 @@
<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.ogsShEri.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Bqa26XJb.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DUxJZl-L.js" rel="modulepreload">
<link href="/_app/immutable/chunks/8XOTKOPf.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DBvUSRxw.js" rel="modulepreload">
<link href="/_app/immutable/chunks/ja5lAnVg.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Dmx72vI6.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.CiI1z1Wl.js" rel="modulepreload">
<link href="/_app/immutable/entry/start.DokNvh4N.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BKtN1W-k.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Drat7yco.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BMfnc9-7.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DRaHnfMQ.js" rel="modulepreload">
<link href="/_app/immutable/chunks/XQRMlXop.js" rel="modulepreload">
<link href="/_app/immutable/chunks/nOTKGoxu.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.7S4DMCuN.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_19emkgk = {
__sveltekit_2xnllh = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.ogsShEri.js"),
import("/_app/immutable/entry/app.CiI1z1Wl.js")
import("/_app/immutable/entry/start.DokNvh4N.js"),
import("/_app/immutable/entry/app.7S4DMCuN.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
+11 -3
View File
@@ -62,7 +62,7 @@ export const messages = {
return request<{ messages: any[]; total: number }>('GET', `/api/messages${q ? '?' + q : ''}`);
},
get: (id: number) => request<any>('GET', `/api/messages/${id}`),
send: (body: { from?: string; to: string; body: string; priority?: number; subject?: string; channel_id?: number }) =>
send: (body: { from?: string; to?: string; body: string; priority?: number; subject?: string; channel_id?: number }) =>
request<any>('POST', '/api/messages', body),
markDone: (id: number) => request<{ status: string }>('POST', `/api/messages/${id}/done`),
search: (q: string, limit?: number) => {
@@ -86,7 +86,11 @@ export const agents = {
request<{ agent: any; api_key: string }>('POST', '/api/agents', body),
delete: (name: string) => request<{ status: string }>('DELETE', `/api/agents/${encodeURIComponent(name)}`),
revokeKey: (name: string) =>
request<{ agent: any; api_key: string }>('POST', `/api/agents/${encodeURIComponent(name)}/revoke-key`)
request<{ agent: any; api_key: string }>('POST', `/api/agents/${encodeURIComponent(name)}/revoke-key`),
messages: (name: string, limit?: number) => {
const qs = limit ? `?limit=${limit}` : '';
return request<{ messages: any[]; total: number }>('GET', `/api/agents/${encodeURIComponent(name)}/messages${qs}`);
}
};
// Channels
@@ -98,7 +102,11 @@ export const channels = {
join: (name: string, agent?: string) =>
request<{ status: string }>('POST', `/api/channels/${encodeURIComponent(name)}/join`, agent ? { agent } : {}),
leave: (name: string, agent?: string) =>
request<{ status: string }>('POST', `/api/channels/${encodeURIComponent(name)}/leave`, agent ? { agent } : {})
request<{ status: string }>('POST', `/api/channels/${encodeURIComponent(name)}/leave`, agent ? { agent } : {}),
messages: (name: string, limit?: number) => {
const qs = limit ? `?limit=${limit}` : '';
return request<{ messages: any[]; total: number }>('GET', `/api/channels/${encodeURIComponent(name)}/messages${qs}`);
}
};
// API Keys
+1
View File
@@ -19,6 +19,7 @@
if (path === '/conversations') return 'Conversations';
if (path.startsWith('/channels/')) return '#' + ($page.params.name ?? '');
if (path === '/channels') return 'Channels';
if (path.startsWith('/dm/')) return ($page.params.name ?? 'DM');
if (path.startsWith('/agents/')) return ($page.params.name ?? 'Agent');
if (path === '/agents') return 'Agents';
if (path === '/settings/api-keys') return 'API Keys';
+22 -13
View File
@@ -43,17 +43,30 @@
const adminLinks = [
{ href: '/agents', label: 'Agents' },
{ href: '/settings', label: 'Settings' },
{ href: '/settings/api-keys', label: 'API Keys' }
{ href: '/settings', label: 'Settings' }
];
</script>
<aside class="fixed top-0 left-0 z-40 h-screen w-[260px] bg-bg-secondary border-r border-border flex flex-col select-none">
<!-- Workspace header -->
<div class="flex items-center gap-2.5 h-14 px-4 border-b border-border flex-shrink-0">
<div class="w-7 h-7 rounded-lg bg-accent-purple flex items-center justify-center">
<svg class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
<div class="w-7 h-7 rounded-lg bg-gradient-to-br from-accent-purple to-[#06b6d4] flex items-center justify-center">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="5" r="1.8" fill="#c4b5fd" />
<circle cx="6" cy="10" r="1.5" fill="#a78bfa" />
<circle cx="18" cy="9" r="1.5" fill="#c4b5fd" />
<circle cx="12" cy="13" r="2" fill="#67e8f9" />
<circle cx="5" cy="17" r="1.5" fill="#a78bfa" />
<circle cx="19" cy="17" r="1.3" fill="#a78bfa" />
<circle cx="11" cy="20" r="1.3" fill="#c4b5fd" />
<line x1="12" y1="5" x2="6" y2="10" stroke="white" stroke-width="0.5" opacity="0.5" />
<line x1="12" y1="5" x2="18" y2="9" stroke="white" stroke-width="0.5" opacity="0.5" />
<line x1="6" y1="10" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5" />
<line x1="18" y1="9" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5" />
<line x1="12" y1="13" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.5" />
<line x1="12" y1="13" x2="19" y2="17" stroke="white" stroke-width="0.5" opacity="0.5" />
<line x1="5" y1="17" x2="11" y2="20" stroke="white" stroke-width="0.5" opacity="0.3" />
<line x1="6" y1="10" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.3" />
</svg>
</div>
<div class="min-w-0 flex-1">
@@ -174,8 +187,8 @@
{:else}
{#each agentList as agent}
<a
href="/agents/{agent.name}"
class="sidebar-item {isActive('/agents/' + agent.name) ? 'sidebar-item-active' : ''}"
href="/dm/{agent.name}"
class="sidebar-item {isActive('/dm/' + agent.name) ? 'sidebar-item-active' : ''}"
>
<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">
@@ -196,7 +209,7 @@
{/if}
</div>
<!-- Admin section -->
<!-- Manage section -->
<div class="mb-3">
<button
class="section-header w-full hover:text-text-primary transition-colors"
@@ -206,7 +219,7 @@
<svg class="w-3 h-3 transition-transform {adminExpanded ? 'rotate-0' : '-rotate-90'}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Admin
Manage
</span>
</button>
{#if adminExpanded}
@@ -225,10 +238,6 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{:else if link.label === 'API Keys'}
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{/if}
{link.label}
</a>
+1 -1
View File
@@ -55,7 +55,7 @@
<Sidebar />
<div class="ml-[260px] flex-1 flex flex-col min-w-0">
<Header />
<main class="flex-1 overflow-y-auto">
<main class="flex-1 overflow-y-auto flex flex-col">
{@render children()}
</main>
</div>
+256 -80
View File
@@ -1,21 +1,36 @@
<script lang="ts">
import { page } from '$app/stores';
import { channels as channelsApi } from '$lib/api/client';
import { channels as channelsApi, messages as messagesApi, agents as agentsApi } from '$lib/api/client';
let channel = $state<any>(null);
let members = $state<any[]>([]);
let messageList = $state<any[]>([]);
let agentList = $state<any[]>([]);
let loadingData = $state(true);
let joinError = $state('');
let joining = $state(false);
let showInfo = $state(false);
// Compose state
let body = $state('');
let sending = $state(false);
let sendError = $state('');
let channelName = $derived($page.params.name);
let messagesContainer: HTMLDivElement;
async function loadChannel() {
loadingData = true;
try {
const res = await channelsApi.get(channelName);
channel = res.channel;
members = res.members;
const [chRes, agRes] = await Promise.all([
channelsApi.get(channelName),
agentsApi.list()
]);
channel = chRes.channel;
members = chRes.members;
agentList = agRes.agents ?? [];
await loadMessages();
} catch {
// handled
} finally {
@@ -23,6 +38,25 @@
}
}
async function loadMessages() {
if (!channel) return;
try {
const res = await channelsApi.messages(channelName);
messageList = res.messages;
scrollToBottom();
} catch {
// handled
}
}
function scrollToBottom() {
requestAnimationFrame(() => {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
});
}
let _initialized = $state(false);
$effect(() => {
if (!_initialized) {
@@ -44,96 +78,238 @@
}
}
async function handleLeave() {
joining = true;
joinError = '';
async function handleSend() {
if (!body.trim() || !agentList.length) return;
sending = true;
sendError = '';
try {
await channelsApi.leave(channelName);
await loadChannel();
await messagesApi.send({
from: agentList[0].name,
body: body.trim(),
channel_id: channel.id
});
body = '';
await loadMessages();
} catch (err: any) {
joinError = err.message || 'Failed to leave channel';
sendError = err.message || 'Failed to send message';
} finally {
joining = false;
sending = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
function agentColor(name: string): string {
const colors = ['bg-accent-blue', 'bg-accent-green', 'bg-accent-purple', 'bg-accent-yellow', 'bg-accent-red'];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
function formatTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
let isMember = $derived(
members.some(m => agentList.some(a => a.name === m.agent_name))
);
</script>
<div class="p-5 max-w-5xl">
<a href="/channels" class="inline-flex items-center gap-1 text-xs text-text-link hover:underline mb-4">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to channels
</a>
{#if loadingData}
<div class="card p-6">
<div class="skeleton h-6 w-1/4 mb-2"></div>
<div class="skeleton h-4 w-1/2 mb-4"></div>
<div class="skeleton h-20 w-full"></div>
<div class="flex flex-col flex-1 min-h-0 overflow-hidden">
<!-- Channel header -->
<div class="flex items-center justify-between px-5 py-3 border-b border-border flex-shrink-0 bg-bg-secondary">
<div class="flex items-center gap-2 min-w-0">
<span class="text-lg font-bold text-text-primary font-display font-mono">#{channelName}</span>
{#if channel?.description}
<span class="text-xs text-text-secondary truncate hidden sm:inline">— {channel.description}</span>
{/if}
</div>
{:else if channel}
<div class="card mb-5">
<div class="px-5 py-4 border-b border-border">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<h1 class="text-lg font-bold text-text-primary font-display font-mono">#{channel.name}</h1>
{#if channel.is_private}
<span class="badge bg-bg-tertiary text-text-secondary">Private</span>
{/if}
<span class="badge bg-accent-blue/20 text-accent-blue">{channel.type}</span>
</div>
{#if channel.description}
<p class="text-sm text-text-secondary mt-1">{channel.description}</p>
{/if}
{#if channel.topic}
<p class="text-xs text-text-secondary mt-1">Topic: {channel.topic}</p>
{/if}
</div>
<div class="flex gap-2">
<button class="btn-primary text-xs" onclick={handleJoin} disabled={joining}>
{joining ? '...' : 'Join'}
</button>
<button class="btn-secondary text-xs" onclick={handleLeave} disabled={joining}>
Leave
</button>
</div>
</div>
{#if joinError}
<div class="mt-2 text-xs text-accent-red">{joinError}</div>
{/if}
</div>
<div class="flex items-center gap-2 flex-shrink-0">
{#if !isMember}
<button class="btn-primary text-xs" onclick={handleJoin} disabled={joining}>
{joining ? 'Joining...' : 'Join Channel'}
</button>
{/if}
<button
class="p-1.5 rounded hover:bg-bg-tertiary text-text-secondary hover:text-text-primary transition-colors"
title="Channel info"
onclick={() => (showInfo = !showInfo)}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
</div>
<!-- Members -->
<div class="card">
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">Members ({members.length})</h2>
</div>
{#if members.length === 0}
<div class="p-6 text-center text-text-secondary text-sm">No members</div>
{:else}
<div class="divide-y divide-border">
{#each members as member}
<div class="px-5 py-2.5 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="w-6 h-6 rounded-full bg-bg-tertiary flex items-center justify-center text-[10px] font-bold text-text-secondary">
{member.agent_name.charAt(0).toUpperCase()}
</span>
<span class="text-sm text-text-primary">{member.agent_name}</span>
{#if joinError}
<div class="px-5 py-2 bg-accent-red/10 text-xs text-accent-red border-b border-accent-red/20">{joinError}</div>
{/if}
<div class="flex flex-1 min-h-0">
<!-- Messages area -->
<div class="flex flex-col flex-1 min-w-0">
{#if loadingData}
<div class="flex-1 p-5 space-y-4">
{#each Array(5) as _}
<div class="flex gap-3">
<div class="skeleton w-9 h-9 rounded-lg flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="skeleton h-3 w-1/4"></div>
<div class="skeleton h-3 w-2/3"></div>
</div>
<span class="badge {member.role === 'owner' ? 'bg-accent-yellow/20 text-accent-yellow' : 'bg-bg-tertiary text-text-secondary'}">
{member.role}
</span>
</div>
{/each}
</div>
{:else}
<!-- Message list -->
<div class="flex-1 overflow-y-auto" bind:this={messagesContainer}>
{#if messageList.length === 0}
<div class="flex flex-col items-center justify-center h-full text-center p-5">
<div class="w-12 h-12 rounded-2xl bg-accent-blue/10 flex items-center justify-center mb-3">
<span class="text-2xl font-bold text-accent-blue font-mono">#</span>
</div>
<h3 class="text-base font-semibold text-text-primary font-display mb-1">Welcome to #{channelName}</h3>
<p class="text-sm text-text-secondary">
{channel?.description || 'This is the start of the channel.'}
</p>
</div>
{:else}
<div class="py-2">
{#each messageList as msg (msg.id)}
<div class="group px-5 py-2 hover:bg-bg-tertiary/40 transition-colors">
<div class="flex gap-3">
<div class="w-9 h-9 rounded-lg {agentColor(msg.from_agent)} flex items-center justify-center text-sm font-bold text-white flex-shrink-0 mt-0.5">
{msg.from_agent.charAt(0).toUpperCase()}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-0.5">
<span class="font-semibold text-sm text-text-primary">{msg.from_agent}</span>
<span class="text-xs text-text-secondary">{formatTime(msg.created_at)}</span>
</div>
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body}</p>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Compose bar -->
{#if isMember || !channel?.is_private}
<div class="px-4 pb-4 pt-2 flex-shrink-0">
{#if sendError}
<div class="mb-2 px-3 py-1.5 bg-accent-red/10 rounded text-xs text-accent-red">{sendError}</div>
{/if}
{#if agentList.length === 0}
<div class="px-3 py-2 bg-bg-tertiary rounded text-xs text-text-secondary text-center">
Register an agent to send messages
</div>
{:else}
<div class="flex items-end gap-2 bg-bg-tertiary rounded-lg border border-border focus-within:border-border-active transition-colors">
<textarea
placeholder="Message #{channelName}..."
class="flex-1 px-3 py-2.5 bg-transparent text-sm text-text-primary placeholder-text-secondary resize-none outline-none min-h-[40px] max-h-[120px]"
bind:value={body}
rows="1"
onkeydown={handleKeydown}
></textarea>
<button
class="p-2 mr-1 mb-1 rounded-md bg-accent-green text-white hover:brightness-110 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
disabled={sending || !body.trim()}
onclick={handleSend}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
<p class="text-[10px] text-text-secondary mt-1 px-1">Sending as <span class="font-mono">{agentList[0]?.name}</span></p>
{/if}
</div>
{/if}
{/if}
</div>
{:else}
<div class="card p-8 text-center text-text-secondary text-sm">
Channel not found
</div>
{/if}
<!-- Info panel (collapsible) -->
{#if showInfo}
<div class="w-64 border-l border-border bg-bg-secondary flex-shrink-0 overflow-y-auto">
<div class="p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-sm text-text-primary font-display">Channel Info</h3>
<button class="text-text-secondary hover:text-text-primary" onclick={() => (showInfo = false)}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{#if channel}
{#if channel.description}
<div class="mb-3">
<p class="text-xs text-text-secondary mb-0.5">Description</p>
<p class="text-sm text-text-primary">{channel.description}</p>
</div>
{/if}
{#if channel.topic}
<div class="mb-3">
<p class="text-xs text-text-secondary mb-0.5">Topic</p>
<p class="text-sm text-text-primary">{channel.topic}</p>
</div>
{/if}
<div class="mb-3">
<p class="text-xs text-text-secondary mb-0.5">Type</p>
<span class="badge bg-accent-blue/20 text-accent-blue">{channel.type}</span>
</div>
{/if}
<div class="border-t border-border pt-3 mt-3">
<h4 class="text-xs font-medium text-text-secondary mb-2">Members ({members.length})</h4>
{#if members.length === 0}
<p class="text-xs text-text-secondary italic">No members</p>
{:else}
<div class="space-y-1.5">
{#each members as member}
<div class="flex items-center gap-2">
<span class="w-5 h-5 rounded-full bg-bg-tertiary flex items-center justify-center text-[10px] font-bold text-text-secondary">
{member.agent_name.charAt(0).toUpperCase()}
</span>
<span class="text-xs text-text-primary truncate">{member.agent_name}</span>
{#if member.role === 'owner'}
<span class="text-[9px] text-accent-yellow ml-auto">owner</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<div class="border-t border-border pt-3 mt-3 space-y-2">
{#if !isMember}
<button class="btn-primary text-xs w-full" onclick={handleJoin} disabled={joining}>
{joining ? 'Joining...' : 'Join Channel'}
</button>
{:else}
<button class="btn-secondary text-xs w-full" onclick={async () => { await channelsApi.leave(channelName); await loadChannel(); }}>
Leave Channel
</button>
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>
+234
View File
@@ -0,0 +1,234 @@
<script lang="ts">
import { page } from '$app/stores';
import { agents as agentsApi, messages as messagesApi } from '$lib/api/client';
let peerAgent = $derived($page.params.name);
let peer = $state<any>(null);
let messageList = $state<any[]>([]);
let ownAgents = $state<any[]>([]);
let loadingData = $state(true);
// Compose state
let body = $state('');
let sending = $state(false);
let sendError = $state('');
let messagesContainer: HTMLDivElement;
async function loadData() {
loadingData = true;
try {
const [agRes, peerRes] = await Promise.all([
agentsApi.list(),
agentsApi.get(peerAgent).catch(() => null)
]);
ownAgents = agRes.agents ?? [];
peer = peerRes?.agent ?? { name: peerAgent };
await loadMessages();
} catch {
// handled
} finally {
loadingData = false;
}
}
async function loadMessages() {
try {
const res = await agentsApi.messages(peerAgent);
messageList = res.messages;
scrollToBottom();
} catch {
// handled
}
}
function scrollToBottom() {
requestAnimationFrame(() => {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
});
}
let _initialized = $state(false);
$effect(() => {
if (!_initialized) {
_initialized = true;
loadData();
}
});
async function handleSend() {
if (!body.trim() || !ownAgents.length) return;
sending = true;
sendError = '';
try {
await messagesApi.send({
from: ownAgents[0].name,
to: peerAgent,
body: body.trim()
});
body = '';
await loadMessages();
} catch (err: any) {
sendError = err.message || 'Failed to send message';
} finally {
sending = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
function agentColor(name: string): string {
const colors = ['bg-accent-blue', 'bg-accent-green', 'bg-accent-purple', 'bg-accent-yellow', 'bg-accent-red'];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
function formatTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
let isOwnAgent = $derived(ownAgents.some(a => a.name === peerAgent));
</script>
<div class="flex flex-col flex-1 min-h-0 overflow-hidden">
<!-- DM header -->
<div class="flex items-center gap-3 px-5 py-3 border-b border-border flex-shrink-0 bg-bg-secondary">
<div class="relative">
<div class="w-8 h-8 rounded-full {agentColor(peerAgent)} flex items-center justify-center text-sm font-bold text-white">
{(peer?.display_name || peerAgent).charAt(0).toUpperCase()}
</div>
{#if peer?.status === 'active'}
<span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-accent-green border-2 border-bg-secondary"></span>
{/if}
</div>
<div class="min-w-0">
<h2 class="text-sm font-bold text-text-primary font-display truncate">{peer?.display_name || peerAgent}</h2>
<p class="text-[10px] text-text-secondary font-mono">@{peerAgent}</p>
</div>
{#if peer?.type === 'ai'}
<span class="text-[9px] font-mono text-accent-purple bg-accent-purple/10 px-1.5 py-0.5 rounded">AI</span>
{/if}
<div class="ml-auto">
<a
href="/agents/{peerAgent}"
class="p-1.5 rounded hover:bg-bg-tertiary text-text-secondary hover:text-text-primary transition-colors"
title="Agent details"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</a>
</div>
</div>
<!-- Messages -->
{#if loadingData}
<div class="flex-1 p-5 space-y-4">
{#each Array(5) as _}
<div class="flex gap-3">
<div class="skeleton w-9 h-9 rounded-lg flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="skeleton h-3 w-1/4"></div>
<div class="skeleton h-3 w-2/3"></div>
</div>
</div>
{/each}
</div>
{:else}
<div class="flex-1 overflow-y-auto" bind:this={messagesContainer}>
{#if messageList.length === 0}
<div class="flex flex-col items-center justify-center h-full text-center p-5">
<div class="w-12 h-12 rounded-full {agentColor(peerAgent)} flex items-center justify-center text-xl font-bold text-white mb-3">
{(peer?.display_name || peerAgent).charAt(0).toUpperCase()}
</div>
<h3 class="text-base font-semibold text-text-primary font-display mb-1">
{peer?.display_name || peerAgent}
</h3>
<p class="text-sm text-text-secondary">
{#if isOwnAgent}
This is your own agent. Send a test message!
{:else}
Start a conversation with this agent.
{/if}
</p>
</div>
{:else}
<div class="py-2">
{#each messageList as msg (msg.id)}
<div class="group px-5 py-2 hover:bg-bg-tertiary/40 transition-colors">
<div class="flex gap-3">
<div class="w-9 h-9 rounded-lg {agentColor(msg.from_agent)} flex items-center justify-center text-sm font-bold text-white flex-shrink-0 mt-0.5">
{msg.from_agent.charAt(0).toUpperCase()}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-0.5">
<span class="font-semibold text-sm text-text-primary">{msg.from_agent}</span>
{#if msg.to_agent}
<svg class="w-3 h-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span class="text-xs text-text-secondary">{msg.to_agent}</span>
{/if}
<span class="text-xs text-text-secondary">{formatTime(msg.created_at)}</span>
{#if msg.status !== 'done'}
<span class="text-[9px] px-1.5 py-0.5 rounded {msg.status === 'pending' ? 'bg-accent-yellow/20 text-accent-yellow' : msg.status === 'processing' ? 'bg-accent-blue/20 text-accent-blue' : 'bg-bg-tertiary text-text-secondary'}">{msg.status}</span>
{/if}
</div>
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body}</p>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Compose bar -->
<div class="px-4 pb-4 pt-2 flex-shrink-0">
{#if sendError}
<div class="mb-2 px-3 py-1.5 bg-accent-red/10 rounded text-xs text-accent-red">{sendError}</div>
{/if}
{#if ownAgents.length === 0}
<div class="px-3 py-2 bg-bg-tertiary rounded text-xs text-text-secondary text-center">
Register an agent to send messages
</div>
{:else}
<div class="flex items-end gap-2 bg-bg-tertiary rounded-lg border border-border focus-within:border-border-active transition-colors">
<textarea
placeholder="Message {peer?.display_name || peerAgent}..."
class="flex-1 px-3 py-2.5 bg-transparent text-sm text-text-primary placeholder-text-secondary resize-none outline-none min-h-[40px] max-h-[120px]"
bind:value={body}
rows="1"
onkeydown={handleKeydown}
></textarea>
<button
class="p-2 mr-1 mb-1 rounded-md bg-accent-green text-white hover:brightness-110 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
disabled={sending || !body.trim()}
onclick={handleSend}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
<p class="text-[10px] text-text-secondary mt-1 px-1">Sending as <span class="font-mono">{ownAgents[0]?.name}</span></p>
{/if}
</div>
{/if}
</div>