fix: UI polish — leave channel, thread panel lifecycle, agent form, and text fixes

- Fix leave channel: handle ErrOwnerCannotLeave with error display, remove
  all owned agents from channel
- Close thread panel when navigating between channels/DMs
- Remove Type dropdown from agent registration (agents are always AI;
  human accounts created via CLI)
- Fix dashboard showing "Untitled conversation" — now shows last agent name
- Fix "1 msgs" → "1 msg" singular form on dashboard
- Fix conversation detail "-- N messages" → "— N message(s)" with em-dash
- Hide "done" status badge in MessageList and conversation detail
  (consistent with DM view behavior)
- Add thread reply buttons and reply count to channel and DM messages
- Add agent selector for multi-agent users in channel and DM compose
- Add "Join Channel" prompt for non-members in channel compose area
- Show "(you)" indicator on channel member list for owned agents
- Add agent detail page with messages endpoint
- Improve release workflow and Dockerfile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-03-14 08:16:54 +02:00
co-authored by Claude Opus 4.6
parent f12824cda9
commit 69e926441e
18 changed files with 462 additions and 90 deletions
+137 -39
View File
@@ -5,40 +5,41 @@ on:
tags:
- "v*"
permissions:
contents: write
packages: write
env:
GO_VERSION: "1.25"
NODE_VERSION: "20"
jobs:
release:
name: Build & Release
build:
name: Build ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
permissions:
contents: write
strategy:
matrix:
include:
- goos: linux
goarch: amd64
suffix: ""
- goos: linux
goarch: arm64
suffix: ""
- goos: darwin
goarch: amd64
suffix: ""
- goos: darwin
goarch: arm64
suffix: ""
- goos: windows
goarch: amd64
suffix: ".exe"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: ${{ env.GO_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
@@ -51,60 +52,154 @@ jobs:
rm -rf internal/web/dist
cp -r web/build internal/web/dist
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Build binary
env:
CGO_ENABLED: "0"
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
go build -ldflags "-s -w" -o synapbus-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.suffix }} ./cmd/synapbus/
SUFFIX=""
if [ "${{ matrix.goos }}" = "windows" ]; then
SUFFIX=".exe"
fi
go build \
-ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" \
-o "synapbus${SUFFIX}" \
./cmd/synapbus/
- name: Create archive
run: |
ARCHIVE_NAME="synapbus-${{ matrix.goos }}-${{ matrix.goarch }}"
if [ "${{ matrix.goos }}" = "windows" ]; then
zip "${ARCHIVE_NAME}.zip" synapbus.exe
else
tar czf "${ARCHIVE_NAME}.tar.gz" synapbus
fi
- uses: actions/upload-artifact@v4
with:
name: synapbus-${{ matrix.goos }}-${{ matrix.goarch }}
path: synapbus-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.suffix }}
path: synapbus-*.tar.gz
if-no-files-found: ignore
publish:
name: Publish Release
needs: release
- uses: actions/upload-artifact@v4
if: matrix.goos == 'windows'
with:
name: synapbus-${{ matrix.goos }}-${{ matrix.goarch }}-zip
path: synapbus-*.zip
if-no-files-found: ignore
homebrew:
name: Generate Homebrew Formula
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Calculate checksums and generate formula
run: |
DARWIN_AMD64_SHA=$(sha256sum artifacts/synapbus-darwin-amd64.tar.gz | awk '{print $1}')
DARWIN_ARM64_SHA=$(sha256sum artifacts/synapbus-darwin-arm64.tar.gz | awk '{print $1}')
LINUX_AMD64_SHA=$(sha256sum artifacts/synapbus-linux-amd64.tar.gz | awk '{print $1}')
LINUX_ARM64_SHA=$(sha256sum artifacts/synapbus-linux-arm64.tar.gz | awk '{print $1}')
VERSION="${{ steps.version.outputs.VERSION }}"
REPO="https://github.com/${{ github.repository }}"
cat > synapbus.rb <<FORMULA
# typed: false
# frozen_string_literal: true
class Synapbus < Formula
desc "Local-first, MCP-native agent-to-agent messaging service"
homepage "${REPO}"
version "${VERSION}"
license "Apache-2.0"
on_macos do
if Hardware::CPU.intel?
url "${REPO}/releases/download/${GITHUB_REF_NAME}/synapbus-darwin-amd64.tar.gz"
sha256 "${DARWIN_AMD64_SHA}"
end
if Hardware::CPU.arm?
url "${REPO}/releases/download/${GITHUB_REF_NAME}/synapbus-darwin-arm64.tar.gz"
sha256 "${DARWIN_ARM64_SHA}"
end
end
on_linux do
if Hardware::CPU.intel?
url "${REPO}/releases/download/${GITHUB_REF_NAME}/synapbus-linux-amd64.tar.gz"
sha256 "${LINUX_AMD64_SHA}"
end
if Hardware::CPU.arm?
url "${REPO}/releases/download/${GITHUB_REF_NAME}/synapbus-linux-arm64.tar.gz"
sha256 "${LINUX_ARM64_SHA}"
end
end
def install
bin.install "synapbus"
end
test do
assert_match version.to_s, shell_output("#{bin}/synapbus --version")
end
end
FORMULA
# Remove leading whitespace from heredoc
sed -i 's/^ //' synapbus.rb
- uses: actions/upload-artifact@v4
with:
name: homebrew-formula
path: synapbus.rb
release:
name: GitHub Release
needs: [build, homebrew]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: List artifacts
run: ls -la artifacts/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: artifacts/*
files: |
artifacts/synapbus-*.tar.gz
artifacts/synapbus-*.zip
artifacts/synapbus.rb
docker:
name: Docker
needs: release
name: Docker Image
needs: build
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build web frontend
run: |
cd web && npm install --legacy-peer-deps && npm run build
- name: Copy web assets
run: |
rm -rf internal/web/dist
cp -r web/build internal/web/dist
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v3
@@ -116,13 +211,14 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version tag
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/smart-mcp-proxy/synapbus
images: ghcr.io/synapbus/synapbus
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- uses: docker/build-push-action@v6
@@ -130,6 +226,8 @@ jobs:
context: .
push: true
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.version.outputs.VERSION }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
+2 -1
View File
@@ -8,13 +8,14 @@ RUN npm run build
# Stage 2: Build Go binary
FROM golang:1.25-alpine AS go-builder
ARG VERSION=dev
RUN apk add --no-cache tzdata
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=web-builder /app/web/build internal/web/dist/
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /synapbus ./cmd/synapbus/
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /synapbus ./cmd/synapbus/
# Stage 3: Runtime
FROM scratch
+2 -1
View File
@@ -3,7 +3,8 @@
BINARY := synapbus
MODULE := github.com/synapbus/synapbus
BUILD_DIR := bin
LDFLAGS := -s -w
VERSION ?= dev
LDFLAGS := -s -w -X main.version=$(VERSION)
CGO_ENABLED := 0
+9 -5
View File
@@ -38,6 +38,9 @@ import (
"github.com/synapbus/synapbus/internal/web"
)
// version is set at build time via -ldflags "-X main.version=..."
var version = "dev"
var (
host string
port int
@@ -50,9 +53,10 @@ var (
func main() {
rootCmd := &cobra.Command{
Use: "synapbus",
Short: "SynapBus — MCP-native agent-to-agent messaging",
Long: "Local-first, MCP-native messaging service for AI agents. Single binary with embedded storage, semantic search, and a Slack-like Web UI.",
Use: "synapbus",
Short: "SynapBus — MCP-native agent-to-agent messaging",
Long: "Local-first, MCP-native messaging service for AI agents. Single binary with embedded storage, semantic search, and a Slack-like Web UI.",
Version: version,
}
serveCmd := &cobra.Command{
@@ -378,7 +382,7 @@ func runServe(cmd *cobra.Command, args []string) error {
slog.Info("task expiry worker started")
// Create health checker
healthChecker := health.NewChecker(db.DB, "0.1.0")
healthChecker := health.NewChecker(db.DB, version)
// Set up chi router
r := chi.NewRouter()
@@ -390,7 +394,7 @@ func runServe(cmd *cobra.Command, args []string) error {
}
// Health endpoint (no auth)
r.Get("/health", mcpserver.NewHealthHandler(mcpSrv.ConnectionManager(), "0.1.0", startTime))
r.Get("/health", mcpserver.NewHealthHandler(mcpSrv.ConnectionManager(), version, startTime))
// Kubernetes-style health probes (no auth, always registered)
r.Get("/healthz", healthChecker.Healthz)
+41
View File
@@ -123,6 +123,47 @@ func (h *AgentsHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
})
}
// UpdateAgent handles PUT /api/agents/{name}.
func (h *AgentsHandler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
name := chi.URLParam(r, "name")
// Verify ownership
agent, err := h.agentService.GetAgent(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Agent not found"))
return
}
if agent.OwnerID != ownerID {
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this agent"))
return
}
var req struct {
DisplayName string `json:"display_name"`
Capabilities json.RawMessage `json:"capabilities,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
return
}
updated, err := h.agentService.UpdateAgent(r.Context(), name, req.DisplayName, req.Capabilities)
if err != nil {
h.logger.Error("update agent failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("update_failed", err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]any{"agent": updated})
}
// DeleteAgent handles DELETE /api/agents/{name}.
func (h *AgentsHandler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
+1
View File
@@ -98,6 +98,7 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Get("/api/agents", agentsHandler.ListAgents)
r.Get("/api/agents/{name}", agentsHandler.GetAgent)
r.Post("/api/agents", agentsHandler.RegisterAgent)
r.Put("/api/agents/{name}", agentsHandler.UpdateAgent)
r.Delete("/api/agents/{name}", agentsHandler.DeleteAgent)
r.Post("/api/agents/{name}/revoke-key", agentsHandler.RevokeKey)
r.Get("/api/agents/{name}/messages", messagesHandler.DMMessages)
+6 -6
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.DokNvh4N.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BKtN1W-k.js" rel="modulepreload">
<link href="/_app/immutable/entry/start.JdeQkmMs.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DLPtJI9G.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">
<link href="/_app/immutable/entry/app.CPDStlOo.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_2xnllh = {
__sveltekit_14joe6 = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.DokNvh4N.js"),
import("/_app/immutable/entry/app.7S4DMCuN.js")
import("/_app/immutable/entry/start.JdeQkmMs.js"),
import("/_app/immutable/entry/app.CPDStlOo.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
+3 -1
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; reply_to?: number }) =>
request<any>('POST', '/api/messages', body),
markDone: (id: number) => request<{ status: string }>('POST', `/api/messages/${id}/done`),
search: (q: string, limit?: number) => {
@@ -84,6 +84,8 @@ export const agents = {
get: (name: string) => request<{ agent: any; traces: any[] }>('GET', `/api/agents/${encodeURIComponent(name)}`),
register: (body: { name: string; display_name?: string; type?: string; capabilities?: object }) =>
request<{ agent: any; api_key: string }>('POST', '/api/agents', body),
update: (name: string, body: { display_name?: string; capabilities?: object }) =>
request<{ agent: any }>('PUT', `/api/agents/${encodeURIComponent(name)}`, 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`),
+4 -1
View File
@@ -8,7 +8,7 @@
created_at: string;
};
let { agent }: { agent: Agent } = $props();
let { agent, ownerName }: { agent: Agent; ownerName?: string | null } = $props();
</script>
<a href="/agents/{agent.name}" class="card p-4 block hover:border-border-active transition-all group">
@@ -22,6 +22,9 @@
{#if agent.display_name}
<p class="text-xs text-text-secondary font-mono">@{agent.name}</p>
{/if}
{#if ownerName}
<p class="text-[10px] text-text-secondary mt-0.5">Owner: {ownerName}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
@@ -91,7 +91,9 @@
<span class="text-sm 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="{statusClass(msg.status)}">{msg.status}</span>
{/if}
{#if msg.priority >= 8}
<span class="badge bg-accent-red/20 text-accent-red">P{msg.priority}</span>
{:else if msg.priority >= 5}
+1
View File
@@ -199,6 +199,7 @@
></span>
</span>
<span class="truncate">{agent.display_name || agent.name}</span>
<span class="text-[9px] text-text-secondary flex-shrink-0">(you)</span>
{#if agent.type === 'ai'}
<span class="ml-auto text-[9px] font-mono text-accent-purple bg-accent-purple/10 px-1 rounded flex-shrink-0">AI</span>
{/if}
+3 -3
View File
@@ -92,14 +92,14 @@
<div class="flex items-center justify-between">
<div class="min-w-0">
<p class="font-medium text-sm text-text-primary truncate">
{conv.subject || 'Untitled conversation'}
{conv.last_agent}
</p>
<p class="text-xs text-text-secondary truncate mt-0.5">
{conv.last_agent}: {conv.last_message}
{conv.last_message}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ml-3">
<span class="badge bg-bg-tertiary text-text-secondary">{conv.message_count} msgs</span>
<span class="badge bg-bg-tertiary text-text-secondary">{conv.message_count} {conv.message_count === 1 ? 'msg' : 'msgs'}</span>
</div>
</div>
</a>
+3 -9
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { agents as agentsApi } from '$lib/api/client';
import { user } from '$lib/stores/auth';
import AgentCard from '$lib/components/AgentCard.svelte';
let agentList = $state<any[]>([]);
@@ -8,7 +9,7 @@
let newName = $state('');
let newDisplayName = $state('');
let newType = $state('ai');
let newType = 'ai'; // agents are always AI; human accounts created via CLI
let registering = $state(false);
let registerError = $state('');
let newApiKey = $state('');
@@ -172,13 +173,6 @@
<label for="agent-display" class="block text-xs font-medium text-text-secondary mb-1">Display name <span class="text-text-secondary">(optional)</span></label>
<input id="agent-display" type="text" class="input" placeholder="e.g. Research Agent" bind:value={newDisplayName} />
</div>
<div>
<label for="agent-type" class="block text-xs font-medium text-text-secondary mb-1">Type</label>
<select id="agent-type" class="input" bind:value={newType}>
<option value="ai">AI Agent</option>
<option value="human">Human Agent</option>
</select>
</div>
<button type="submit" class="btn-primary w-full" disabled={registering || !newName.trim()}>
{registering ? 'Registering...' : 'Register Agent'}
</button>
@@ -209,7 +203,7 @@
{:else}
<div class="grid gap-3 sm:grid-cols-2">
{#each agentList as agent (agent.id)}
<AgentCard {agent} />
<AgentCard {agent} ownerName={$user?.display_name || $user?.username} />
{/each}
</div>
{/if}
+98
View File
@@ -12,6 +12,14 @@
let deleting = $state(false);
let confirmDelete = $state(false);
// Access Rights state
let allowedChannels = $state('');
let readOnly = $state(false);
let maxRate = $state(60);
let savingAccess = $state(false);
let accessSaved = $state(false);
let accessError = $state('');
let agentName = $derived($page.params.name);
async function loadAgent() {
@@ -20,6 +28,11 @@
const res = await agentsApi.get(agentName);
agent = res.agent;
traces = res.traces;
// Populate access rights from capabilities
const caps = agent.capabilities || {};
allowedChannels = (caps.allowed_channels || []).join(', ');
readOnly = caps.read_only ?? false;
maxRate = caps.max_rate ?? 60;
} catch {
// handled
} finally {
@@ -61,6 +74,32 @@
confirmDelete = false;
}
}
async function handleSaveAccess() {
savingAccess = true;
accessError = '';
accessSaved = false;
try {
const channels = allowedChannels
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
const capabilities = {
...(agent.capabilities || {}),
allowed_channels: channels,
read_only: readOnly,
max_rate: maxRate
};
const res = await agentsApi.update(agentName, { capabilities });
agent = res.agent;
accessSaved = true;
setTimeout(() => (accessSaved = false), 3000);
} catch (err: any) {
accessError = err.message || 'Failed to save access rights';
} finally {
savingAccess = false;
}
}
</script>
<div class="p-5 max-w-5xl">
@@ -156,6 +195,65 @@
</div>
</div>
<!-- Access Rights -->
<div class="card mb-5">
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">Access Rights</h2>
</div>
<div class="p-5 space-y-4">
{#if accessError}
<div class="px-3 py-2 bg-accent-red/10 rounded text-xs text-accent-red">{accessError}</div>
{/if}
{#if accessSaved}
<div class="px-3 py-2 bg-accent-green/10 rounded text-xs text-accent-green">Access rights saved successfully.</div>
{/if}
<div>
<label for="allowed-channels" class="block text-xs font-medium text-text-secondary mb-1">Allowed Channels</label>
<input
id="allowed-channels"
type="text"
class="input"
placeholder="e.g. general, project-alpha"
bind:value={allowedChannels}
/>
<p class="text-[10px] text-text-secondary mt-1">Comma-separated list of channel names this agent can access. Leave empty for all channels.</p>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="w-4 h-4 rounded border-border bg-bg-tertiary text-accent-blue focus:ring-accent-blue"
bind:checked={readOnly}
/>
<span class="text-sm text-text-primary">Read-only mode</span>
</label>
<p class="text-[10px] text-text-secondary">Agent can read messages but cannot send.</p>
</div>
<div>
<label for="max-rate" class="block text-xs font-medium text-text-secondary mb-1">Max Message Rate (per minute)</label>
<input
id="max-rate"
type="number"
class="input w-32"
min="1"
max="1000"
bind:value={maxRate}
/>
</div>
<button
class="btn-primary text-xs"
onclick={handleSaveAccess}
disabled={savingAccess}
>
{savingAccess ? 'Saving...' : 'Save Access Rights'}
</button>
</div>
</div>
<!-- Activity Traces -->
<div class="card">
<div class="px-5 py-3 border-b border-border">
+86 -10
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { channels as channelsApi, messages as messagesApi, agents as agentsApi } from '$lib/api/client';
import { openThread, closeThread } from '$lib/stores/thread';
let channel = $state<any>(null);
let members = $state<any[]>([]);
@@ -10,11 +11,13 @@
let joinError = $state('');
let joining = $state(false);
let showInfo = $state(false);
let leaveError = $state('');
// Compose state
let body = $state('');
let sending = $state(false);
let sendError = $state('');
let selectedAgent = $state<any>(null);
let channelName = $derived($page.params.name);
@@ -30,6 +33,9 @@
channel = chRes.channel;
members = chRes.members;
agentList = agRes.agents ?? [];
if (agentList.length > 0 && !selectedAgent) {
selectedAgent = agentList[0];
}
await loadMessages();
} catch {
// handled
@@ -57,14 +63,31 @@
});
}
let _initialized = $state(false);
let _prevChannel = $state('');
$effect(() => {
if (!_initialized) {
_initialized = true;
if (channelName !== _prevChannel) {
_prevChannel = channelName;
closeThread();
loadChannel();
}
});
async function handleLeave() {
leaveError = '';
try {
// Leave all owned agents from the channel
const memberAgents = members
.filter(m => agentList.some(a => a.name === m.agent_name))
.map(m => m.agent_name);
for (const agentName of memberAgents) {
await channelsApi.leave(channelName, agentName);
}
await loadChannel();
} catch (err: any) {
leaveError = err.message || 'Failed to leave channel';
}
}
async function handleJoin() {
joining = true;
joinError = '';
@@ -79,12 +102,12 @@
}
async function handleSend() {
if (!body.trim() || !agentList.length) return;
if (!body.trim() || !selectedAgent) return;
sending = true;
sendError = '';
try {
await messagesApi.send({
from: agentList[0].name,
from: selectedAgent.name,
body: body.trim(),
channel_id: channel.id
});
@@ -190,7 +213,7 @@
{: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="group px-5 py-2 hover:bg-bg-tertiary/40 transition-colors relative">
<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()}
@@ -201,7 +224,28 @@
<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>
{#if msg.reply_count > 0}
<button
class="mt-1 flex items-center gap-1 text-xs text-accent-blue hover:underline"
onclick={() => openThread(msg.id, msg.conversation_id)}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</svg>
{msg.reply_count} {msg.reply_count === 1 ? 'reply' : 'replies'}
</button>
{/if}
</div>
<!-- Reply button (visible on hover) -->
<button
class="absolute top-1.5 right-3 p-1 rounded hover:bg-bg-tertiary text-text-secondary hover:text-text-primary opacity-0 group-hover:opacity-100 transition-opacity"
title="Reply in thread"
onclick={() => openThread(msg.id, msg.conversation_id)}
>
<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="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</svg>
</button>
</div>
</div>
{/each}
@@ -210,7 +254,7 @@
</div>
<!-- Compose bar -->
{#if isMember || !channel?.is_private}
{#if isMember}
<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>
@@ -238,9 +282,35 @@
</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>
<div class="flex items-center gap-2 mt-1 px-1">
<p class="text-[10px] text-text-secondary">Sending as <span class="font-mono">{selectedAgent?.display_name || selectedAgent?.name}</span></p>
{#if agentList.length > 1}
<select
class="text-[10px] bg-bg-tertiary border border-border rounded px-1 py-0.5 text-text-secondary outline-none"
onchange={(e) => {
const target = e.target as HTMLSelectElement;
selectedAgent = agentList.find(a => a.name === target.value) ?? agentList[0];
}}
>
{#each agentList as agent}
<option value={agent.name} selected={agent.name === selectedAgent?.name}>
{agent.display_name || agent.name}
</option>
{/each}
</select>
{/if}
</div>
{/if}
</div>
{:else}
<div class="px-4 pb-4 pt-2 flex-shrink-0">
<div class="flex items-center justify-center gap-3 px-4 py-3 bg-bg-tertiary rounded-lg border border-border">
<p class="text-sm text-text-secondary">Join this channel to participate</p>
<button class="btn-primary text-xs" onclick={handleJoin} disabled={joining}>
{joining ? 'Joining...' : 'Join Channel'}
</button>
</div>
</div>
{/if}
{/if}
</div>
@@ -288,8 +358,11 @@
{member.agent_name.charAt(0).toUpperCase()}
</span>
<span class="text-xs text-text-primary truncate">{member.agent_name}</span>
{#if agentList.some(a => a.name === member.agent_name)}
<span class="text-[9px] text-accent-green ml-auto">(you)</span>
{/if}
{#if member.role === 'owner'}
<span class="text-[9px] text-accent-yellow ml-auto">owner</span>
<span class="text-[9px] text-accent-yellow {agentList.some(a => a.name === member.agent_name) ? '' : 'ml-auto'}">owner</span>
{/if}
</div>
{/each}
@@ -298,12 +371,15 @@
</div>
<div class="border-t border-border pt-3 mt-3 space-y-2">
{#if leaveError}
<div class="px-2 py-1.5 bg-accent-red/10 rounded text-[11px] text-accent-red">{leaveError}</div>
{/if}
{#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(); }}>
<button class="btn-secondary text-xs w-full" onclick={handleLeave}>
Leave Channel
</button>
{/if}
+10 -6
View File
@@ -86,12 +86,16 @@
{#each convList as conv}
<a href="/conversations/{conv.id}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
<div class="flex items-center justify-between">
<div class="min-w-0">
<p class="font-medium text-sm text-text-primary truncate">
{conv.subject || 'Untitled'}
</p>
<p class="text-xs text-text-secondary truncate mt-0.5">
{conv.last_agent}: {conv.last_message}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-0.5">
{#if conv.last_agent}
<span class="font-semibold text-sm text-text-primary">{conv.last_agent}</span>
<span class="text-[10px] text-text-secondary">&middot;</span>
{/if}
<span class="text-xs text-text-secondary">{conv.updated_at ? new Date(conv.updated_at).toLocaleString() : ''}</span>
</div>
<p class="text-sm text-text-primary/80 truncate">
{conv.last_message ? (conv.last_message.length > 120 ? conv.last_message.slice(0, 120) + '...' : conv.last_message) : 'No messages'}
</p>
</div>
<span class="badge bg-bg-tertiary text-text-secondary flex-shrink-0 ml-3">
@@ -123,7 +123,7 @@
{conversation.subject || 'Conversation #' + conversation.id}
</h1>
<p class="text-[11px] text-text-secondary">
Started by {conversation.created_by} -- {messagesList.length} messages
Started by {conversation.created_by} — {messagesList.length} {messagesList.length === 1 ? 'message' : 'messages'}
</p>
</div>
</div>
@@ -146,7 +146,9 @@
</svg>
<span class="text-xs text-text-secondary">{msg.to_agent}</span>
{/if}
{#if msg.status !== 'done'}
<span class="{statusBadge(msg.status)}">{msg.status}</span>
{/if}
<span class="text-xs text-text-secondary ml-auto">{formatTime(msg.created_at)}</span>
</div>
<div class="text-sm text-text-primary/90 whitespace-pre-wrap leading-relaxed">{msg.body}</div>
+51 -7
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { agents as agentsApi, messages as messagesApi } from '$lib/api/client';
import { openThread, closeThread } from '$lib/stores/thread';
let peerAgent = $derived($page.params.name);
let peer = $state<any>(null);
@@ -12,6 +13,7 @@
let body = $state('');
let sending = $state(false);
let sendError = $state('');
let selectedAgent = $state<any>(null);
let messagesContainer: HTMLDivElement;
@@ -24,6 +26,9 @@
]);
ownAgents = agRes.agents ?? [];
peer = peerRes?.agent ?? { name: peerAgent };
if (ownAgents.length > 0 && !selectedAgent) {
selectedAgent = ownAgents[0];
}
await loadMessages();
} catch {
// handled
@@ -50,21 +55,22 @@
});
}
let _initialized = $state(false);
let _prevPeer = $state('');
$effect(() => {
if (!_initialized) {
_initialized = true;
if (peerAgent !== _prevPeer) {
_prevPeer = peerAgent;
closeThread();
loadData();
}
});
async function handleSend() {
if (!body.trim() || !ownAgents.length) return;
if (!body.trim() || !selectedAgent) return;
sending = true;
sendError = '';
try {
await messagesApi.send({
from: ownAgents[0].name,
from: selectedAgent.name,
to: peerAgent,
body: body.trim()
});
@@ -171,7 +177,7 @@
{: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="group px-5 py-2 hover:bg-bg-tertiary/40 transition-colors relative">
<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()}
@@ -191,7 +197,28 @@
{/if}
</div>
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body}</p>
{#if msg.reply_count > 0}
<button
class="mt-1 flex items-center gap-1 text-xs text-accent-blue hover:underline"
onclick={() => openThread(msg.id, msg.conversation_id)}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</svg>
{msg.reply_count} {msg.reply_count === 1 ? 'reply' : 'replies'}
</button>
{/if}
</div>
<!-- Reply button (visible on hover) -->
<button
class="absolute top-1.5 right-3 p-1 rounded hover:bg-bg-tertiary text-text-secondary hover:text-text-primary opacity-0 group-hover:opacity-100 transition-opacity"
title="Reply in thread"
onclick={() => openThread(msg.id, msg.conversation_id)}
>
<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="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</svg>
</button>
</div>
</div>
{/each}
@@ -227,7 +254,24 @@
</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>
<div class="flex items-center gap-2 mt-1 px-1">
<p class="text-[10px] text-text-secondary">Sending as <span class="font-mono">{selectedAgent?.display_name || selectedAgent?.name}</span></p>
{#if ownAgents.length > 1}
<select
class="text-[10px] bg-bg-tertiary border border-border rounded px-1 py-0.5 text-text-secondary outline-none"
onchange={(e) => {
const target = e.target as HTMLSelectElement;
selectedAgent = ownAgents.find(a => a.name === target.value) ?? ownAgents[0];
}}
>
{#each ownAgents as agent}
<option value={agent.name} selected={agent.name === selectedAgent?.name}>
{agent.display_name || agent.name}
</option>
{/each}
</select>
{/if}
</div>
{/if}
</div>
{/if}