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:
co-authored by
Claude Opus 4.6
parent
cad0138337
commit
f5ae132ef4
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+11
-11
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user