@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)
604 lines
16 KiB
Svelte
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>
|