From a401d2cc462b50cda8cdfd69333d44115a19aa3a Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Sun, 5 Jul 2026 15:13:18 +0800 Subject: [PATCH 1/5] [TASK-tsk_3b1abf5387c04f75f0814556] fix: remove unused SearchState interface --- packages/web-ui/src/hooks/useMessageSearch.ts | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 packages/web-ui/src/hooks/useMessageSearch.ts diff --git a/packages/web-ui/src/hooks/useMessageSearch.ts b/packages/web-ui/src/hooks/useMessageSearch.ts new file mode 100644 index 00000000..b123a14c --- /dev/null +++ b/packages/web-ui/src/hooks/useMessageSearch.ts @@ -0,0 +1,206 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; +import { api, type SearchResult } from '../api.js'; + +// ─── Types ──────────────────────────────────────────────────────── + +export type SearchScope = 'all' | 'channel' | 'direct'; + +export interface SearchFilters { + scope: SearchScope; + agentId?: string; + channel?: string; + sessionId?: string; + from?: string; + to?: string; +} + +type SearchStatus = 'idle' | 'searching' | 'results' | 'empty' | 'error'; + +// ─── Storage helpers ────────────────────────────────────────────── + +const RECENT_SEARCHES_KEY = 'markus_recent_searches'; +const MAX_RECENT = 10; + +function getRecentSearches(): string[] { + try { + return JSON.parse(localStorage.getItem(RECENT_SEARCHES_KEY) || '[]'); + } catch { + return []; + } +} + +function saveSearchQuery(query: string): void { + const recent = getRecentSearches(); + const filtered = recent.filter(q => q !== query); + const updated = [query, ...filtered].slice(0, MAX_RECENT); + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); +} + +function removeSearchQuery(query: string): void { + const recent = getRecentSearches(); + const updated = recent.filter(q => q !== query); + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); +} + +// ─── Hook ───────────────────────────────────────────────────────── + +export function useMessageSearch(initialFilters?: Partial) { + const [query, setQuery] = useState(''); + const [status, setStatus] = useState('idle'); + const [results, setResults] = useState([]); + const [total, setTotal] = useState(0); + const [speedMs, setSpeedMs] = useState(0); + const [error, setError] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [filters, setFilters] = useState({ + scope: 'all', + ...initialFilters, + }); + const [recentSearches, setRecentSearches] = useState(getRecentSearches); + + const debounceRef = useRef>(undefined); + const abortRef = useRef(undefined); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + abortRef.current?.abort(); + }; + }, []); + + const executeSearch = useCallback(async (q: string, searchFilters: SearchFilters) => { + if (q.length < 2) { + setResults([]); + setTotal(0); + setStatus('idle'); + return; + } + + // Abort any in-flight request + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setStatus('searching'); + setError(null); + setSelectedIndex(-1); + + try { + const response = await api.messages.search(q, { + scope: searchFilters.scope, + channel: searchFilters.channel, + agentId: searchFilters.agentId, + sessionId: searchFilters.sessionId, + from: searchFilters.from, + to: searchFilters.to, + limit: 30, + }); + + // If aborted, ignore result + if (controller.signal.aborted) return; + + const resultList = response.results ?? []; + setResults(resultList); + setTotal(response.total ?? resultList.length); + setSpeedMs(response.speedMs ?? 0); + setStatus(resultList.length > 0 ? 'results' : 'empty'); + + // Save to recent searches on successful search + if (resultList.length > 0 || q.length >= 2) { + saveSearchQuery(q); + setRecentSearches(getRecentSearches()); + } + } catch (err: unknown) { + if (controller.signal.aborted) return; + setResults([]); + setTotal(0); + setError(err instanceof Error ? err.message : 'Search failed'); + setStatus('error'); + } + }, []); + + const handleQueryChange = useCallback((q: string) => { + setQuery(q); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => executeSearch(q, filters), 300); + }, [executeSearch, filters]); + + const handleFilterChange = useCallback((newFilters: Partial) => { + const merged = { ...filters, ...newFilters }; + setFilters(merged); + // Re-search immediately with new filters (no debounce) + setQuery(q => { + executeSearch(q, merged); + return q; + }); + }, [filters, executeSearch]); + + const clearQuery = useCallback(() => { + setQuery(''); + setResults([]); + setTotal(0); + setStatus('idle'); + setError(null); + setSelectedIndex(-1); + abortRef.current?.abort(); + }, []); + + const reset = useCallback(() => { + clearQuery(); + setFilters({ scope: 'all', ...initialFilters }); + }, [clearQuery, initialFilters]); + + const clearRecentSearches = useCallback(() => { + localStorage.removeItem(RECENT_SEARCHES_KEY); + setRecentSearches([]); + }, []); + + const removeRecentSearch = useCallback((q: string) => { + removeSearchQuery(q); + setRecentSearches(getRecentSearches()); + }, []); + + // Keyboard navigation + const navigateUp = useCallback(() => { + setSelectedIndex(prev => Math.max(0, prev - 1)); + }, []); + + const navigateDown = useCallback(() => { + setSelectedIndex(prev => Math.min(results.length - 1, prev + 1)); + }, [results.length]); + + // Trigger search with existing query (for filter changes from imperative calls) + const triggerSearch = useCallback(() => { + if (query.length >= 2) { + executeSearch(query, filters); + } + }, [query, filters, executeSearch]); + + return { + // State + query, + status, + results, + total, + speedMs, + error, + selectedIndex, + filters, + recentSearches, + + // Actions + setQuery: handleQueryChange, + setFilters: handleFilterChange, + clearQuery, + reset, + clearRecentSearches, + removeRecentSearch, + navigateUp, + navigateDown, + triggerSearch, + + // Re-expose for imperative use + executeSearch: (q: string) => executeSearch(q, filters), + }; +} From 8635fae9efd87857b50bd5a474e0dcefde25d355 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Sun, 5 Jul 2026 15:18:27 +0800 Subject: [PATCH 2/5] [TASK-tsk_3b1abf5387c04f75f0814556] feat: add FTS5 search UI components (SearchResultItem, ChatSearchPanel, integrate into Team/ChatPanel, expand API types) --- packages/web-ui/src/api.ts | 26 +- packages/web-ui/src/components/ChatPanel.tsx | 21 ++ .../web-ui/src/components/ChatSearchPanel.tsx | 312 ++++++++++++++++++ .../src/components/SearchResultItem.tsx | 147 +++++++++ packages/web-ui/src/pages/Team.tsx | 87 +---- 5 files changed, 514 insertions(+), 79 deletions(-) create mode 100644 packages/web-ui/src/components/ChatSearchPanel.tsx create mode 100644 packages/web-ui/src/components/SearchResultItem.tsx diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index b91b9fbd..17b1ef99 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -89,11 +89,19 @@ export interface SearchResult { source: 'channel' | 'direct'; id: string; text: string; + /** Text snippet from FTS5 with tags for highlight rendering */ + snippetText?: string; senderName?: string; + senderId?: string; channel?: string; sessionId?: string; + sessionName?: string; agentId?: string; createdAt: string; + /** Number of matches in this message (from FTS5) */ + matchCount?: number; + /** FTS5 relevance score */ + relevanceScore?: number; } @@ -1679,12 +1687,26 @@ export const api = { // ─── Message Search ─────────────────────────────────────────────── messages: { - search: (query: string, opts?: { scope?: 'all' | 'channel' | 'direct'; channel?: string; limit?: number }) => { + search: (query: string, opts?: { + scope?: 'all' | 'channel' | 'direct'; + channel?: string; + agentId?: string; + sessionId?: string; + limit?: number; + offset?: number; + from?: string; + to?: string; + }) => { const params = new URLSearchParams({ q: query }); if (opts?.scope) params.set('scope', opts.scope); if (opts?.channel) params.set('channel', opts.channel); + if (opts?.agentId) params.set('agentId', opts.agentId); + if (opts?.sessionId) params.set('sessionId', opts.sessionId); if (opts?.limit) params.set('limit', String(opts.limit)); - return request<{ results: SearchResult[] }>(`/messages/search?${params}`); + if (opts?.offset) params.set('offset', String(opts.offset)); + if (opts?.from) params.set('from', opts.from); + if (opts?.to) params.set('to', opts.to); + return request<{ results: SearchResult[]; total?: number; speedMs?: number }>(`/messages/search?${params}`); }, }, diff --git a/packages/web-ui/src/components/ChatPanel.tsx b/packages/web-ui/src/components/ChatPanel.tsx index d2bd1e25..005ff2a4 100644 --- a/packages/web-ui/src/components/ChatPanel.tsx +++ b/packages/web-ui/src/components/ChatPanel.tsx @@ -6,6 +6,7 @@ import { type AuthUser, type StoredSegment, } from '../api.ts'; import { MarkdownMessage } from './MarkdownMessage.tsx'; +import { ChatSearchPanel } from './ChatSearchPanel.tsx'; import { AgentMessageBody, segmentsToStreamEntries, friendlyAgentError, } from '../pages/ChatComponents.tsx'; @@ -53,6 +54,7 @@ export function ChatPanel({ const [input, setInput] = useState(''); const [activities, setActivities] = useState([]); const [currentMentionChips, setCurrentMentionChips] = useState([]); + const [searchOpen, setSearchOpen] = useState(false); const chatScrollRef = useRef(null); const messagesEndRef = useRef(null); @@ -505,6 +507,13 @@ export function ChatPanel({ className="rounded-md" /> {agentName} + {onClose && ( + )} + + + + + {/* ─── Filter Toggle ───────────────────────────────────── */} + + + {/* ─── Filters Panel (collapsible) ──────────────────────── */} + {filtersOpen && ( +
+ {/* Scope filter */} +
+ +
+ {(['all', 'channel', 'direct'] as const).map(s => ( + + ))} +
+
+ + {/* Agent filter (if agents provided) */} + {agents && agents.length > 0 && ( +
+ + +
+ )} +
+ )} + + {/* ─── Results Area (scrollable) ────────────────────────── */} +
+ {search.status === 'idle' && search.query.length < 2 && ( + /* ── Recent Searches ── */ +
+ {search.recentSearches.length > 0 && ( + <> +
+ + {t('page.recentSearches') || 'Recent Searches'} + + +
+ {search.recentSearches.map(q => ( + + ))} + + )} + {search.recentSearches.length === 0 && ( +
+

{t('page.noRecentSearches') || 'No recent searches'}

+
+ )} +
+ )} + + {search.status === 'searching' && ( +
+
+ {t('page.searching')} +
+ )} + + {search.status === 'results' && search.results.length > 0 && ( +
+ {/* Result count + speed */} +
+ + {search.total > 0 + ? `${search.total} ${search.total === 1 ? 'result' : 'results'}` + : ''} + + {search.speedMs > 0 && ( + {search.speedMs}ms + )} +
+ + {/* Result items */} + {search.results.map((r, i) => ( +
+ +
+ ))} +
+ )} + + {search.status === 'empty' && ( +
+ +

{t('page.noSearchResults') || 'No messages found'}

+

+ Try different keywords or broader scope +

+
+ )} + + {search.status === 'error' && ( +
+

{search.error}

+ +
+ )} +
+
+ ); +} diff --git a/packages/web-ui/src/components/SearchResultItem.tsx b/packages/web-ui/src/components/SearchResultItem.tsx new file mode 100644 index 00000000..efe3e40c --- /dev/null +++ b/packages/web-ui/src/components/SearchResultItem.tsx @@ -0,0 +1,147 @@ +import { type ReactNode, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { SearchResult } from '../api.js'; + +// ─── Highlight renderer ─────────────────────────────────────────── + +/** + * Renders text with optional tags from FTS5 snippet. + * Falls back to query-based highlighting when no snippetText is available. + */ +function renderHighlightedText(text: string, query: string): ReactNode { + // If text contains tags (FTS5 snippet), render them with dangerouslySetInnerHTML. + // We use a custom parser since we only allow tags for security. + if (text.includes('')) { + const parts: ReactNode[] = []; + const regex = /(.*?)<\/mark>|([^<]+(?!<\/mark>))|<[^>]+>/g; + let match: RegExpExecArray | null; + let lastIndex = 0; + let key = 0; + + while ((match = regex.exec(text)) !== null) { + if (match[1] !== undefined) { + // Matched content + parts.push( + + {match[1]} + + ); + } else if (match[0]) { + // Regular text + parts.push({match[0]}); + } + lastIndex = match.index + match[0].length; + } + + // Remaining text after last match + if (lastIndex < text.length) { + parts.push({text.slice(lastIndex)}); + } + + return parts.length > 0 ? parts : text; + } + + // Fallback: client-side query highlighting (case-insensitive) + if (!query || query.length < 2) return text; + + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escaped})`, 'gi'); + const parts = text.split(regex); + + if (parts.length === 1) return text; + + return parts.map((part, i) => + regex.test(part) + ? {part} + : part + ); +} + +// ─── Helpers ────────────────────────────────────────────────────── + +function formatDate(iso: string): string { + try { + const d = new Date(iso); + const now = new Date(); + const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return d.toLocaleDateString(undefined, { weekday: 'short' }); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } catch { + return iso; + } +} + +function truncateText(text: string, maxLen = 200): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen) + '…'; +} + +// ─── Component ──────────────────────────────────────────────────── + +interface SearchResultItemProps { + result: SearchResult; + query: string; + selected?: boolean; + onSelect: (result: SearchResult) => void; +} + +export function SearchResultItem({ result, query, selected, onSelect }: SearchResultItemProps) { + const { t } = useTranslation(['team', 'common']); + + // Determine the display text — prefer snippetText (with tags) from FTS5 + const displayText = result.snippetText ?? truncateText(result.text); + + const highlighted = useMemo( + () => renderHighlightedText(displayText, query), + [displayText, query] + ); + + return ( + + ); +} diff --git a/packages/web-ui/src/pages/Team.tsx b/packages/web-ui/src/pages/Team.tsx index faf77d26..844532df 100644 --- a/packages/web-ui/src/pages/Team.tsx +++ b/packages/web-ui/src/pages/Team.tsx @@ -19,6 +19,7 @@ import { navBus } from '../navBus.ts'; import { PAGE, resolvePageId, hashPath } from '../routes.ts'; import { parseMentionNames, renderMentionText } from '../components/CommentInput.tsx'; import { ChatTeamSidebar } from '../components/ChatTeamSidebar.tsx'; +import { ChatSearchPanel } from '../components/ChatSearchPanel.tsx'; import { TeamDetailPanel } from '../components/TeamDetailPanel.tsx'; import { AgentProfile, TAB_DEF as AGENT_TAB_DEF, type ProfileTab } from './AgentProfile.tsx'; import { TeamProfile, TABS as TEAM_TABS, type TeamTab } from './TeamProfile.tsx'; @@ -451,10 +452,6 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: // Message search const [searchOpen, setSearchOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [searchLoading, setSearchLoading] = useState(false); - const searchDebounceRef = useRef>(undefined); // Teams const [teams, setTeams] = useState(previewData?.teams ?? []); @@ -2346,28 +2343,8 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: } }; - const executeSearch = useCallback(async (q: string) => { - if (q.length < 2) { setSearchResults([]); return; } - setSearchLoading(true); - try { - const scope = chatMode === 'channel' ? 'channel' : chatMode === 'direct' ? 'direct' : 'all'; - const channel = chatMode === 'channel' ? activeChannel : undefined; - const { results } = await api.messages.search(q, { scope, channel, limit: 30 }); - setSearchResults(results); - } catch { setSearchResults([]); } - setSearchLoading(false); - }, [chatMode, activeChannel]); - - const handleSearchInput = useCallback((q: string) => { - setSearchQuery(q); - if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); - searchDebounceRef.current = setTimeout(() => executeSearch(q), 300); - }, [executeSearch]); - const handleSearchResultClick = useCallback((result: import('../api.ts').SearchResult) => { setSearchOpen(false); - setSearchQuery(''); - setSearchResults([]); if (result.source === 'channel' && result.channel) { setChatMode('channel'); setActiveChannel(result.channel); @@ -2825,7 +2802,7 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: >{chatMode === 'channel' ? t('page.teamTab') : t('page.profileTab')}
-
- {searchLoading && ( -
- - {t('page.searching')} -
- )} - {!searchLoading && searchQuery.length >= 2 && searchResults.length === 0 && ( -
{t('page.noSearchResults')}
- )} - {searchResults.length > 0 && ( -
- {searchResults.map(r => ( - - ))} -
- )} +
+ setSearchOpen(false)} + />
)} From c39bb309fca3f4a35650e30d5f39759095b63186 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Sun, 5 Jul 2026 17:07:05 +0800 Subject: [PATCH 3/5] [TASK-tsk_3b1abf5387c04f75f0814556] feat: integrate FTS5 messages search into SearchModal, add dispatch-open-search event system --- .../web-ui/src/components/SearchModal.tsx | 219 +++++++++++++++++- 1 file changed, 210 insertions(+), 9 deletions(-) diff --git a/packages/web-ui/src/components/SearchModal.tsx b/packages/web-ui/src/components/SearchModal.tsx index a159ff55..30b25d60 100644 --- a/packages/web-ui/src/components/SearchModal.tsx +++ b/packages/web-ui/src/components/SearchModal.tsx @@ -1,11 +1,12 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { api, type AgentInfo, type TaskInfo, type ProjectInfo, type DeliverableInfo, type RequirementInfo, type WorkflowInfo } from '../api.ts'; +import { api, type AgentInfo, type TaskInfo, type ProjectInfo, type DeliverableInfo, type RequirementInfo, type WorkflowInfo, type SearchResult } from '../api.ts'; import { navBus } from '../navBus.ts'; import { PAGE, type PageId } from '../routes.ts'; import { Avatar } from './Avatar.tsx'; -type SearchCategory = 'all' | 'agents' | 'tasks' | 'requirements' | 'projects' | 'deliverables' | 'workflows'; +type SearchCategory = 'all' | 'agents' | 'tasks' | 'requirements' | 'projects' | 'deliverables' | 'workflows' | 'messages'; +type SearchScope = 'all' | 'channel' | 'direct'; interface SearchResults { agents: AgentInfo[]; @@ -18,7 +19,7 @@ interface SearchResults { interface FlatItem { id: string; - type: 'agent' | 'task' | 'requirement' | 'project' | 'deliverable' | 'workflow' | 'showMore'; + type: 'agent' | 'task' | 'requirement' | 'project' | 'deliverable' | 'workflow' | 'message' | 'showMore'; page: PageId; params?: Record; expandCategory?: SearchCategory; @@ -41,15 +42,26 @@ let _persistedCategory: SearchCategory = 'all'; let _persistedResults: SearchResults = EMPTY; let _persistedSearched = false; -export function SearchModal({ onClose, currentPage }: { onClose: () => void; currentPage?: string }) { +export function SearchModal({ onClose, currentPage, initialTab }: { onClose: () => void; currentPage?: string; initialTab?: SearchCategory }) { const { t } = useTranslation(['common', 'home', 'work']); + const initialCategory = initialTab && initialTab !== 'all' ? initialTab : _persistedCategory; const [query, setQuery] = useState(_persistedQuery); - const [category, setCategory] = useState(_persistedCategory); + const [category, setCategory] = useState(initialCategory); const [results, setResults] = useState(_persistedResults); const [loading, setLoading] = useState(false); const [searched, setSearched] = useState(_persistedSearched); const [focusIdx, setFocusIdx] = useState(-1); + // Message search state + const [messageResults, setMessageResults] = useState([]); + const [messageLoading, setMessageLoading] = useState(false); + const [messageTotal, setMessageTotal] = useState(0); + const [messageError, setMessageError] = useState(null); + const [messageScope, setMessageScope] = useState('all'); + + const messageAbortRef = useRef(null); + const messageDebounceRef = useRef | undefined>(undefined); + useEffect(() => { _persistedQuery = query; }, [query]); useEffect(() => { _persistedCategory = category; }, [category]); useEffect(() => { _persistedResults = results; }, [results]); @@ -63,6 +75,47 @@ export function SearchModal({ onClose, currentPage }: { onClose: () => void; cur setTimeout(() => { inputRef.current?.focus(); inputRef.current?.select(); }, 100); }, []); + // ─── Messages search ─────────────────────────────────────────────── + const doMessageSearch = useCallback(async (q: string, scope: SearchScope, channel?: string, agentId?: string) => { + if (!q.trim() || q.length < 2) { + setMessageResults([]); + setMessageTotal(0); + setMessageLoading(false); + return; + } + messageAbortRef.current?.abort(); + const controller = new AbortController(); + messageAbortRef.current = controller; + setMessageLoading(true); + setMessageError(null); + try { + const response = await api.messages.search(q, { scope, channel, agentId, limit: 30 }); + if (controller.signal.aborted) return; + setMessageResults(response.results ?? []); + setMessageTotal(response.total ?? response.results?.length ?? 0); + } catch (err: unknown) { + if (controller.signal.aborted) return; + setMessageResults([]); + setMessageTotal(0); + setMessageError(err instanceof Error ? err.message : 'Search failed'); + } finally { + if (!controller.signal.aborted) setMessageLoading(false); + } + }, []); + + const handleMessageInput = useCallback((q: string) => { + if (messageDebounceRef.current) clearTimeout(messageDebounceRef.current); + messageDebounceRef.current = setTimeout(() => doMessageSearch(q, messageScope), 300); + }, [doMessageSearch, messageScope]); + + const handleMessageScopeChange = useCallback((scope: SearchScope) => { + setMessageScope(scope); + if (query.trim().length >= 2) { + doMessageSearch(query, scope); + } + }, [query, doMessageSearch]); + + // ─── Entity search ───────────────────────────────────────────────── const doSearch = useCallback(async (q: string) => { if (!q.trim()) { setResults(EMPTY); setSearched(false); setFocusIdx(-1); return; } setLoading(true); @@ -114,6 +167,10 @@ export function SearchModal({ onClose, currentPage }: { onClose: () => void; cur const handleInput = (value: string) => { setQuery(value); + if (category === 'messages') { + handleMessageInput(value); + return; + } if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => doSearch(value), 400); }; @@ -131,6 +188,7 @@ export function SearchModal({ onClose, currentPage }: { onClose: () => void; cur { id: 'projects', label: t('common:projects', { defaultValue: '项目' }) }, { id: 'deliverables', label: t('common:deliverables', { defaultValue: '交付物' }) }, { id: 'workflows', label: t('common:workflows', { defaultValue: '工作流' }) }, + { id: 'messages', label: t('common:chatMessages', { defaultValue: '对话' }) }, ]; const sectionOrder = useMemo(() => PAGE_SECTION_ORDER[currentPage || ''] || DEFAULT_ORDER, [currentPage]); @@ -267,20 +325,130 @@ export function SearchModal({ onClose, currentPage }: { onClose: () => void; cur {/* Results */}
- {loading && ( + {/* ── Messages tab ───────────────────────────────────── */} + {category === 'messages' && ( + <> + {/* Scope filter (only visible in messages tab) */} +
+ {t('page.searchScope', { defaultValue: '范围' })}: + {(['all', 'channel', 'direct'] as const).map(s => ( + + ))} +
+ + {/* Loading */} + {messageLoading && ( +
+
{t('common:loading')}
+
+ )} + + {/* Results */} + {!messageLoading && messageResults.length > 0 && ( +
+
+ {messageTotal > 0 ? `${messageTotal} ${t('common:results', { defaultValue: '个结果' })}` : ''} +
+ {messageResults.map((r) => { + const idx = itemCounter++; + const displayText = r.snippetText ?? r.text; + return ( + + ); + })} +
+ )} + + {/* Empty */} + {!messageLoading && query.length >= 2 && messageResults.length === 0 && !messageError && ( +
+ +

{t('common:noResults', { defaultValue: '没有找到相关结果' })}

+
+ )} + + {/* Error */} + {messageError && ( +
+

{messageError}

+ +
+ )} + + {/* Initial state */} + {!messageLoading && query.length < 2 && ( +
+ +

{t('common:searchHint', { defaultValue: '输入关键词搜索对话内容' })}

+
+ )} + + )} + + {/* ── Entity tabs (non-messages) ──────────────────────── */} + {category !== 'messages' && loading && (
{t('common:loading')}
)} - {!loading && searched && !hasResults && ( + {category !== 'messages' && !loading && searched && !hasResults && (

{t('common:noResults', { defaultValue: '没有找到相关结果' })}

)} - {!loading && hasResults && ( + {category !== 'messages' && !loading && hasResults && (
{sectionOrder.map(section => { if (category !== 'all' && category !== section) return null; @@ -438,7 +606,7 @@ export function SearchModal({ onClose, currentPage }: { onClose: () => void; cur
)} - {!loading && !searched && ( + {category !== 'messages' && !loading && !searched && (

{t('common:searchHint', { defaultValue: '输入关键词搜索' })}

@@ -459,6 +627,39 @@ export function SearchModal({ onClose, currentPage }: { onClose: () => void; cur ); } +/** + * Render text with tags from FTS5 snippet, or fallback to case-insensitive highlight. + */ +function renderMessageHighlight(text: string, query: string): React.ReactNode { + if (text.includes('')) { + const parts: React.ReactNode[] = []; + const regex = /(.*?)<\/mark>|([^<]+(?!<\/mark>))|<[^>]+>/g; + let match: RegExpExecArray | null; + let lastIndex = 0; + let key = 0; + while ((match = regex.exec(text)) !== null) { + if (match[1] !== undefined) { + parts.push({match[1]}); + } else if (match[0]) { + parts.push({match[0]}); + } + lastIndex = match.index + match[0].length; + } + if (lastIndex < text.length) parts.push({text.slice(lastIndex)}); + return parts.length > 0 ? parts : text; + } + if (!query || query.length < 2) return text; + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\function StatusDot({ status }: { status: string }) {'); + const regex = new RegExp(`(${escaped})`, 'gi'); + const parts = text.split(regex); + if (parts.length === 1) return text; + return parts.map((part, i) => + regex.test(part) + ? {part} + : part + ); +} + function StatusDot({ status }: { status: string }) { const color = status === 'active' || status === 'idle' ? 'bg-green-500' : status === 'working' || status === 'busy' ? 'bg-blue-500' From cf2b89cdf05db75d532804a357ebd8bd57477daa Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Sun, 5 Jul 2026 17:12:50 +0800 Subject: [PATCH 4/5] =?UTF-8?q?[TSK-3b1abf5]=20feat:=20FTS5=20search=20UI?= =?UTF-8?q?=20=E2=80=94=20replace=20inline=20ChatSearchPanel=20with=20glob?= =?UTF-8?q?al=20SearchModal=20+=20messages=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchModal: export SearchCategory type for typed initialTab support - App.tsx: listen to markus:open-search CustomEvent with detail.initialTab - ChatPanel: replace inline ChatSearchPanel button with global SearchModal dispatch (initialTab='messages') - Team.tsx: remove ChatSearchPanel integration entirely - Delete obsolete files: ChatSearchPanel.tsx, SearchResultItem.tsx, useMessageSearch.ts --- packages/web-ui/src/App.tsx | 17 +- packages/web-ui/src/components/ChatPanel.tsx | 18 +- .../web-ui/src/components/ChatSearchPanel.tsx | 312 ------------------ .../web-ui/src/components/SearchModal.tsx | 2 +- .../src/components/SearchResultItem.tsx | 147 --------- packages/web-ui/src/hooks/useMessageSearch.ts | 206 ------------ packages/web-ui/src/pages/Team.tsx | 13 - 7 files changed, 15 insertions(+), 700 deletions(-) delete mode 100644 packages/web-ui/src/components/ChatSearchPanel.tsx delete mode 100644 packages/web-ui/src/components/SearchResultItem.tsx delete mode 100644 packages/web-ui/src/hooks/useMessageSearch.ts diff --git a/packages/web-ui/src/App.tsx b/packages/web-ui/src/App.tsx index 0e2653ec..62da540a 100644 --- a/packages/web-ui/src/App.tsx +++ b/packages/web-ui/src/App.tsx @@ -24,7 +24,7 @@ import { useTheme } from './hooks/useTheme.ts'; import { useIsMobile } from './hooks/useIsMobile.ts'; import { prefetch, PREFETCH_KEYS } from './prefetchCache.ts'; import { useTranslation } from 'react-i18next'; -import { SearchModal } from './components/SearchModal.tsx'; +import { SearchModal, type SearchCategory } from './components/SearchModal.tsx'; const HIDDEN_STYLE: React.CSSProperties = { visibility: 'hidden', @@ -110,6 +110,7 @@ export function App() { }, [checkLicenseLimits]); const [showSearchModal, setShowSearchModal] = useState(false); + const [searchInitialTab, setSearchInitialTab] = useState(); // Global search shortcut: Cmd+P (Mac) / Ctrl+P (Win/Linux) useEffect(() => { @@ -118,16 +119,22 @@ export function App() { const onKey = (e: KeyboardEvent) => { if (isMac && e.metaKey && !e.ctrlKey && e.key === 'p') { e.preventDefault(); + setSearchInitialTab(undefined); setShowSearchModal(prev => !prev); } else if (!isMac && e.ctrlKey && !e.metaKey && e.key === 'p') { e.preventDefault(); + setSearchInitialTab(undefined); setShowSearchModal(prev => !prev); } }; - const onOpen = () => setShowSearchModal(true); + const onOpen = (e: Event) => { + const detail = (e as CustomEvent).detail; + setSearchInitialTab(detail?.initialTab); + setShowSearchModal(true); + }; document.addEventListener('keydown', onKey); - window.addEventListener('markus:open-search', onOpen); - return () => { document.removeEventListener('keydown', onKey); window.removeEventListener('markus:open-search', onOpen); }; + window.addEventListener('markus:open-search', onOpen as EventListener); + return () => { document.removeEventListener('keydown', onKey); window.removeEventListener('markus:open-search', onOpen as EventListener); }; }, [isMobile]); const navigate = useCallback((p: PageId) => { @@ -449,7 +456,7 @@ export function App() { {/* Global search modal (desktop) */} {!isMobile && showSearchModal && ( - setShowSearchModal(false)} currentPage={page} /> + { setShowSearchModal(false); setSearchInitialTab(undefined); }} currentPage={page} initialTab={searchInitialTab} /> )}
); diff --git a/packages/web-ui/src/components/ChatPanel.tsx b/packages/web-ui/src/components/ChatPanel.tsx index 005ff2a4..15839674 100644 --- a/packages/web-ui/src/components/ChatPanel.tsx +++ b/packages/web-ui/src/components/ChatPanel.tsx @@ -6,7 +6,6 @@ import { type AuthUser, type StoredSegment, } from '../api.ts'; import { MarkdownMessage } from './MarkdownMessage.tsx'; -import { ChatSearchPanel } from './ChatSearchPanel.tsx'; import { AgentMessageBody, segmentsToStreamEntries, friendlyAgentError, } from '../pages/ChatComponents.tsx'; @@ -54,7 +53,6 @@ export function ChatPanel({ const [input, setInput] = useState(''); const [activities, setActivities] = useState([]); const [currentMentionChips, setCurrentMentionChips] = useState([]); - const [searchOpen, setSearchOpen] = useState(false); const chatScrollRef = useRef(null); const messagesEndRef = useRef(null); @@ -508,8 +506,8 @@ export function ChatPanel({ /> {agentName}
- {searchOpen && ( -
- { - setSearchOpen(false); - }} - onClose={() => setSearchOpen(false)} - /> -
- )} - {/* Messages */}
- - - - ); -} - -function ClearIcon() { - return ( - - - - ); -} - -function ClockIcon() { - return ( - - - - - ); -} - -function ChevronDownIcon() { - return ( - - - - ); -} - -function FilterIcon() { - return ( - - - - ); -} - -// ─── Props ───────────────────────────────────────────────────────── - -interface ChatSearchPanelProps { - /** Optional initial scope filter */ - scope?: 'all' | 'channel' | 'direct'; - /** Optional initial channel filter */ - channel?: string; - /** Optional initial agent filter */ - agentId?: string; - /** Optional agent display names for filter selection */ - agents?: { id: string; name: string }[]; - /** Called when user selects a result — e.g. jump to message */ - onSelectResult: (result: SearchResult) => void; - /** Called when user dismisses/close the search panel */ - onClose: () => void; -} - -// ─── Component ───────────────────────────────────────────────────── - -export function ChatSearchPanel({ scope, channel, agentId, agents, onSelectResult, onClose }: ChatSearchPanelProps) { - const { t } = useTranslation(['team', 'common']); - const inputRef = useRef(null); - const listRef = useRef(null); - const [filtersOpen, setFiltersOpen] = useState(false); - - const search = useMessageSearch({ scope, channel, agentId }); - - // Auto-focus input on mount - useEffect(() => { - inputRef.current?.focus(); - }, []); - - // Scroll selected item into view - const selectedResult = search.selectedIndex >= 0 ? search.results[search.selectedIndex] : null; - - useEffect(() => { - if (selectedResult && listRef.current) { - const selectedEl = listRef.current.querySelector('[data-selected="true"]'); - selectedEl?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } - }, [selectedResult]); - - // Keyboard handler - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault(); - search.navigateDown(); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - search.navigateUp(); - } else if (e.key === 'Enter') { - if (selectedResult) { - onSelectResult(selectedResult); - } else if (search.results.length > 0) { - onSelectResult(search.results[0]); - } - } else if (e.key === 'Escape') { - if (search.query) { - search.clearQuery(); - } else { - onClose(); - } - } - }, [search, selectedResult, onSelectResult, onClose]); - - const handleSelectResult = useCallback((result: SearchResult) => { - onSelectResult(result); - }, [onSelectResult]); - - const handleClickRecent = useCallback((q: string) => { - search.setQuery(q); - }, [search]); - - return ( -
- {/* ─── Search Input ───────────────────────────────────── */} -
-
- - - - search.setQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={t('page.searchPlaceholder')} - className="w-full h-8 pl-8 pr-7 text-xs bg-surface-secondary rounded-lg border border-border-primary focus:outline-none focus:border-brand-500/50 placeholder:text-fg-tertiary/50 text-fg-primary" - /> - {search.query && ( - - )} -
- -
- - {/* ─── Filter Toggle ───────────────────────────────────── */} - - - {/* ─── Filters Panel (collapsible) ──────────────────────── */} - {filtersOpen && ( -
- {/* Scope filter */} -
- -
- {(['all', 'channel', 'direct'] as const).map(s => ( - - ))} -
-
- - {/* Agent filter (if agents provided) */} - {agents && agents.length > 0 && ( -
- - -
- )} -
- )} - - {/* ─── Results Area (scrollable) ────────────────────────── */} -
- {search.status === 'idle' && search.query.length < 2 && ( - /* ── Recent Searches ── */ -
- {search.recentSearches.length > 0 && ( - <> -
- - {t('page.recentSearches') || 'Recent Searches'} - - -
- {search.recentSearches.map(q => ( - - ))} - - )} - {search.recentSearches.length === 0 && ( -
-

{t('page.noRecentSearches') || 'No recent searches'}

-
- )} -
- )} - - {search.status === 'searching' && ( -
-
- {t('page.searching')} -
- )} - - {search.status === 'results' && search.results.length > 0 && ( -
- {/* Result count + speed */} -
- - {search.total > 0 - ? `${search.total} ${search.total === 1 ? 'result' : 'results'}` - : ''} - - {search.speedMs > 0 && ( - {search.speedMs}ms - )} -
- - {/* Result items */} - {search.results.map((r, i) => ( -
- -
- ))} -
- )} - - {search.status === 'empty' && ( -
- -

{t('page.noSearchResults') || 'No messages found'}

-

- Try different keywords or broader scope -

-
- )} - - {search.status === 'error' && ( -
-

{search.error}

- -
- )} -
-
- ); -} diff --git a/packages/web-ui/src/components/SearchModal.tsx b/packages/web-ui/src/components/SearchModal.tsx index 30b25d60..8ee02afc 100644 --- a/packages/web-ui/src/components/SearchModal.tsx +++ b/packages/web-ui/src/components/SearchModal.tsx @@ -5,7 +5,7 @@ import { navBus } from '../navBus.ts'; import { PAGE, type PageId } from '../routes.ts'; import { Avatar } from './Avatar.tsx'; -type SearchCategory = 'all' | 'agents' | 'tasks' | 'requirements' | 'projects' | 'deliverables' | 'workflows' | 'messages'; +export type SearchCategory = 'all' | 'agents' | 'tasks' | 'requirements' | 'projects' | 'deliverables' | 'workflows' | 'messages'; type SearchScope = 'all' | 'channel' | 'direct'; interface SearchResults { diff --git a/packages/web-ui/src/components/SearchResultItem.tsx b/packages/web-ui/src/components/SearchResultItem.tsx deleted file mode 100644 index efe3e40c..00000000 --- a/packages/web-ui/src/components/SearchResultItem.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { type ReactNode, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import type { SearchResult } from '../api.js'; - -// ─── Highlight renderer ─────────────────────────────────────────── - -/** - * Renders text with optional tags from FTS5 snippet. - * Falls back to query-based highlighting when no snippetText is available. - */ -function renderHighlightedText(text: string, query: string): ReactNode { - // If text contains tags (FTS5 snippet), render them with dangerouslySetInnerHTML. - // We use a custom parser since we only allow tags for security. - if (text.includes('')) { - const parts: ReactNode[] = []; - const regex = /(.*?)<\/mark>|([^<]+(?!<\/mark>))|<[^>]+>/g; - let match: RegExpExecArray | null; - let lastIndex = 0; - let key = 0; - - while ((match = regex.exec(text)) !== null) { - if (match[1] !== undefined) { - // Matched content - parts.push( - - {match[1]} - - ); - } else if (match[0]) { - // Regular text - parts.push({match[0]}); - } - lastIndex = match.index + match[0].length; - } - - // Remaining text after last match - if (lastIndex < text.length) { - parts.push({text.slice(lastIndex)}); - } - - return parts.length > 0 ? parts : text; - } - - // Fallback: client-side query highlighting (case-insensitive) - if (!query || query.length < 2) return text; - - const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(`(${escaped})`, 'gi'); - const parts = text.split(regex); - - if (parts.length === 1) return text; - - return parts.map((part, i) => - regex.test(part) - ? {part} - : part - ); -} - -// ─── Helpers ────────────────────────────────────────────────────── - -function formatDate(iso: string): string { - try { - const d = new Date(iso); - const now = new Date(); - const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)); - - if (diffDays === 0) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); - if (diffDays === 1) return 'Yesterday'; - if (diffDays < 7) return d.toLocaleDateString(undefined, { weekday: 'short' }); - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); - } catch { - return iso; - } -} - -function truncateText(text: string, maxLen = 200): string { - if (text.length <= maxLen) return text; - return text.slice(0, maxLen) + '…'; -} - -// ─── Component ──────────────────────────────────────────────────── - -interface SearchResultItemProps { - result: SearchResult; - query: string; - selected?: boolean; - onSelect: (result: SearchResult) => void; -} - -export function SearchResultItem({ result, query, selected, onSelect }: SearchResultItemProps) { - const { t } = useTranslation(['team', 'common']); - - // Determine the display text — prefer snippetText (with tags) from FTS5 - const displayText = result.snippetText ?? truncateText(result.text); - - const highlighted = useMemo( - () => renderHighlightedText(displayText, query), - [displayText, query] - ); - - return ( - - ); -} diff --git a/packages/web-ui/src/hooks/useMessageSearch.ts b/packages/web-ui/src/hooks/useMessageSearch.ts deleted file mode 100644 index b123a14c..00000000 --- a/packages/web-ui/src/hooks/useMessageSearch.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { useState, useRef, useCallback, useEffect } from 'react'; -import { api, type SearchResult } from '../api.js'; - -// ─── Types ──────────────────────────────────────────────────────── - -export type SearchScope = 'all' | 'channel' | 'direct'; - -export interface SearchFilters { - scope: SearchScope; - agentId?: string; - channel?: string; - sessionId?: string; - from?: string; - to?: string; -} - -type SearchStatus = 'idle' | 'searching' | 'results' | 'empty' | 'error'; - -// ─── Storage helpers ────────────────────────────────────────────── - -const RECENT_SEARCHES_KEY = 'markus_recent_searches'; -const MAX_RECENT = 10; - -function getRecentSearches(): string[] { - try { - return JSON.parse(localStorage.getItem(RECENT_SEARCHES_KEY) || '[]'); - } catch { - return []; - } -} - -function saveSearchQuery(query: string): void { - const recent = getRecentSearches(); - const filtered = recent.filter(q => q !== query); - const updated = [query, ...filtered].slice(0, MAX_RECENT); - localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); -} - -function removeSearchQuery(query: string): void { - const recent = getRecentSearches(); - const updated = recent.filter(q => q !== query); - localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); -} - -// ─── Hook ───────────────────────────────────────────────────────── - -export function useMessageSearch(initialFilters?: Partial) { - const [query, setQuery] = useState(''); - const [status, setStatus] = useState('idle'); - const [results, setResults] = useState([]); - const [total, setTotal] = useState(0); - const [speedMs, setSpeedMs] = useState(0); - const [error, setError] = useState(null); - const [selectedIndex, setSelectedIndex] = useState(-1); - const [filters, setFilters] = useState({ - scope: 'all', - ...initialFilters, - }); - const [recentSearches, setRecentSearches] = useState(getRecentSearches); - - const debounceRef = useRef>(undefined); - const abortRef = useRef(undefined); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - abortRef.current?.abort(); - }; - }, []); - - const executeSearch = useCallback(async (q: string, searchFilters: SearchFilters) => { - if (q.length < 2) { - setResults([]); - setTotal(0); - setStatus('idle'); - return; - } - - // Abort any in-flight request - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - - setStatus('searching'); - setError(null); - setSelectedIndex(-1); - - try { - const response = await api.messages.search(q, { - scope: searchFilters.scope, - channel: searchFilters.channel, - agentId: searchFilters.agentId, - sessionId: searchFilters.sessionId, - from: searchFilters.from, - to: searchFilters.to, - limit: 30, - }); - - // If aborted, ignore result - if (controller.signal.aborted) return; - - const resultList = response.results ?? []; - setResults(resultList); - setTotal(response.total ?? resultList.length); - setSpeedMs(response.speedMs ?? 0); - setStatus(resultList.length > 0 ? 'results' : 'empty'); - - // Save to recent searches on successful search - if (resultList.length > 0 || q.length >= 2) { - saveSearchQuery(q); - setRecentSearches(getRecentSearches()); - } - } catch (err: unknown) { - if (controller.signal.aborted) return; - setResults([]); - setTotal(0); - setError(err instanceof Error ? err.message : 'Search failed'); - setStatus('error'); - } - }, []); - - const handleQueryChange = useCallback((q: string) => { - setQuery(q); - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => executeSearch(q, filters), 300); - }, [executeSearch, filters]); - - const handleFilterChange = useCallback((newFilters: Partial) => { - const merged = { ...filters, ...newFilters }; - setFilters(merged); - // Re-search immediately with new filters (no debounce) - setQuery(q => { - executeSearch(q, merged); - return q; - }); - }, [filters, executeSearch]); - - const clearQuery = useCallback(() => { - setQuery(''); - setResults([]); - setTotal(0); - setStatus('idle'); - setError(null); - setSelectedIndex(-1); - abortRef.current?.abort(); - }, []); - - const reset = useCallback(() => { - clearQuery(); - setFilters({ scope: 'all', ...initialFilters }); - }, [clearQuery, initialFilters]); - - const clearRecentSearches = useCallback(() => { - localStorage.removeItem(RECENT_SEARCHES_KEY); - setRecentSearches([]); - }, []); - - const removeRecentSearch = useCallback((q: string) => { - removeSearchQuery(q); - setRecentSearches(getRecentSearches()); - }, []); - - // Keyboard navigation - const navigateUp = useCallback(() => { - setSelectedIndex(prev => Math.max(0, prev - 1)); - }, []); - - const navigateDown = useCallback(() => { - setSelectedIndex(prev => Math.min(results.length - 1, prev + 1)); - }, [results.length]); - - // Trigger search with existing query (for filter changes from imperative calls) - const triggerSearch = useCallback(() => { - if (query.length >= 2) { - executeSearch(query, filters); - } - }, [query, filters, executeSearch]); - - return { - // State - query, - status, - results, - total, - speedMs, - error, - selectedIndex, - filters, - recentSearches, - - // Actions - setQuery: handleQueryChange, - setFilters: handleFilterChange, - clearQuery, - reset, - clearRecentSearches, - removeRecentSearch, - navigateUp, - navigateDown, - triggerSearch, - - // Re-expose for imperative use - executeSearch: (q: string) => executeSearch(q, filters), - }; -} diff --git a/packages/web-ui/src/pages/Team.tsx b/packages/web-ui/src/pages/Team.tsx index 844532df..5831faf5 100644 --- a/packages/web-ui/src/pages/Team.tsx +++ b/packages/web-ui/src/pages/Team.tsx @@ -19,7 +19,6 @@ import { navBus } from '../navBus.ts'; import { PAGE, resolvePageId, hashPath } from '../routes.ts'; import { parseMentionNames, renderMentionText } from '../components/CommentInput.tsx'; import { ChatTeamSidebar } from '../components/ChatTeamSidebar.tsx'; -import { ChatSearchPanel } from '../components/ChatSearchPanel.tsx'; import { TeamDetailPanel } from '../components/TeamDetailPanel.tsx'; import { AgentProfile, TAB_DEF as AGENT_TAB_DEF, type ProfileTab } from './AgentProfile.tsx'; import { TeamProfile, TABS as TEAM_TABS, type TeamTab } from './TeamProfile.tsx'; @@ -3057,18 +3056,6 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: })() )} - {/* Search panel */} - {searchOpen && ( -
- setSearchOpen(false)} - /> -
- )} - {/* Session tab bar (direct mode, chat tab) — hide when only 1 session */} {chatMode === 'direct' && selectedAgent && mainTab === 'chat' && openSessionTabs.length > 1 && (
From 7ca2690f8c6ea601ccc05bd6328e95576ecb5ad6 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Tue, 7 Jul 2026 18:16:45 +0800 Subject: [PATCH 5/5] [TASK-tsk_3b1abf5387c04f75f0814556] fix: Team.tsx search buttons dispatch markus:open-search event - Both search buttons now dispatch CustomEvent 'markus:open-search' with { detail: { initialTab: 'messages' } } instead of toggling local searchOpen state - Removed unused searchOpen state and setSearchOpen - Removed dead handleSearchResultClick function (ChatSearchPanel no longer exists to call it) - Cleaned up conditional className based on searchOpen --- packages/web-ui/src/pages/Team.tsx | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/packages/web-ui/src/pages/Team.tsx b/packages/web-ui/src/pages/Team.tsx index 5831faf5..d265f6d9 100644 --- a/packages/web-ui/src/pages/Team.tsx +++ b/packages/web-ui/src/pages/Team.tsx @@ -449,9 +449,6 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: const pendingSelectTeamRef = useRef(null); const [showMemberPanel, setShowMemberPanel] = useState(false); - // Message search - const [searchOpen, setSearchOpen] = useState(false); - // Teams const [teams, setTeams] = useState(previewData?.teams ?? []); @@ -2342,25 +2339,6 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: } }; - const handleSearchResultClick = useCallback((result: import('../api.ts').SearchResult) => { - setSearchOpen(false); - if (result.source === 'channel' && result.channel) { - setChatMode('channel'); - setActiveChannel(result.channel); - } else if (result.source === 'direct' && result.agentId) { - setChatMode('direct'); - setSelectedAgent(result.agentId); - } - setTimeout(() => { - const el = document.getElementById(`msg-${result.id}`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - el.classList.add('bg-brand-500/10'); - setTimeout(() => el.classList.remove('bg-brand-500/10'), 2000); - } - }, 500); - }, []); - const newConversation = () => { setActiveSessionId(NEW_CHAT_PLACEHOLDER_ID); const key = currentConvKeyRef.current; @@ -2801,8 +2779,8 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: >{chatMode === 'channel' ? t('page.teamTab') : t('page.profileTab')}
@@ -2988,8 +2966,8 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: {/* Right side buttons */}