diff --git a/src/renderer/components/communication/ChannelSidebar.tsx b/src/renderer/components/communication/ChannelSidebar.tsx index dbfb39fb..dbcbbf11 100644 --- a/src/renderer/components/communication/ChannelSidebar.tsx +++ b/src/renderer/components/communication/ChannelSidebar.tsx @@ -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) => 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} /> + {scopeToggle} + + + ) +} + export function CommunicationHub({ messages, comments, tasks, agents, projects, companyId, selectedEntityId, onEntityConsumed, onNavigateToTask, onRefresh }: CommunicationHubProps) { const t = useT() const [selectedChannelKey, setSelectedChannelKey] = useState(null) @@ -31,6 +71,20 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects, const pendingReadIdsRef = useRef>(new Set()) const [filters, setFilters] = useState({ 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 | 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) => { + 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() @@ -101,6 +230,10 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects, return groups }, [channels]) + const scopeToggleElement = isSearchActive ? ( + + ) : null + return (
@@ -122,9 +255,39 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects, {showSidebar ? setShowSidebar(false)} /> : null}
- setShowSidebar(true)} channelMessages={channelMessages} taskDiscussionComments={taskComments} activeTask={activeTask} unreadByChannel={unreadByChannel} onNavigateToTask={onNavigateToTask} filterBar={} /> + setShowSidebar(true)} channelMessages={channelMessages} taskDiscussionComments={taskComments} activeTask={activeTask} unreadByChannel={unreadByChannel} onNavigateToTask={onNavigateToTask} filterBar={} />
- + + {isSearchActive ? ( + + + + ) : ( + + + + )} +
{activeChannel ? (activeChannel.kind === "message" ? { setReplyTo(null); onRefresh() }} onCancelReply={() => setReplyTo(null)} /> : ) : }
diff --git a/src/renderer/components/communication/SearchResults.tsx b/src/renderer/components/communication/SearchResults.tsx new file mode 100644 index 00000000..8a355071 --- /dev/null +++ b/src/renderer/components/communication/SearchResults.tsx @@ -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 = { + 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) + ? {part} + : part + ) +} + +function SkeletonRow() { + return ( +
+
+
+
+
+
+
+
+ ) +} + +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(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 ( +
+ + + +
+ ) + } + + if (results.length === 0 && query.length > 0) { + return ( +
+ +
+ ) + } + + 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 ( + +
+ + {results.length} result{results.length !== 1 ? "s" : ""} + +
+ +
+
+ {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 ( +
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)]" : ""}`} + > +
+ + +
+ {/* Row 1: Agent name, channel badge, timestamp */} +
+ + {agent?.name ?? "Unknown Agent"} + + + + {config.label} + + + {formatMessageTime(message.createdAt)} + +
+ + {/* Row 2: Subject */} +
+ {highlightMatch(message.subject, query)} +
+ + {/* Row 3: Body snippet */} +
+ {highlightMatch(message.body, query)} +
+
+
+
+ ) + })} +
+
+
+ ) +} diff --git a/src/renderer/state/slices/communication.ts b/src/renderer/state/slices/communication.ts index 1dead8dc..b2bfcde6 100644 --- a/src/renderer/state/slices/communication.ts +++ b/src/renderer/state/slices/communication.ts @@ -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; + }), });