feat: rework Conversations page into search-only with Slack-style filters
Replace the Conversations page with a dedicated search page featuring: - Large, prominent search input with autofocus - Collapsible filter panel with time range presets (24h, week, month, 3 months, custom date range), channel filter (comma-separated, - prefix to exclude), and agent filter (same syntax) - Search-results-only display with helpful empty state when no search has been performed - Remove duplicate "Conversations" heading, rename to "Search" - Update sidebar nav label and icon to match Backend changes: - Add channel, agent, after, before query parameters to GET /api/messages/search endpoint - Add Channels, ExcludeChannels, Agents, ExcludeAgents fields to SearchOptions with SQL filter generation in store.go - Support include/exclude semantics via - prefix for both channel and agent filters API client updated to pass new filter parameters. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6d817867f2
commit
47ba1e9740
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -399,9 +400,60 @@ func (h *MessagesHandler) SearchMessages(w http.ResponseWriter, r *http.Request)
|
||||
limit = 20
|
||||
}
|
||||
|
||||
// Parse advanced filter parameters
|
||||
channelFilter := r.URL.Query().Get("channel")
|
||||
agentFilter := r.URL.Query().Get("agent")
|
||||
afterFilter := r.URL.Query().Get("after")
|
||||
beforeFilter := r.URL.Query().Get("before")
|
||||
|
||||
var allMessages []*messaging.Message
|
||||
for _, agent := range ownedAgents {
|
||||
opts := messaging.SearchOptions{Limit: limit}
|
||||
opts := messaging.SearchOptions{
|
||||
Limit: limit,
|
||||
After: afterFilter,
|
||||
Before: beforeFilter,
|
||||
}
|
||||
|
||||
// Apply channel filters (comma-separated, prefix with - to exclude)
|
||||
if channelFilter != "" {
|
||||
channels := strings.Split(channelFilter, ",")
|
||||
var includeChannels []string
|
||||
var excludeChannels []string
|
||||
for _, ch := range channels {
|
||||
ch = strings.TrimSpace(ch)
|
||||
if ch == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(ch, "-") {
|
||||
excludeChannels = append(excludeChannels, strings.TrimPrefix(ch, "-"))
|
||||
} else {
|
||||
includeChannels = append(includeChannels, ch)
|
||||
}
|
||||
}
|
||||
opts.Channels = includeChannels
|
||||
opts.ExcludeChannels = excludeChannels
|
||||
}
|
||||
|
||||
// Apply agent filters (comma-separated, prefix with - to exclude)
|
||||
if agentFilter != "" {
|
||||
agentNames := strings.Split(agentFilter, ",")
|
||||
var includeAgents []string
|
||||
var excludeAgents []string
|
||||
for _, a := range agentNames {
|
||||
a = strings.TrimSpace(a)
|
||||
if a == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(a, "-") {
|
||||
excludeAgents = append(excludeAgents, strings.TrimPrefix(a, "-"))
|
||||
} else {
|
||||
includeAgents = append(includeAgents, a)
|
||||
}
|
||||
}
|
||||
opts.Agents = includeAgents
|
||||
opts.ExcludeAgents = excludeAgents
|
||||
}
|
||||
|
||||
result, err := h.msgService.SearchMessages(r.Context(), agent.Name, query, opts)
|
||||
if err != nil {
|
||||
continue
|
||||
|
||||
@@ -25,14 +25,18 @@ type ReadOptions struct {
|
||||
|
||||
// SearchOptions configures message search behavior.
|
||||
type SearchOptions struct {
|
||||
FromAgent string `json:"from_agent,omitempty"`
|
||||
ToAgent string `json:"to_agent,omitempty"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
MinPriority int `json:"min_priority,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
After string `json:"after,omitempty"`
|
||||
Before string `json:"before,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
FromAgent string `json:"from_agent,omitempty"`
|
||||
ToAgent string `json:"to_agent,omitempty"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
MinPriority int `json:"min_priority,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
After string `json:"after,omitempty"`
|
||||
Before string `json:"before,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Channels []string `json:"channels,omitempty"` // include messages in these channels
|
||||
ExcludeChannels []string `json:"exclude_channels,omitempty"` // exclude messages in these channels
|
||||
Agents []string `json:"agents,omitempty"` // include messages from/to these agents
|
||||
ExcludeAgents []string `json:"exclude_agents,omitempty"` // exclude messages from/to these agents
|
||||
}
|
||||
|
||||
@@ -492,6 +492,57 @@ func (s *SQLiteMessageStore) buildSearchConditions(agentName, query string, opts
|
||||
args = append(args, opts.Channel)
|
||||
}
|
||||
|
||||
if len(opts.Channels) > 0 {
|
||||
placeholders := make([]string, len(opts.Channels))
|
||||
for i, ch := range opts.Channels {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, ch)
|
||||
}
|
||||
conditions = append(conditions, fmt.Sprintf("m.channel_id IN (SELECT id FROM channels WHERE LOWER(name) IN (%s))", strings.Join(placeholders, ",")))
|
||||
}
|
||||
|
||||
if len(opts.ExcludeChannels) > 0 {
|
||||
placeholders := make([]string, len(opts.ExcludeChannels))
|
||||
for i, ch := range opts.ExcludeChannels {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, ch)
|
||||
}
|
||||
conditions = append(conditions, fmt.Sprintf("(m.channel_id IS NULL OR m.channel_id NOT IN (SELECT id FROM channels WHERE LOWER(name) IN (%s)))", strings.Join(placeholders, ",")))
|
||||
}
|
||||
|
||||
if len(opts.Agents) > 0 {
|
||||
placeholders := make([]string, len(opts.Agents))
|
||||
for i, a := range opts.Agents {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, a)
|
||||
}
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
// Clone placeholders for the second IN clause
|
||||
placeholders2 := make([]string, len(opts.Agents))
|
||||
for i, a := range opts.Agents {
|
||||
placeholders2[i] = "?"
|
||||
args = append(args, a)
|
||||
}
|
||||
inClause2 := strings.Join(placeholders2, ",")
|
||||
conditions = append(conditions, fmt.Sprintf("(m.from_agent IN (%s) OR m.to_agent IN (%s))", inClause, inClause2))
|
||||
}
|
||||
|
||||
if len(opts.ExcludeAgents) > 0 {
|
||||
placeholders := make([]string, len(opts.ExcludeAgents))
|
||||
for i, a := range opts.ExcludeAgents {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, a)
|
||||
}
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
placeholders2 := make([]string, len(opts.ExcludeAgents))
|
||||
for i, a := range opts.ExcludeAgents {
|
||||
placeholders2[i] = "?"
|
||||
args = append(args, a)
|
||||
}
|
||||
inClause2 := strings.Join(placeholders2, ",")
|
||||
conditions = append(conditions, fmt.Sprintf("m.from_agent NOT IN (%s) AND (m.to_agent = '' OR m.to_agent NOT IN (%s))", inClause, inClause2))
|
||||
}
|
||||
|
||||
if opts.After != "" {
|
||||
if t, err := time.Parse(time.RFC3339, opts.After); err == nil {
|
||||
conditions = append(conditions, "m.created_at >= ?")
|
||||
|
||||
Vendored
+11
-11
@@ -8,29 +8,29 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="/_app/immutable/entry/start.CyIe6_Q2.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BoJy5JPd.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BoEm3yK3.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/B9lFjOCO.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/_CYN1FQs.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/8mnKcOKb.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DszstWrr.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.CiwCeDom.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/start.Dw7IqsBy.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/9VVSaZnO.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/thcHVQ3R.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BMFalvB2.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BYTM2CSi.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/xEGQqPVm.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DfGRd7cz.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.B5PjFiqj.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_1mn75q7 = {
|
||||
__sveltekit_1fzldg3 = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.CyIe6_Q2.js"),
|
||||
import("/_app/immutable/entry/app.CiwCeDom.js")
|
||||
import("/_app/immutable/entry/start.Dw7IqsBy.js"),
|
||||
import("/_app/immutable/entry/app.B5PjFiqj.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
|
||||
@@ -65,9 +65,13 @@ export const messages = {
|
||||
send: (body: { from?: string; to?: string; body: string; priority?: number; subject?: string; channel_id?: number; conversation_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) => {
|
||||
search: (q: string, opts?: { limit?: number; channel?: string; agent?: string; after?: string; before?: string }) => {
|
||||
const qs = new URLSearchParams({ q });
|
||||
if (limit) qs.set('limit', String(limit));
|
||||
if (opts?.limit) qs.set('limit', String(opts.limit));
|
||||
if (opts?.channel) qs.set('channel', opts.channel);
|
||||
if (opts?.agent) qs.set('agent', opts.agent);
|
||||
if (opts?.after) qs.set('after', opts.after);
|
||||
if (opts?.before) qs.set('before', opts.before);
|
||||
return request<{ messages: any[]; query: string; total: number }>('GET', `/api/messages/search?${qs}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
const path = $page.url.pathname;
|
||||
if (path === '/') return 'Dashboard';
|
||||
if (path.startsWith('/conversations/')) return 'Thread';
|
||||
if (path === '/conversations') return 'Conversations';
|
||||
if (path === '/conversations') return 'Search';
|
||||
if (path.startsWith('/channels/')) return '#' + ($page.params.name ?? '');
|
||||
if (path === '/channels') return 'Channels';
|
||||
if (path.startsWith('/dm/')) return ($page.params.name ?? 'DM');
|
||||
|
||||
@@ -104,15 +104,15 @@
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<!-- Conversations -->
|
||||
<!-- Search -->
|
||||
<a
|
||||
href="/conversations"
|
||||
class="sidebar-item mb-3 {isActive('/conversations') ? 'sidebar-item-active' : ''}"
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Conversations
|
||||
Search
|
||||
</a>
|
||||
|
||||
<!-- Channels section -->
|
||||
|
||||
@@ -1,110 +1,450 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
|
||||
import { goto } from '$app/navigation';
|
||||
import { messages as messagesApi, channels as channelsApi } from '$lib/api/client';
|
||||
|
||||
let convList = $state<any[]>([]);
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<any[] | null>(null);
|
||||
let loadingData = $state(true);
|
||||
let searchTotal = $state(0);
|
||||
let loading = $state(false);
|
||||
let hasSearched = $state(false);
|
||||
|
||||
// Filter state
|
||||
let timeRange = $state('any');
|
||||
let customAfter = $state('');
|
||||
let customBefore = $state('');
|
||||
let channelFilter = $state('');
|
||||
let agentFilter = $state('');
|
||||
let showFilters = $state(false);
|
||||
|
||||
// Channel list for suggestions
|
||||
let channelList = $state<string[]>([]);
|
||||
|
||||
let _initialized = $state(false);
|
||||
$effect(() => {
|
||||
if (!_initialized) {
|
||||
_initialized = true;
|
||||
(async () => {
|
||||
const q = $page.url.searchParams.get('q');
|
||||
if (q) {
|
||||
try {
|
||||
const res = await messagesApi.search(q);
|
||||
searchResults = res.messages;
|
||||
} catch {
|
||||
searchResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await convsApi.list();
|
||||
convList = res.conversations;
|
||||
} catch {
|
||||
// handled
|
||||
} finally {
|
||||
loadingData = false;
|
||||
}
|
||||
})();
|
||||
// Load channel list for suggestions
|
||||
channelsApi.list().then((res) => {
|
||||
channelList = res.channels.map((c: any) => c.name);
|
||||
}).catch(() => {});
|
||||
|
||||
// Check for initial query from URL
|
||||
const q = $page.url.searchParams.get('q');
|
||||
if (q) {
|
||||
searchQuery = q;
|
||||
performSearch();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function getTimeFilters(): { after?: string; before?: string } {
|
||||
const now = new Date();
|
||||
switch (timeRange) {
|
||||
case '24h': {
|
||||
const d = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
return { after: d.toISOString() };
|
||||
}
|
||||
case 'week': {
|
||||
const d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
return { after: d.toISOString() };
|
||||
}
|
||||
case 'month': {
|
||||
const d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
return { after: d.toISOString() };
|
||||
}
|
||||
case '3months': {
|
||||
const d = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
return { after: d.toISOString() };
|
||||
}
|
||||
case 'custom': {
|
||||
const result: { after?: string; before?: string } = {};
|
||||
if (customAfter) result.after = new Date(customAfter).toISOString();
|
||||
if (customBefore) result.before = new Date(customBefore).toISOString();
|
||||
return result;
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function performSearch() {
|
||||
const q = searchQuery.trim();
|
||||
if (!q) return;
|
||||
|
||||
loading = true;
|
||||
hasSearched = true;
|
||||
|
||||
// Update URL without navigation
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('q', q);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
|
||||
try {
|
||||
const timeFilters = getTimeFilters();
|
||||
const res = await messagesApi.search(q, {
|
||||
limit: 50,
|
||||
channel: channelFilter.trim() || undefined,
|
||||
agent: agentFilter.trim() || undefined,
|
||||
after: timeFilters.after,
|
||||
before: timeFilters.before
|
||||
});
|
||||
searchResults = res.messages;
|
||||
searchTotal = res.total;
|
||||
} catch {
|
||||
searchResults = [];
|
||||
searchTotal = 0;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
performSearch();
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery = '';
|
||||
searchResults = null;
|
||||
searchTotal = 0;
|
||||
hasSearched = false;
|
||||
timeRange = 'any';
|
||||
customAfter = '';
|
||||
customBefore = '';
|
||||
channelFilter = '';
|
||||
agentFilter = '';
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('q');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined });
|
||||
}
|
||||
|
||||
function getMessageContext(msg: any): string {
|
||||
if (msg.channel_id) {
|
||||
return 'channel';
|
||||
}
|
||||
if (msg.to_agent) {
|
||||
return `DM with ${msg.to_agent}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getMessageLink(msg: any): string {
|
||||
if (msg.channel_id) {
|
||||
// We don't have channel name directly, link to conversation
|
||||
return `/conversations/${msg.conversation_id}`;
|
||||
}
|
||||
if (msg.to_agent) {
|
||||
return `/dm/${msg.from_agent === 'system' ? msg.to_agent : msg.to_agent}`;
|
||||
}
|
||||
return `/conversations/${msg.conversation_id}`;
|
||||
}
|
||||
|
||||
let activeFilterCount = $derived(
|
||||
(timeRange !== 'any' ? 1 : 0) +
|
||||
(channelFilter.trim() ? 1 : 0) +
|
||||
(agentFilter.trim() ? 1 : 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
<h1 class="text-xl font-bold text-text-primary font-display mb-5">Conversations</h1>
|
||||
<div class="p-5 max-w-4xl mx-auto">
|
||||
<!-- Search box -->
|
||||
<form onsubmit={handleSubmit} class="mb-4">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-secondary pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search messages..."
|
||||
class="w-full pl-12 pr-24 py-3.5 text-base bg-bg-tertiary border border-border rounded-lg text-text-primary placeholder-text-secondary focus:border-accent-blue focus:ring-1 focus:ring-accent-blue outline-none transition-colors"
|
||||
bind:value={searchQuery}
|
||||
autofocus
|
||||
/>
|
||||
{#if searchQuery || hasSearched}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearSearch}
|
||||
class="absolute right-20 top-1/2 -translate-y-1/2 text-text-secondary hover:text-text-primary transition-colors p-1"
|
||||
title="Clear search"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 px-4 py-1.5 text-sm font-medium rounded bg-accent-blue text-white hover:brightness-110 transition-all disabled:opacity-50"
|
||||
disabled={!searchQuery.trim() || loading}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if searchResults !== null}
|
||||
<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">
|
||||
Search Results ({searchResults.length})
|
||||
</h2>
|
||||
</div>
|
||||
{#if searchResults.length === 0}
|
||||
<div class="p-8 text-center text-text-secondary text-sm">
|
||||
No messages found for '{$page.url.searchParams.get('q')}'
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-border">
|
||||
{#each searchResults as msg}
|
||||
<a href="/conversations/{msg.conversation_id}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-semibold text-sm text-text-primary">{msg.from_agent}</span>
|
||||
<span class="text-xs text-text-secondary">{new Date(msg.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<p class="text-sm text-text-primary/80">{msg.body.slice(0, 200)}</p>
|
||||
</a>
|
||||
<!-- Filter toggle -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded transition-colors {showFilters ? 'bg-accent-blue/20 text-accent-blue' : 'bg-bg-tertiary text-text-secondary hover:text-text-primary'}"
|
||||
onclick={() => showFilters = !showFilters}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
Filters
|
||||
{#if activeFilterCount > 0}
|
||||
<span class="inline-flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-accent-blue text-white">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if activeFilterCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { timeRange = 'any'; customAfter = ''; customBefore = ''; channelFilter = ''; agentFilter = ''; }}
|
||||
class="text-xs text-text-secondary hover:text-accent-red transition-colors"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Filters panel -->
|
||||
{#if showFilters}
|
||||
<div class="card p-4 mb-4 space-y-4">
|
||||
<!-- Time range -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-text-secondary mb-1.5">Time range</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each [
|
||||
{ value: 'any', label: 'Any time' },
|
||||
{ value: '24h', label: 'Past 24h' },
|
||||
{ value: 'week', label: 'Past week' },
|
||||
{ value: 'month', label: 'Past month' },
|
||||
{ value: '3months', label: 'Past 3 months' },
|
||||
{ value: 'custom', label: 'Custom range' }
|
||||
] as opt}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => timeRange = opt.value}
|
||||
class="px-3 py-1 text-xs rounded transition-colors {timeRange === opt.value ? 'bg-accent-blue/20 text-accent-blue font-medium' : 'bg-bg-tertiary text-text-secondary hover:text-text-primary'}"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if timeRange === 'custom'}
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<div class="flex-1">
|
||||
<label class="block text-[10px] text-text-secondary mb-0.5">From</label>
|
||||
<input
|
||||
type="date"
|
||||
class="w-full px-2 py-1 text-xs bg-bg-input border border-border rounded text-text-primary focus:border-border-active outline-none"
|
||||
bind:value={customAfter}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-[10px] text-text-secondary mb-0.5">To</label>
|
||||
<input
|
||||
type="date"
|
||||
class="w-full px-2 py-1 text-xs bg-bg-input border border-border rounded text-text-primary focus:border-border-active outline-none"
|
||||
bind:value={customBefore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Channel filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-text-secondary mb-1.5">
|
||||
Channels
|
||||
<span class="font-normal text-text-secondary/60 ml-1">comma-separated, prefix with - to exclude</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. general, news-tech or -spam, -test"
|
||||
class="w-full px-3 py-1.5 text-sm bg-bg-input border border-border rounded text-text-primary placeholder-text-secondary/50 focus:border-border-active outline-none"
|
||||
bind:value={channelFilter}
|
||||
/>
|
||||
{#if channelList.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-1.5">
|
||||
{#each channelList.slice(0, 8) as ch}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (channelFilter) {
|
||||
channelFilter = channelFilter + ', ' + ch;
|
||||
} else {
|
||||
channelFilter = ch;
|
||||
}
|
||||
}}
|
||||
class="px-1.5 py-0.5 text-[10px] rounded bg-bg-tertiary text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
#{ch}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Agent filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-text-secondary mb-1.5">
|
||||
Agents
|
||||
<span class="font-normal text-text-secondary/60 ml-1">comma-separated, prefix with - to exclude</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. research-mcpproxy, social-commenter or -system"
|
||||
class="w-full px-3 py-1.5 text-sm bg-bg-input border border-border rounded text-text-primary placeholder-text-secondary/50 focus:border-border-active outline-none"
|
||||
bind:value={agentFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Apply filters button (re-search) -->
|
||||
{#if hasSearched}
|
||||
<button
|
||||
type="button"
|
||||
onclick={performSearch}
|
||||
class="btn-primary text-xs py-1.5 px-4"
|
||||
>
|
||||
Apply filters
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display">All Conversations</h2>
|
||||
</div>
|
||||
{#if loadingData}
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Results area -->
|
||||
{#if loading}
|
||||
<div class="card">
|
||||
<div class="p-5 space-y-5">
|
||||
{#each Array(5) as _}
|
||||
<div class="space-y-2">
|
||||
<div class="skeleton h-3 w-1/3"></div>
|
||||
<div class="skeleton h-3 w-2/3"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="skeleton h-3 w-24"></div>
|
||||
<div class="skeleton h-3 w-16"></div>
|
||||
</div>
|
||||
<div class="skeleton h-3 w-3/4"></div>
|
||||
<div class="skeleton h-3 w-1/2"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if convList.length === 0}
|
||||
<div class="p-8 text-center text-text-secondary text-sm">
|
||||
No conversations yet.
|
||||
</div>
|
||||
{:else if hasSearched && searchResults !== null}
|
||||
<!-- Search result count -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-xs text-text-secondary">
|
||||
{searchTotal} result{searchTotal !== 1 ? 's' : ''} for "<span class="text-text-primary font-medium">{$page.url.searchParams.get('q')}</span>"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if searchResults.length === 0}
|
||||
<div class="card">
|
||||
<div class="p-12 text-center">
|
||||
<svg class="w-12 h-12 mx-auto mb-3 text-text-secondary/40" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<p class="text-sm text-text-secondary mb-1">No messages found</p>
|
||||
<p class="text-xs text-text-secondary/60">Try different keywords or adjust your filters</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-border">
|
||||
{#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 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 class="card divide-y divide-border">
|
||||
{#each searchResults as msg}
|
||||
<a href={getMessageLink(msg)} class="block px-5 py-3.5 hover:bg-bg-tertiary/50 transition-colors group">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<!-- Agent avatar -->
|
||||
<div class="w-6 h-6 rounded flex-shrink-0 flex items-center justify-center text-[10px] font-bold text-white" style="background-color: var(--accent-purple)">
|
||||
{msg.from_agent?.charAt(0)?.toUpperCase() || '?'}
|
||||
</div>
|
||||
<span class="badge bg-bg-tertiary text-text-secondary flex-shrink-0 ml-3">
|
||||
{conv.message_count}
|
||||
</span>
|
||||
<span class="font-semibold text-sm text-text-primary">{msg.from_agent}</span>
|
||||
{#if msg.to_agent}
|
||||
<svg class="w-3 h-3 text-text-secondary/50" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<span class="text-sm text-text-secondary">{msg.to_agent}</span>
|
||||
{/if}
|
||||
<span class="text-[10px] text-text-secondary/60 ml-auto flex-shrink-0">{formatTimestamp(msg.created_at)}</span>
|
||||
</div>
|
||||
<p class="text-sm text-text-primary/80 leading-relaxed line-clamp-2 ml-8">
|
||||
{msg.body.length > 300 ? msg.body.slice(0, 300) + '...' : msg.body}
|
||||
</p>
|
||||
{#if getMessageContext(msg)}
|
||||
<div class="flex items-center gap-1.5 mt-1.5 ml-8">
|
||||
{#if msg.channel_id}
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] rounded bg-bg-tertiary text-text-secondary">
|
||||
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
|
||||
</svg>
|
||||
channel
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] rounded bg-bg-tertiary text-text-secondary">
|
||||
DM
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Empty state - no search performed yet -->
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<div class="w-16 h-16 mb-5 rounded-2xl bg-bg-tertiary flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-text-secondary/50" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-display font-semibold text-text-primary mb-2">Search messages</h2>
|
||||
<p class="text-sm text-text-secondary text-center max-w-md mb-6">
|
||||
Search across all your conversations, channels, and direct messages.
|
||||
Use filters to narrow results by time, channel, or agent.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchQuery = 'deployment'; performSearch(); }}
|
||||
class="px-3 py-1.5 text-xs rounded-full bg-bg-tertiary text-text-secondary hover:text-text-primary hover:bg-border transition-colors"
|
||||
>
|
||||
Try "deployment"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchQuery = 'error'; performSearch(); }}
|
||||
class="px-3 py-1.5 text-xs rounded-full bg-bg-tertiary text-text-secondary hover:text-text-primary hover:bg-border transition-colors"
|
||||
>
|
||||
Try "error"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchQuery = 'update'; performSearch(); }}
|
||||
class="px-3 py-1.5 text-xs rounded-full bg-bg-tertiary text-text-secondary hover:text-text-primary hover:bg-border transition-colors"
|
||||
>
|
||||
Try "update"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user