feat(03-01): add SearchResults component with highlighting, scope toggle, and CommunicationHub integration
- Create SearchResults component with virtualized list, term highlighting via <mark>, keyboard navigation, loading/empty states - Add search scope toggle (All Channels / This Channel) with radio group accessibility - Integrate debounced (300ms) IPC search into CommunicationHub with stale response discard via requestId - Update CommunicationSlice with search state (query, scope, results, loading, requestId) - Modify FilterBar to accept scopeToggle prop and Escape key handler - AnimatePresence cross-fade between SearchResults and MessageList views
This commit is contained in:
1 parent
43cb97ac6d
commit
7d5d1e5b30
4 files changed
+407
-12
No files matched your search
@@ -202,9 +202,13 @@ export interface FilterState {
|
||||
export function FilterBar({
|
||||
filters,
|
||||
onChangeFilters,
|
||||
scopeToggle,
|
||||
onSearchKeyDown,
|
||||
}: {
|
||||
filters: FilterState
|
||||
onChangeFilters: (next: FilterState) => void
|
||||
scopeToggle?: React.ReactNode
|
||||
onSearchKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
@@ -218,11 +222,13 @@ export function FilterBar({
|
||||
type="text"
|
||||
value={filters.search}
|
||||
onChange={(e) => onChangeFilters({ ...filters, search: e.target.value })}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder={t("comm.searchMessages")}
|
||||
className="w-full rounded-[6px] border border-[color:var(--line)] bg-[color:var(--panel)] py-1.5 pl-8 pr-3 text-[11px] text-[color:var(--text)] placeholder:text-[color:var(--muted)] focus:outline-none focus:ring-1"
|
||||
style={{ focusRingColor: TERRACOTTA } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
{scopeToggle}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { AnimatePresence } from "framer-motion"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { ChatCircle, Plus } from "@phosphor-icons/react"
|
||||
import type { AgentMessageRecord, AgentRecord, CommentRecord, MessageChannel, ProjectRecord, TaskRecord } from "@shared/types"
|
||||
import { useAppStore } from "../../state/store"
|
||||
@@ -10,6 +10,7 @@ import type { ChannelDescriptor, TaskDiscussionDescriptor, AnyChannel } from "./
|
||||
import { ChannelHeader } from "./ChannelHeader"
|
||||
import { MessageList } from "./MessageList"
|
||||
import { MessageComposer, TaskDiscussionComposer, NoChannelComposer, ComposeModal } from "./MessageComposer"
|
||||
import { SearchResults } from "./SearchResults"
|
||||
import { deriveChannels, deriveTaskDiscussionChannels, getChannelMessages } from "./channel-utils"
|
||||
|
||||
const TERRACOTTA = "#d97745"
|
||||
@@ -21,6 +22,45 @@ interface CommunicationHubProps {
|
||||
onEntityConsumed?: () => void; onNavigateToTask: (taskId: string) => void; onRefresh: () => void
|
||||
}
|
||||
|
||||
function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channel"; onChangeScope: (scope: "global" | "channel") => void }) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center gap-0.5 rounded-full bg-[color:var(--panel)] p-0.5"
|
||||
role="radiogroup"
|
||||
aria-label="Search scope"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={scope === "global"}
|
||||
onClick={() => onChangeScope("global")}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onChangeScope("global") } }}
|
||||
className={`rounded-full px-2.5 py-1 text-[10px] font-semibold transition-colors duration-150 ${
|
||||
scope === "global"
|
||||
? "bg-[color:var(--accent)] text-white"
|
||||
: "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"
|
||||
}`}
|
||||
>
|
||||
All Channels
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={scope === "channel"}
|
||||
onClick={() => onChangeScope("channel")}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onChangeScope("channel") } }}
|
||||
className={`rounded-full px-2.5 py-1 text-[10px] font-semibold transition-colors duration-150 ${
|
||||
scope === "channel"
|
||||
? "bg-[color:var(--accent)] text-white"
|
||||
: "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"
|
||||
}`}
|
||||
>
|
||||
This Channel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CommunicationHub({ messages, comments, tasks, agents, projects, companyId, selectedEntityId, onEntityConsumed, onNavigateToTask, onRefresh }: CommunicationHubProps) {
|
||||
const t = useT()
|
||||
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
|
||||
@@ -31,6 +71,20 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
|
||||
const pendingReadIdsRef = useRef<Set<string>>(new Set())
|
||||
const [filters, setFilters] = useState<FilterState>({ search: "", channelType: "all", priority: "all", unreadOnly: false })
|
||||
|
||||
// Search state from Zustand store
|
||||
const searchQuery = useAppStore((s) => s.searchQuery)
|
||||
const searchScope = useAppStore((s) => s.searchScope)
|
||||
const searchResults = useAppStore((s) => s.searchResults)
|
||||
const searchLoading = useAppStore((s) => s.searchLoading)
|
||||
const searchRequestId = useAppStore((s) => s.searchRequestId)
|
||||
const setSearchQuery = useAppStore((s) => s.setSearchQuery)
|
||||
const setSearchScope = useAppStore((s) => s.setSearchScope)
|
||||
const setSearchResults = useAppStore((s) => s.setSearchResults)
|
||||
const setSearchLoading = useAppStore((s) => s.setSearchLoading)
|
||||
const clearSearch = useAppStore((s) => s.clearSearch)
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
|
||||
const effectiveMessages = useMemo(() => messages.map((m) => optimisticReadIds.has(m.id) ? { ...m, readAt: m.readAt ?? new Date().toISOString() } : m), [messages, optimisticReadIds])
|
||||
|
||||
@@ -61,14 +115,89 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
|
||||
const filteredMessages = useMemo(() => {
|
||||
let r = channelMessages
|
||||
if (filters.channelType !== "all") r = r.filter((m) => m.channel === filters.channelType)
|
||||
if (filters.search) { const q = filters.search.toLowerCase(); r = r.filter((m) => m.subject.toLowerCase().includes(q) || m.body.toLowerCase().includes(q) || (agentMap.get(m.fromAgentId)?.name ?? "").toLowerCase().includes(q)) }
|
||||
if (filters.search && !searchQuery) { const q = filters.search.toLowerCase(); r = r.filter((m) => m.subject.toLowerCase().includes(q) || m.body.toLowerCase().includes(q) || (agentMap.get(m.fromAgentId)?.name ?? "").toLowerCase().includes(q)) }
|
||||
if (filters.priority !== "all") r = r.filter((m) => m.priority === filters.priority)
|
||||
if (filters.unreadOnly) r = r.filter((m) => !m.readAt)
|
||||
return r
|
||||
}, [channelMessages, filters, agentMap])
|
||||
}, [channelMessages, filters, agentMap, searchQuery])
|
||||
|
||||
// Debounced search via IPC
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
|
||||
const query = filters.search
|
||||
if (!query || query.length < 1) {
|
||||
if (searchQuery) clearSearch()
|
||||
return
|
||||
}
|
||||
|
||||
setSearchQuery(query)
|
||||
setSearchLoading(true)
|
||||
|
||||
const currentRequestId = searchRequestId + 1
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const channelParam = searchScope === "channel" && activeChannel?.kind === "message"
|
||||
? (activeChannel as ChannelDescriptor).channel
|
||||
: undefined
|
||||
const channelTargetParam = searchScope === "channel" && activeChannel?.kind === "message"
|
||||
? (activeChannel as ChannelDescriptor).targetId
|
||||
: undefined
|
||||
|
||||
const result = await window.agentCompany.searchMessages({
|
||||
companyId,
|
||||
query,
|
||||
channel: channelParam,
|
||||
channelTargetId: channelTargetParam,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
if (result.ok) {
|
||||
setSearchResults(result.data, currentRequestId)
|
||||
} else {
|
||||
setSearchResults([], currentRequestId)
|
||||
}
|
||||
} catch {
|
||||
setSearchResults([], currentRequestId)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters.search, searchScope, activeChannel, companyId])
|
||||
|
||||
// Navigate to the channel containing a search result
|
||||
const handleNavigateToResult = useCallback((message: AgentMessageRecord) => {
|
||||
// Find which channel this message belongs to
|
||||
const matchingChannel = messageChannels.find((ch) => {
|
||||
const channelMsgs = getChannelMessages(effectiveMessages, ch)
|
||||
return channelMsgs.some((m) => m.id === message.id)
|
||||
})
|
||||
|
||||
if (matchingChannel) {
|
||||
setSelectedChannelKey(matchingChannel.key)
|
||||
}
|
||||
|
||||
// Clear search to return to normal view
|
||||
setFilters((prev) => ({ ...prev, search: "" }))
|
||||
clearSearch()
|
||||
}, [messageChannels, effectiveMessages, clearSearch])
|
||||
|
||||
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setFilters((prev) => ({ ...prev, search: "" }))
|
||||
clearSearch()
|
||||
}
|
||||
}, [clearSearch])
|
||||
|
||||
const isSearchActive = filters.search.length >= 1
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeChannel || activeChannel.kind !== "message") return
|
||||
if (isSearchActive) return // Don't mark as read during search
|
||||
const ids = filteredMessages.filter((m) => !m.readAt && !pendingReadIdsRef.current.has(m.id)).map((m) => m.id)
|
||||
if (ids.length === 0) return
|
||||
let cancelled = false
|
||||
@@ -81,7 +210,7 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
|
||||
if (!cancelled && failed.length > 0) setOptimisticReadIds((c) => { const n = new Set(c); for (const id of failed) n.delete(id); return n })
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [activeChannel, companyId, filteredMessages])
|
||||
}, [activeChannel, companyId, filteredMessages, isSearchActive])
|
||||
|
||||
const unreadByChannel = useMemo(() => {
|
||||
const counts = new Map<string, { unread: number; hasUrgent: boolean }>()
|
||||
@@ -101,6 +230,10 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
|
||||
return groups
|
||||
}, [channels])
|
||||
|
||||
const scopeToggleElement = isSearchActive ? (
|
||||
<SearchScopeToggle scope={searchScope} onChangeScope={setSearchScope} />
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -122,9 +255,39 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
|
||||
{showSidebar ? <ChannelSidebar channelGroups={channelGroups} taskDiscussionChannels={taskDiscussionChannels} selectedChannelKey={selectedChannelKey} onSelectChannel={setSelectedChannelKey} unreadByChannel={unreadByChannel} agents={agents} totalChannels={channels.length} onHideSidebar={() => setShowSidebar(false)} /> : null}
|
||||
</AnimatePresence>
|
||||
<div className="flex min-w-0 flex-1 flex-col bg-[color:var(--bg)]">
|
||||
<ChannelHeader activeChannel={activeChannel} showSidebar={showSidebar} onShowSidebar={() => setShowSidebar(true)} channelMessages={channelMessages} taskDiscussionComments={taskComments} activeTask={activeTask} unreadByChannel={unreadByChannel} onNavigateToTask={onNavigateToTask} filterBar={<FilterBar filters={filters} onChangeFilters={setFilters} />} />
|
||||
<ChannelHeader activeChannel={activeChannel} showSidebar={showSidebar} onShowSidebar={() => setShowSidebar(true)} channelMessages={channelMessages} taskDiscussionComments={taskComments} activeTask={activeTask} unreadByChannel={unreadByChannel} onNavigateToTask={onNavigateToTask} filterBar={<FilterBar filters={filters} onChangeFilters={setFilters} scopeToggle={scopeToggleElement} onSearchKeyDown={handleSearchKeyDown} />} />
|
||||
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||
<MessageList activeChannel={activeChannel} filteredMessages={filteredMessages} taskDiscussionComments={taskComments} activeTask={activeTask} agentMap={agentMap} filters={filters} onReply={setReplyTo} messageCount={effectiveMessages.length} selectedChannelKey={selectedChannelKey} />
|
||||
<AnimatePresence mode="wait">
|
||||
{isSearchActive ? (
|
||||
<motion.div
|
||||
key="search-results"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15, ease: "easeInOut" }}
|
||||
className="h-full"
|
||||
>
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
query={searchQuery}
|
||||
loading={searchLoading}
|
||||
agents={agents}
|
||||
onNavigateToResult={handleNavigateToResult}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="message-list"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15, ease: "easeInOut" }}
|
||||
className="h-full"
|
||||
>
|
||||
<MessageList activeChannel={activeChannel} filteredMessages={filteredMessages} taskDiscussionComments={taskComments} activeTask={activeTask} agentMap={agentMap} filters={filters} onReply={setReplyTo} messageCount={effectiveMessages.length} selectedChannelKey={selectedChannelKey} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{activeChannel ? (activeChannel.kind === "message" ? <MessageComposer agents={agents} companyId={companyId} channel={activeChannel.channel} channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId} directParticipantIds={activeChannel.channel === "direct" ? activeChannel.participantIds : []} replyTo={replyTo} onSent={() => { setReplyTo(null); onRefresh() }} onCancelReply={() => setReplyTo(null)} /> : <TaskDiscussionComposer companyId={companyId} taskId={activeChannel.taskId} onSent={onRefresh} />) : <NoChannelComposer />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useRef, useCallback } from "react"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import { motion } from "framer-motion"
|
||||
import { EnvelopeSimple, Buildings, Megaphone, Hash, Fire } from "@phosphor-icons/react"
|
||||
import type { AgentMessageRecord, AgentRecord, MessageChannel } from "@shared/types"
|
||||
import { AgentAvatar } from "./MessageItem"
|
||||
import { formatMessageTime } from "../../lib/formatters"
|
||||
import { EmptyState, useReducedMotion } from "../ui"
|
||||
import { useRovingTabIndex } from "../../lib/keyboard"
|
||||
|
||||
const channelConfig: Record<MessageChannel, { label: string; icon: React.ElementType; color: string }> = {
|
||||
direct: { label: "Direct", icon: EnvelopeSimple, color: "var(--accent)" },
|
||||
department: { label: "Department", icon: Buildings, color: "#6366f1" },
|
||||
company: { label: "Company", icon: Megaphone, color: "#d97745" },
|
||||
project: { label: "Project", icon: Hash, color: "#10b981" },
|
||||
incident: { label: "Incident", icon: Fire, color: "var(--danger)" },
|
||||
}
|
||||
|
||||
function highlightMatch(text: string, query: string): React.ReactNode {
|
||||
if (!query) return text
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
const regex = new RegExp(`(${escaped})`, "gi")
|
||||
const parts = text.split(regex)
|
||||
return parts.map((part, i) =>
|
||||
regex.test(part)
|
||||
? <mark key={i} className="rounded-[2px] bg-[color:var(--warn-soft)] px-0.5 text-[color:var(--text)]">{part}</mark>
|
||||
: part
|
||||
)
|
||||
}
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<div className="flex gap-3 px-3 py-3" style={{ height: 72 }}>
|
||||
<div className="h-7 w-7 shrink-0 animate-pulse rounded-full bg-[color:var(--panel-soft)]" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="h-3 w-32 animate-pulse rounded bg-[color:var(--panel-soft)]" />
|
||||
<div className="h-3 w-48 animate-pulse rounded bg-[color:var(--panel-soft)]" />
|
||||
<div className="h-3 w-64 animate-pulse rounded bg-[color:var(--panel-soft)]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SearchResultsProps {
|
||||
results: AgentMessageRecord[]
|
||||
query: string
|
||||
loading: boolean
|
||||
agents: AgentRecord[]
|
||||
onNavigateToResult: (message: AgentMessageRecord) => void
|
||||
}
|
||||
|
||||
export function SearchResults({ results, query, loading, agents, onNavigateToResult }: SearchResultsProps) {
|
||||
const reducedMotion = useReducedMotion()
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
const agentMap = new Map(agents.map((a) => [a.id, a]))
|
||||
|
||||
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(results.length)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: results.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 72,
|
||||
overscan: 8,
|
||||
})
|
||||
|
||||
const handleResultClick = useCallback((message: AgentMessageRecord) => {
|
||||
onNavigateToResult(message)
|
||||
}, [onNavigateToResult])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<SkeletonRow />
|
||||
<SkeletonRow />
|
||||
<SkeletonRow />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (results.length === 0 && query.length > 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<EmptyState
|
||||
title="No messages matched your search"
|
||||
detail="Try different keywords or search across all channels."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (results.length === 0) return null
|
||||
|
||||
const animationProps = reducedMotion
|
||||
? {}
|
||||
: { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0.2, ease: "easeOut" as const } }
|
||||
|
||||
return (
|
||||
<motion.div className="flex h-full flex-col" {...animationProps}>
|
||||
<div className="shrink-0 px-3 py-2">
|
||||
<span className="text-[11px] font-semibold text-[color:var(--muted)]">
|
||||
{results.length} result{results.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="flex-1 overflow-auto"
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const message = results[virtualRow.index]
|
||||
const agent = agentMap.get(message.fromAgentId)
|
||||
const config = channelConfig[message.channel]
|
||||
const ChannelIcon = config.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
tabIndex={getTabIndex(virtualRow.index)}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
onClick={() => handleResultClick(message)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
handleResultClick(message)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
className={`cursor-pointer px-3 py-2 transition-colors duration-100 hover:bg-[color:var(--panel-soft)] focus:outline-none ${virtualRow.index === focusedIndex ? "ring-1 ring-inset ring-[color:var(--accent)]" : ""}`}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AgentAvatar agent={agent} size="sm" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Row 1: Agent name, channel badge, timestamp */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-[color:var(--text)]">
|
||||
{agent?.name ?? "Unknown Agent"}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{ backgroundColor: `${config.color}18`, color: config.color }}
|
||||
>
|
||||
<ChannelIcon size={10} weight="bold" />
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 font-mono text-[10px] text-[color:var(--muted)]">
|
||||
{formatMessageTime(message.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Subject */}
|
||||
<div className="mt-0.5 truncate text-[12px] font-semibold text-[color:var(--text)]">
|
||||
{highlightMatch(message.subject, query)}
|
||||
</div>
|
||||
|
||||
{/* Row 3: Body snippet */}
|
||||
<div className="mt-0.5 line-clamp-2 text-[12px] leading-relaxed text-[color:var(--muted)]">
|
||||
{highlightMatch(message.body, query)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AgentMessageRecord } from "@shared/types";
|
||||
import type { AppState } from "../store";
|
||||
|
||||
export interface CommunicationSlice {
|
||||
// Placeholder for future message state -- this slice will be populated
|
||||
// during component decomposition. The communication component currently
|
||||
// manages its own internal state.
|
||||
searchQuery: string;
|
||||
searchScope: "global" | "channel";
|
||||
searchResults: AgentMessageRecord[];
|
||||
searchLoading: boolean;
|
||||
searchRequestId: number;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSearchScope: (scope: "global" | "channel") => void;
|
||||
setSearchResults: (results: AgentMessageRecord[], requestId: number) => void;
|
||||
setSearchLoading: (loading: boolean) => void;
|
||||
clearSearch: () => void;
|
||||
}
|
||||
|
||||
export const createCommunicationSlice: StateCreator<
|
||||
@@ -12,6 +20,36 @@ export const createCommunicationSlice: StateCreator<
|
||||
[["zustand/immer", never]],
|
||||
[],
|
||||
CommunicationSlice
|
||||
> = () => ({
|
||||
// Empty state for now
|
||||
> = (set) => ({
|
||||
searchQuery: "",
|
||||
searchScope: "global",
|
||||
searchResults: [],
|
||||
searchLoading: false,
|
||||
searchRequestId: 0,
|
||||
setSearchQuery: (query) =>
|
||||
set((state) => {
|
||||
state.searchQuery = query;
|
||||
state.searchRequestId += 1;
|
||||
}),
|
||||
setSearchScope: (scope) =>
|
||||
set((state) => {
|
||||
state.searchScope = scope;
|
||||
}),
|
||||
setSearchResults: (results, requestId) =>
|
||||
set((state) => {
|
||||
if (requestId === state.searchRequestId) {
|
||||
state.searchResults = results;
|
||||
state.searchLoading = false;
|
||||
}
|
||||
}),
|
||||
setSearchLoading: (loading) =>
|
||||
set((state) => {
|
||||
state.searchLoading = loading;
|
||||
}),
|
||||
clearSearch: () =>
|
||||
set((state) => {
|
||||
state.searchQuery = "";
|
||||
state.searchResults = [];
|
||||
state.searchLoading = false;
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user