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:
co-authored by
Claude Opus 4.6
parent
f12824cda9
commit
69e926441e
+137
-39
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+6
-6
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">·</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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user