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:
Algis Dumbris
2026-03-15 19:47:22 +02:00
co-authored by Claude Opus 4.6
parent 6d817867f2
commit 47ba1e9740
8 changed files with 554 additions and 103 deletions
+53 -1
View File
@@ -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
+14 -10
View File
@@ -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
}
+51
View File
@@ -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 >= ?")
+11 -11
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.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);
});
+6 -2
View File
@@ -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}`);
}
};
+1 -1
View File
@@ -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');
+3 -3
View File
@@ -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 -->
+415 -75
View File
@@ -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">&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 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>