- SearchBar: 300ms debounce with local input state, flush on Enter - CommandPalette: global search debounce 200→400ms - Show "500+" when search results are capped - Fix: syncSearchMatchesWithRendered now updates searchMatchItemIds Ported from upstream 5c7f921e (SearchBar/CommandPalette parts).
510 lines
16 KiB
TypeScript
510 lines
16 KiB
TypeScript
/**
|
|
* Conversation slice - manages expansion states, chart mode, search, and detail popover.
|
|
*/
|
|
|
|
import { findLastOutput } from '@renderer/utils/aiGroupEnhancer';
|
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
|
|
|
import type { AppState, SearchMatch } from '../types';
|
|
import type { AIGroupExpansionLevel } from '@renderer/types/groups';
|
|
import type { SessionConversation } from '@renderer/types/groups';
|
|
import type { StateCreator } from 'zustand';
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
type DetailItemType = 'thinking' | 'text' | 'linked-tool' | 'subagent';
|
|
|
|
/** Maximum number of search matches to track. Beyond this, results are capped. */
|
|
const MAX_SEARCH_MATCHES = 500;
|
|
|
|
const isSearchDebugEnabled = (): boolean => {
|
|
if (typeof window === 'undefined') return false;
|
|
try {
|
|
return (
|
|
window.localStorage.getItem('search-debug') === '1' ||
|
|
(window as { __searchDebug?: boolean }).__searchDebug === true
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export interface ActiveDetailItem {
|
|
aiGroupId: string;
|
|
itemId: string;
|
|
type: DetailItemType;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Slice Interface
|
|
// =============================================================================
|
|
|
|
export interface ConversationSlice {
|
|
// Expansion states
|
|
aiGroupExpansionLevels: Map<string, AIGroupExpansionLevel>;
|
|
expandedStepIds: Set<string>;
|
|
/** Display item expansion state per AI group - persists across refreshes */
|
|
expandedDisplayItemIds: Map<string, Set<string>>;
|
|
/** AI group expanded/collapsed state - persists across refreshes */
|
|
expandedAIGroupIds: Set<string>;
|
|
|
|
// Detail popover state
|
|
activeDetailItem: ActiveDetailItem | null;
|
|
|
|
// Search state
|
|
searchQuery: string;
|
|
searchVisible: boolean;
|
|
searchResultCount: number;
|
|
currentSearchIndex: number;
|
|
searchMatches: SearchMatch[];
|
|
/** True when total matches exceeded the cap and results were truncated */
|
|
searchResultsCapped: boolean;
|
|
/** Item IDs that contain at least one search match — used by components to skip re-renders */
|
|
searchMatchItemIds: Set<string>;
|
|
|
|
// Auto-expand state for search results
|
|
/** AI group IDs that should be expanded to show search results */
|
|
searchExpandedAIGroupIds: Set<string>;
|
|
/** Subagent IDs within AI groups that should show their execution trace */
|
|
searchExpandedSubagentIds: Set<string>;
|
|
/** Current search result's display item ID for precise expansion (e.g., "thinking-0") */
|
|
searchCurrentDisplayItemId: string | null;
|
|
/** Current search result's item ID within subagent trace (e.g., "subagent-thinking-0") */
|
|
searchCurrentSubagentItemId: string | null;
|
|
|
|
// Actions
|
|
setAIGroupExpansion: (aiGroupId: string, level: AIGroupExpansionLevel) => void;
|
|
toggleStepExpansion: (stepId: string) => void;
|
|
/** Toggle expansion of a display item within an AI group */
|
|
toggleDisplayItemExpansion: (aiGroupId: string, itemId: string) => void;
|
|
/** Get expanded display item IDs for an AI group */
|
|
getExpandedDisplayItemIds: (aiGroupId: string) => Set<string>;
|
|
/** Toggle AI group expanded/collapsed state */
|
|
toggleAIGroupExpansion: (aiGroupId: string) => void;
|
|
|
|
// Detail popover actions
|
|
showDetailPopover: (
|
|
aiGroupId: string,
|
|
itemId: string,
|
|
type: 'thinking' | 'text' | 'linked-tool' | 'subagent'
|
|
) => void;
|
|
hideDetailPopover: () => void;
|
|
|
|
// Search actions
|
|
setSearchQuery: (query: string, conversationOverride?: SessionConversation | null) => void;
|
|
/** Canonicalize search matches from currently rendered mark elements (DOM order) */
|
|
syncSearchMatchesWithRendered: (
|
|
renderedMatches: { itemId: string; matchIndexInItem: number }[]
|
|
) => void;
|
|
/** Select a specific search match by item ID and in-item match index */
|
|
selectSearchMatch: (itemId: string, matchIndexInItem: number) => boolean;
|
|
showSearch: () => void;
|
|
hideSearch: () => void;
|
|
nextSearchResult: () => void;
|
|
previousSearchResult: () => void;
|
|
/** Expand AI groups and subagents needed to show the current search result */
|
|
expandForCurrentSearchResult: () => void;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Slice Creator
|
|
// =============================================================================
|
|
|
|
export const createConversationSlice: StateCreator<AppState, [], [], ConversationSlice> = (
|
|
set,
|
|
get
|
|
) => ({
|
|
// Initial state
|
|
aiGroupExpansionLevels: new Map(),
|
|
expandedStepIds: new Set(),
|
|
expandedDisplayItemIds: new Map(),
|
|
expandedAIGroupIds: new Set(),
|
|
|
|
ganttChartMode: 'timeline',
|
|
|
|
activeDetailItem: null,
|
|
|
|
// Search state (initial values)
|
|
searchQuery: '',
|
|
searchVisible: false,
|
|
searchResultCount: 0,
|
|
currentSearchIndex: -1,
|
|
searchMatches: [],
|
|
searchResultsCapped: false,
|
|
searchMatchItemIds: new Set(),
|
|
|
|
// Auto-expand state for search results (initial values)
|
|
searchExpandedAIGroupIds: new Set(),
|
|
searchExpandedSubagentIds: new Set(),
|
|
searchCurrentDisplayItemId: null,
|
|
searchCurrentSubagentItemId: null,
|
|
|
|
// Set expansion level for a specific AI Group
|
|
setAIGroupExpansion: (aiGroupId: string, level: AIGroupExpansionLevel) => {
|
|
const state = get();
|
|
const newLevels = new Map(state.aiGroupExpansionLevels);
|
|
newLevels.set(aiGroupId, level);
|
|
set({ aiGroupExpansionLevels: newLevels });
|
|
},
|
|
|
|
// Toggle expansion state for a semantic step
|
|
toggleStepExpansion: (stepId: string) => {
|
|
const state = get();
|
|
const newExpandedStepIds = new Set(state.expandedStepIds);
|
|
if (newExpandedStepIds.has(stepId)) {
|
|
newExpandedStepIds.delete(stepId);
|
|
} else {
|
|
newExpandedStepIds.add(stepId);
|
|
}
|
|
set({ expandedStepIds: newExpandedStepIds });
|
|
},
|
|
|
|
// Toggle expansion of a display item within an AI group
|
|
toggleDisplayItemExpansion: (aiGroupId: string, itemId: string) => {
|
|
const state = get();
|
|
const newMap = new Map(state.expandedDisplayItemIds);
|
|
const currentSet = newMap.get(aiGroupId) ?? new Set<string>();
|
|
const newSet = new Set(currentSet);
|
|
|
|
if (newSet.has(itemId)) {
|
|
newSet.delete(itemId);
|
|
} else {
|
|
newSet.add(itemId);
|
|
}
|
|
|
|
newMap.set(aiGroupId, newSet);
|
|
set({ expandedDisplayItemIds: newMap });
|
|
},
|
|
|
|
// Get expanded display item IDs for an AI group
|
|
getExpandedDisplayItemIds: (aiGroupId: string) => {
|
|
const state = get();
|
|
return state.expandedDisplayItemIds.get(aiGroupId) ?? new Set<string>();
|
|
},
|
|
|
|
// Toggle AI group expanded/collapsed state
|
|
toggleAIGroupExpansion: (aiGroupId: string) => {
|
|
const state = get();
|
|
const newSet = new Set(state.expandedAIGroupIds);
|
|
if (newSet.has(aiGroupId)) {
|
|
newSet.delete(aiGroupId);
|
|
} else {
|
|
newSet.add(aiGroupId);
|
|
}
|
|
set({ expandedAIGroupIds: newSet });
|
|
},
|
|
|
|
// Show detail popover
|
|
showDetailPopover: (
|
|
aiGroupId: string,
|
|
itemId: string,
|
|
type: 'thinking' | 'text' | 'linked-tool' | 'subagent'
|
|
) => {
|
|
set({
|
|
activeDetailItem: {
|
|
aiGroupId,
|
|
itemId,
|
|
type,
|
|
},
|
|
});
|
|
},
|
|
|
|
// Hide detail popover
|
|
hideDetailPopover: () => {
|
|
set({ activeDetailItem: null });
|
|
},
|
|
|
|
// Search actions
|
|
|
|
setSearchQuery: (query: string, conversationOverride?: SessionConversation | null) => {
|
|
const conversation = conversationOverride ?? get().conversation;
|
|
|
|
if (!query.trim() || !conversation) {
|
|
if (isSearchDebugEnabled()) {
|
|
console.info('[search] clear', { query });
|
|
}
|
|
set({
|
|
searchQuery: query,
|
|
searchResultCount: 0,
|
|
currentSearchIndex: -1,
|
|
searchMatches: [],
|
|
searchResultsCapped: false,
|
|
searchMatchItemIds: new Set(),
|
|
searchCurrentDisplayItemId: null,
|
|
searchCurrentSubagentItemId: null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Build search matches by scanning conversation.
|
|
// Plain indexOf search — no markdown parsing. Match counts may differ
|
|
// slightly from rendered highlights; syncSearchMatchesWithRendered corrects this.
|
|
const matches: SearchMatch[] = [];
|
|
const lowerQuery = query.toLowerCase();
|
|
let globalIndex = 0;
|
|
let capped = false;
|
|
|
|
// Count occurrences of lowerQuery in text using plain indexOf
|
|
const addPlainTextMatches = (
|
|
text: string,
|
|
itemId: string,
|
|
itemType: 'user' | 'ai',
|
|
displayItemId?: string
|
|
): void => {
|
|
if (capped) return;
|
|
const lowerText = text.toLowerCase();
|
|
let pos = 0;
|
|
let matchIndexInItem = 0;
|
|
while ((pos = lowerText.indexOf(lowerQuery, pos)) !== -1) {
|
|
if (matches.length >= MAX_SEARCH_MATCHES) {
|
|
capped = true;
|
|
return;
|
|
}
|
|
matches.push({
|
|
itemId,
|
|
itemType,
|
|
matchIndexInItem,
|
|
globalIndex,
|
|
displayItemId,
|
|
});
|
|
matchIndexInItem++;
|
|
globalIndex++;
|
|
pos += lowerQuery.length;
|
|
}
|
|
};
|
|
|
|
for (const item of conversation.items) {
|
|
if (capped) break;
|
|
if (item.type === 'user') {
|
|
const raw = item.group.content.rawText ?? item.group.content.text ?? '';
|
|
const text = stripAgentBlocks(raw);
|
|
addPlainTextMatches(text, item.group.id, 'user');
|
|
} else if (item.type === 'ai') {
|
|
const aiGroup = item.group;
|
|
const itemId = aiGroup.id;
|
|
const lastOutput = findLastOutput(aiGroup.steps, aiGroup.isOngoing ?? false);
|
|
|
|
if (lastOutput?.type === 'text' && lastOutput.text) {
|
|
addPlainTextMatches(lastOutput.text, itemId, 'ai', 'lastOutput');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isSearchDebugEnabled()) {
|
|
const sample = matches.slice(0, 10).map((match) => ({
|
|
itemId: match.itemId,
|
|
itemType: match.itemType,
|
|
matchIndexInItem: match.matchIndexInItem,
|
|
globalIndex: match.globalIndex,
|
|
}));
|
|
const counts = matches.reduce<Record<string, number>>((acc, match) => {
|
|
acc[`${match.itemType}:${match.itemId}`] =
|
|
(acc[`${match.itemType}:${match.itemId}`] ?? 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
console.info('[search] query', query, 'matches', matches.length);
|
|
console.info('[search] counts', counts);
|
|
console.info('[search] sample', sample);
|
|
}
|
|
|
|
// Build set of item IDs that have matches — components use this to skip re-renders
|
|
const matchItemIds = new Set<string>();
|
|
for (const match of matches) {
|
|
matchItemIds.add(match.itemId);
|
|
}
|
|
|
|
set({
|
|
searchQuery: query,
|
|
searchResultCount: matches.length,
|
|
currentSearchIndex: matches.length > 0 ? 0 : -1,
|
|
searchMatches: matches,
|
|
searchResultsCapped: capped,
|
|
searchMatchItemIds: matchItemIds,
|
|
});
|
|
},
|
|
|
|
syncSearchMatchesWithRendered: (renderedMatches) => {
|
|
const state = get();
|
|
if (!state.searchQuery.trim()) return;
|
|
|
|
const dedupedRendered: { itemId: string; matchIndexInItem: number }[] = [];
|
|
const seen = new Set<string>();
|
|
for (const rendered of renderedMatches) {
|
|
const key = `${rendered.itemId}:${rendered.matchIndexInItem}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
dedupedRendered.push(rendered);
|
|
}
|
|
|
|
const oldMatches = state.searchMatches;
|
|
const sameLength = oldMatches.length === dedupedRendered.length;
|
|
const sameContent =
|
|
sameLength &&
|
|
oldMatches.every(
|
|
(match, index) =>
|
|
match.itemId === dedupedRendered[index]?.itemId &&
|
|
match.matchIndexInItem === dedupedRendered[index]?.matchIndexInItem
|
|
);
|
|
if (sameContent) return;
|
|
|
|
const oldMatchMap = new Map<string, SearchMatch>();
|
|
for (const match of oldMatches) {
|
|
oldMatchMap.set(`${match.itemId}:${match.matchIndexInItem}`, match);
|
|
}
|
|
|
|
const nextMatches: SearchMatch[] = dedupedRendered.map((rendered, index) => {
|
|
const key = `${rendered.itemId}:${rendered.matchIndexInItem}`;
|
|
const previous = oldMatchMap.get(key);
|
|
const inferredItemType = rendered.itemId.startsWith('user-') ? 'user' : 'ai';
|
|
return {
|
|
itemId: rendered.itemId,
|
|
itemType: previous?.itemType ?? inferredItemType,
|
|
matchIndexInItem: rendered.matchIndexInItem,
|
|
globalIndex: index,
|
|
displayItemId: previous?.displayItemId,
|
|
};
|
|
});
|
|
|
|
const oldCurrentMatch =
|
|
state.currentSearchIndex >= 0 ? oldMatches[state.currentSearchIndex] : undefined;
|
|
let newCurrentIndex = -1;
|
|
if (oldCurrentMatch) {
|
|
newCurrentIndex = nextMatches.findIndex(
|
|
(match) =>
|
|
match.itemId === oldCurrentMatch.itemId &&
|
|
match.matchIndexInItem === oldCurrentMatch.matchIndexInItem
|
|
);
|
|
}
|
|
|
|
if (newCurrentIndex < 0) {
|
|
if (nextMatches.length === 0) {
|
|
newCurrentIndex = -1;
|
|
} else if (state.currentSearchIndex < 0) {
|
|
newCurrentIndex = 0;
|
|
} else {
|
|
newCurrentIndex = Math.min(state.currentSearchIndex, nextMatches.length - 1);
|
|
}
|
|
}
|
|
|
|
if (isSearchDebugEnabled()) {
|
|
console.info('[search] sync-rendered', {
|
|
parsedCount: oldMatches.length,
|
|
renderedCount: nextMatches.length,
|
|
currentBefore: state.currentSearchIndex,
|
|
currentAfter: newCurrentIndex,
|
|
});
|
|
}
|
|
|
|
// Rebuild matchItemIds from synced matches
|
|
const syncedMatchItemIds = new Set<string>();
|
|
for (const match of nextMatches) {
|
|
syncedMatchItemIds.add(match.itemId);
|
|
}
|
|
|
|
set({
|
|
searchMatches: nextMatches,
|
|
searchResultCount: nextMatches.length,
|
|
currentSearchIndex: newCurrentIndex,
|
|
searchMatchItemIds: syncedMatchItemIds,
|
|
});
|
|
},
|
|
|
|
showSearch: () => {
|
|
set({ searchVisible: true });
|
|
},
|
|
|
|
selectSearchMatch: (itemId: string, matchIndexInItem: number) => {
|
|
const state = get();
|
|
const targetIndex = state.searchMatches.findIndex(
|
|
(match) => match.itemId === itemId && match.matchIndexInItem === matchIndexInItem
|
|
);
|
|
|
|
if (targetIndex < 0) {
|
|
return false;
|
|
}
|
|
|
|
set({ currentSearchIndex: targetIndex });
|
|
get().expandForCurrentSearchResult();
|
|
return true;
|
|
},
|
|
|
|
hideSearch: () => {
|
|
set({
|
|
searchVisible: false,
|
|
searchQuery: '',
|
|
searchResultCount: 0,
|
|
currentSearchIndex: -1,
|
|
searchMatches: [],
|
|
searchResultsCapped: false,
|
|
searchMatchItemIds: new Set(),
|
|
searchExpandedAIGroupIds: new Set(),
|
|
searchExpandedSubagentIds: new Set(),
|
|
searchCurrentDisplayItemId: null,
|
|
searchCurrentSubagentItemId: null,
|
|
});
|
|
},
|
|
|
|
nextSearchResult: () => {
|
|
const state = get();
|
|
if (state.searchResultCount > 0) {
|
|
const nextIndex = (state.currentSearchIndex + 1) % state.searchResultCount;
|
|
set({ currentSearchIndex: nextIndex });
|
|
// Auto-expand any collapsed sections containing the result
|
|
get().expandForCurrentSearchResult();
|
|
if (isSearchDebugEnabled()) {
|
|
const match = get().searchMatches[nextIndex];
|
|
console.info('[search] next', {
|
|
index: nextIndex,
|
|
itemId: match?.itemId,
|
|
matchIndexInItem: match?.matchIndexInItem,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
|
|
previousSearchResult: () => {
|
|
const state = get();
|
|
if (state.searchResultCount > 0) {
|
|
const prevIndex = state.currentSearchIndex - 1;
|
|
const newIndex = prevIndex < 0 ? state.searchResultCount - 1 : prevIndex;
|
|
set({ currentSearchIndex: newIndex });
|
|
// Auto-expand any collapsed sections containing the result
|
|
get().expandForCurrentSearchResult();
|
|
if (isSearchDebugEnabled()) {
|
|
const match = get().searchMatches[newIndex];
|
|
console.info('[search] prev', {
|
|
index: newIndex,
|
|
itemId: match?.itemId,
|
|
matchIndexInItem: match?.matchIndexInItem,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
|
|
expandForCurrentSearchResult: () => {
|
|
const state = get();
|
|
const { currentSearchIndex, searchMatches } = state;
|
|
|
|
if (currentSearchIndex < 0 || searchMatches.length === 0) return;
|
|
|
|
const currentMatch = searchMatches[currentSearchIndex];
|
|
if (!currentMatch) return;
|
|
|
|
// For AI group matches, track the display item ID for highlighting
|
|
// Since we only search lastOutput text (always visible), no expansion needed
|
|
if (currentMatch.itemType === 'ai') {
|
|
set({
|
|
searchCurrentDisplayItemId: currentMatch.displayItemId ?? null,
|
|
searchCurrentSubagentItemId: null,
|
|
});
|
|
} else {
|
|
// For user matches, clear display item IDs
|
|
set({
|
|
searchCurrentDisplayItemId: null,
|
|
searchCurrentSubagentItemId: null,
|
|
});
|
|
}
|
|
},
|
|
});
|