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:
ZacharyZhang-NY committed 2026-03-22 14:34:35 -04:00
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>
)
}
+43 -5
View File
@@ -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;
}),
});