agent-orchestrator/ui-electrobun/src/mainview/CommsTab.svelte
Hibryda f0850f0785 feat: @agor/stores package (3 stores) + 58 BackendAdapter tests
@agor/stores:
- theme.svelte.ts, notifications.svelte.ts, health.svelte.ts extracted
- Original files replaced with re-exports (zero consumer changes needed)
- pnpm workspace + Vite/tsconfig aliases configured

BackendAdapter tests (58 new):
- backend-adapter.test.ts: 9 tests (lifecycle, singleton, testing seam)
- tauri-adapter.test.ts: 28 tests (invoke mapping, command names, params)
- electrobun-adapter.test.ts: 21 tests (RPC names, capabilities, stubs)

Total: 523 tests passing (was 465, +58)
2026-03-22 04:45:56 +01:00

604 lines
16 KiB
Svelte

<script lang="ts">
import { appRpc } from './rpc.ts';
// ── Types ────────────────────────────────────────────────────────────
interface Channel {
id: string;
name: string;
groupId: string;
createdBy: string;
memberCount: number;
createdAt: string;
}
interface ChannelMessage {
id: string;
channelId: string;
fromAgent: string;
content: string;
createdAt: string;
senderName: string;
senderRole: string;
}
interface Agent {
id: string;
name: string;
role: string;
groupId: string;
tier: number;
status: string;
unreadCount: number;
}
interface DM {
id: string;
fromAgent: string;
toAgent: string;
content: string;
read: boolean;
createdAt: string;
senderName: string | null;
senderRole: string | null;
}
interface Props {
groupId: string;
/** Agent ID for this project's perspective (defaults to 'admin'). */
agentId?: string;
}
let { groupId, agentId = 'admin' }: Props = $props();
// ── State ────────────────────────────────────────────────────────────
type TabMode = 'channels' | 'dms';
let mode = $state<TabMode>('channels');
let channels = $state<Channel[]>([]);
let agents = $state<Agent[]>([]);
let activeChannelId = $state<string | null>(null);
let activeDmAgentId = $state<string | null>(null);
let channelMessages = $state<ChannelMessage[]>([]);
let dmMessages = $state<DM[]>([]);
let input = $state('');
let loading = $state(false);
// Feature 7: Channel member list
let channelMembers = $state<Array<{ agentId: string; name: string; role: string }>>([]);
let showMembers = $state(false);
// ── Data fetching ────────────────────────────────────────────────────
async function loadChannels() {
try {
const res = await appRpc.request['btmsg.listChannels']({ groupId });
channels = res.channels;
if (channels.length > 0 && !activeChannelId) {
activeChannelId = channels[0].id;
await loadChannelMessages(channels[0].id);
}
} catch (err) {
console.error('[CommsTab] loadChannels:', err);
}
}
async function loadAgents() {
try {
const res = await appRpc.request['btmsg.getAgents']({ groupId });
agents = res.agents.filter((a: Agent) => a.id !== agentId);
} catch (err) {
console.error('[CommsTab] loadAgents:', err);
}
}
async function loadChannelMessages(channelId: string) {
try {
loading = true;
const res = await appRpc.request['btmsg.getChannelMessages']({
channelId, limit: 100,
});
channelMessages = res.messages;
} catch (err) {
console.error('[CommsTab] loadChannelMessages:', err);
} finally {
loading = false;
}
}
async function loadDmMessages(otherId: string) {
try {
loading = true;
const res = await appRpc.request['btmsg.listMessages']({
agentId, otherId, limit: 50,
});
dmMessages = res.messages;
} catch (err) {
console.error('[CommsTab] loadDmMessages:', err);
} finally {
loading = false;
}
}
function selectChannel(id: string) {
activeChannelId = id;
showMembers = false;
loadChannelMessages(id);
loadChannelMembers(id);
}
// Feature 7: Load channel members
async function loadChannelMembers(channelId: string) {
try {
const res = await appRpc.request['btmsg.getChannelMembers']({ channelId });
channelMembers = res.members;
} catch (err) {
console.error('[CommsTab] loadChannelMembers:', err);
}
}
function selectDm(otherId: string) {
activeDmAgentId = otherId;
loadDmMessages(otherId);
}
async function sendMessage() {
const text = input.trim();
if (!text) return;
input = '';
try {
if (mode === 'channels' && activeChannelId) {
await appRpc.request['btmsg.sendChannelMessage']({
channelId: activeChannelId, fromAgent: agentId, content: text,
});
await loadChannelMessages(activeChannelId);
} else if (mode === 'dms' && activeDmAgentId) {
await appRpc.request['btmsg.sendMessage']({
fromAgent: agentId, toAgent: activeDmAgentId, content: text,
});
await loadDmMessages(activeDmAgentId);
}
} catch (err) {
console.error('[CommsTab] sendMessage:', err);
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
// ── Init + event-driven updates (Feature 4) ─────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
// Feature 4: Listen for push events
function onNewMessage(payload: { groupId: string; channelId?: string }) {
if (mode === 'channels' && activeChannelId) {
if (!payload.channelId || payload.channelId === activeChannelId) {
loadChannelMessages(activeChannelId);
}
} else if (mode === 'dms' && activeDmAgentId) {
loadDmMessages(activeDmAgentId);
}
}
$effect(() => {
loadChannels();
loadAgents();
appRpc.addMessageListener('btmsg.newMessage', onNewMessage);
// Feature 4: Fallback 30s poll for missed events
pollTimer = setInterval(() => {
if (mode === 'channels' && activeChannelId) {
loadChannelMessages(activeChannelId);
} else if (mode === 'dms' && activeDmAgentId) {
loadDmMessages(activeDmAgentId);
}
}, 30000);
return () => {
if (pollTimer) clearInterval(pollTimer);
appRpc.removeMessageListener?.('btmsg.newMessage', onNewMessage);
};
});
</script>
<div class="comms-tab">
<!-- Mode toggle -->
<div class="comms-mode-bar">
<button
class="mode-btn"
class:active={mode === 'channels'}
onclick={() => { mode = 'channels'; }}
>Channels</button>
<button
class="mode-btn"
class:active={mode === 'dms'}
onclick={() => { mode = 'dms'; loadAgents(); }}
>DMs</button>
</div>
<div class="comms-body">
<!-- Sidebar: channel list or agent list -->
<div class="comms-sidebar">
{#if mode === 'channels'}
{#each channels as ch}
<button
class="sidebar-item"
class:active={activeChannelId === ch.id}
onclick={() => selectChannel(ch.id)}
>
<span class="ch-hash">#</span>
<span class="ch-name">{ch.name}</span>
</button>
{/each}
{#if channels.length === 0}
<div class="sidebar-empty">No channels</div>
{/if}
{:else}
{#each agents as ag}
<button
class="sidebar-item"
class:active={activeDmAgentId === ag.id}
onclick={() => selectDm(ag.id)}
>
<span class="agent-dot {ag.status}"></span>
<span class="agent-name">{ag.name}</span>
<span class="agent-role">{ag.role}</span>
{#if ag.unreadCount > 0}
<span class="unread-badge">{ag.unreadCount}</span>
{/if}
</button>
{/each}
{#if agents.length === 0}
<div class="sidebar-empty">No agents</div>
{/if}
{/if}
</div>
<!-- Message area -->
<div class="comms-messages">
{#if loading}
<div class="msg-loading">Loading...</div>
{:else if mode === 'channels'}
<div class="msg-list">
{#each channelMessages as msg}
<div class="msg-row">
<span class="msg-sender">{msg.senderName}</span>
<span class="msg-role">{msg.senderRole}</span>
<span class="msg-time">{msg.createdAt.slice(11, 16)}</span>
<div class="msg-content">{msg.content}</div>
</div>
{/each}
{#if channelMessages.length === 0}
<div class="msg-empty">No messages in this channel</div>
{/if}
</div>
{:else}
<div class="msg-list">
{#each dmMessages as msg}
<div class="msg-row" class:msg-mine={msg.fromAgent === agentId}>
<span class="msg-sender">{msg.senderName ?? msg.fromAgent}</span>
<span class="msg-time">{msg.createdAt.slice(11, 16)}</span>
<div class="msg-content">{msg.content}</div>
</div>
{/each}
{#if dmMessages.length === 0 && activeDmAgentId}
<div class="msg-empty">No messages yet</div>
{/if}
{#if !activeDmAgentId}
<div class="msg-empty">Select an agent to message</div>
{/if}
</div>
{/if}
<!-- Feature 7: Channel member list toggle -->
{#if mode === 'channels' && activeChannelId}
<button class="members-toggle" onclick={() => showMembers = !showMembers}>
Members ({channelMembers.length})
</button>
{#if showMembers}
<div class="members-list">
{#each channelMembers as m}
<span class="member-chip">{m.name} <span class="member-role">{m.role}</span></span>
{/each}
</div>
{/if}
{/if}
<!-- Input bar -->
<div class="msg-input-bar">
<input
class="msg-input"
type="text"
placeholder={mode === 'channels' ? 'Message channel...' : 'Send DM...'}
bind:value={input}
onkeydown={handleKeydown}
/>
<button class="msg-send-btn" onclick={sendMessage} disabled={!input.trim()}>
Send
</button>
</div>
</div>
</div>
</div>
<style>
.comms-tab {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.comms-mode-bar {
display: flex;
gap: 0.125rem;
padding: 0.25rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.mode-btn {
flex: 1;
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid transparent;
border-radius: 0.25rem;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family);
font-size: 0.75rem;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.mode-btn:hover { color: var(--ctp-text); }
.mode-btn.active {
background: var(--ctp-surface0);
color: var(--ctp-text);
border-color: var(--ctp-surface1);
}
.comms-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.comms-sidebar {
width: 10rem;
flex-shrink: 0;
border-right: 1px solid var(--ctp-surface0);
overflow-y: auto;
padding: 0.25rem 0;
background: var(--ctp-mantle);
}
.sidebar-item {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.375rem 0.5rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family);
font-size: 0.75rem;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.sidebar-item:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
.sidebar-item.active { background: var(--ctp-surface0); color: var(--ctp-text); }
.ch-hash {
color: var(--ctp-overlay0);
font-weight: 700;
flex-shrink: 0;
}
.ch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--ctp-overlay0);
flex-shrink: 0;
}
.agent-dot.active { background: var(--ctp-green); }
.agent-dot.running { background: var(--ctp-green); }
.agent-dot.stopped { background: var(--ctp-overlay0); }
.agent-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-role {
font-size: 0.625rem;
color: var(--ctp-overlay0);
flex-shrink: 0;
}
.unread-badge {
background: var(--ctp-red);
color: var(--ctp-base);
font-size: 0.5625rem;
font-weight: 700;
padding: 0 0.25rem;
border-radius: 0.5rem;
min-width: 0.875rem;
text-align: center;
flex-shrink: 0;
}
.sidebar-empty {
padding: 1rem 0.5rem;
color: var(--ctp-overlay0);
font-size: 0.75rem;
font-style: italic;
text-align: center;
}
.comms-messages {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.msg-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.msg-row {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: baseline;
}
.msg-mine { flex-direction: row-reverse; }
.msg-sender {
font-size: 0.6875rem;
font-weight: 600;
color: var(--ctp-blue);
flex-shrink: 0;
}
.msg-role {
font-size: 0.5625rem;
color: var(--ctp-overlay0);
flex-shrink: 0;
}
.msg-time {
font-size: 0.5625rem;
color: var(--ctp-overlay0);
flex-shrink: 0;
margin-left: auto;
}
.msg-content {
width: 100%;
font-size: 0.8125rem;
color: var(--ctp-text);
line-height: 1.4;
word-break: break-word;
}
.msg-empty, .msg-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--ctp-overlay0);
font-size: 0.8125rem;
font-style: italic;
}
.msg-input-bar {
display: flex;
gap: 0.25rem;
padding: 0.375rem;
border-top: 1px solid var(--ctp-surface0);
background: var(--ctp-mantle);
flex-shrink: 0;
}
.msg-input {
flex: 1;
height: 1.75rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.8125rem;
padding: 0 0.5rem;
outline: none;
}
.msg-input:focus { border-color: var(--ctp-mauve); }
.msg-send-btn {
padding: 0 0.625rem;
height: 1.75rem;
background: color-mix(in srgb, var(--ctp-blue) 20%, transparent);
border: 1px solid var(--ctp-blue);
border-radius: 0.25rem;
color: var(--ctp-blue);
font-family: var(--ui-font-family);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background 0.1s;
flex-shrink: 0;
}
.msg-send-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--ctp-blue) 35%, transparent);
}
.msg-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Feature 7: Member list */
.members-toggle {
background: var(--ctp-surface0);
border: none;
border-top: 1px solid var(--ctp-surface0);
padding: 0.25rem 0.5rem;
font-size: 0.625rem;
color: var(--ctp-overlay1);
cursor: pointer;
text-align: left;
font-family: var(--ui-font-family);
}
.members-toggle:hover { color: var(--ctp-text); }
.members-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--ctp-surface0);
border-top: 1px solid var(--ctp-surface0);
}
.member-chip {
font-size: 0.625rem;
color: var(--ctp-text);
background: var(--ctp-mantle);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid var(--ctp-surface1);
}
.member-role {
color: var(--ctp-overlay0);
font-size: 0.5625rem;
}
</style>