mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-09 16:07:49 +00:00
feat: add full-text search across conversations (#482)
* feat: add full-text search across conversations in sidebar Add a search mode toggle (Projects/Conversations) to the sidebar search bar. In Conversations mode, search text content across all JSONL session files with debounced API calls, highlighted snippets, and click-to-navigate results. * fix: address PR review feedback - session summary tracking, search sequence invalidation, fallback navigation, SSE streaming - Track session summaries per-session in a Map instead of file-scoped variable - Increment searchSeqRef when clearing conversation search to invalidate in-flight requests - Add fallback session navigation when session not loaded in sidebar paging - Stream search results via SSE for progressive display with progress indicator * feat(search): add Codex/Gemini search and scroll-to-message navigation - Search now includes Codex sessions (JSONL from ~/.codex/sessions/) and Gemini sessions (in-memory via sessionManager) in addition to Claude - Search results include provider info and display a provider badge - Click handler resolves the correct provider instead of hardcoding claude - Clicking a search result loads all messages and scrolls to the matched message with a highlight flash animation * fix(search): Codex search path matching and scroll reliability - Fix Codex search scanning all sessions for every project by checking session_meta cwd match BEFORE scanning messages (was inflating match count and hitting limit before reaching later projects) - Fix Codex search missing user messages in response_item entries (role=user with input_text content parts) - Fix scroll-to-message being overridden by initial scrollToBottom using searchScrollActiveRef to inhibit competing scroll effects - Fix snippet matching using contiguous substring instead of filtered words (which created non-existent phrases) * feat(search): add Gemini CLI session support for search and history viewing Gemini CLI sessions stored in ~/.gemini/tmp/<project>/chats/*.json are now indexed for conversation search and can be loaded for viewing. Previously only sessions created through the UI (sessionManager) were searchable. * fix(search): full-word matching and longer highlight flash - Search now uses word boundaries (\b) instead of substring matching, so "hi" no longer matches "this" - Highlight flash extended to 4s with thicker outline and subtle background tint for better visibility
This commit is contained in:
@@ -44,7 +44,7 @@ import pty from 'node-pty';
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import mime from 'mime-types';
|
import mime from 'mime-types';
|
||||||
|
|
||||||
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
|
||||||
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
|
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
|
||||||
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
||||||
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
||||||
@@ -608,6 +608,51 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Search conversations content (SSE streaming)
|
||||||
|
app.get('/api/search/conversations', authenticateToken, async (req, res) => {
|
||||||
|
const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
||||||
|
const parsedLimit = Number.parseInt(String(req.query.limit), 10);
|
||||||
|
const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100));
|
||||||
|
|
||||||
|
if (query.length < 2) {
|
||||||
|
return res.status(400).json({ error: 'Query must be at least 2 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
req.on('close', () => { closed = true; abortController.abort(); });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
|
||||||
|
if (closed) return;
|
||||||
|
if (projectResult) {
|
||||||
|
res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`);
|
||||||
|
} else {
|
||||||
|
res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`);
|
||||||
|
}
|
||||||
|
}, abortController.signal);
|
||||||
|
if (!closed) {
|
||||||
|
res.write(`event: done\ndata: {}\n\n`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching conversations:', error);
|
||||||
|
if (!closed) {
|
||||||
|
res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!closed) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const expandWorkspacePath = (inputPath) => {
|
const expandWorkspacePath = (inputPath) => {
|
||||||
if (!inputPath) return inputPath;
|
if (!inputPath) return inputPath;
|
||||||
if (inputPath === '~') {
|
if (inputPath === '~') {
|
||||||
|
|||||||
@@ -481,9 +481,13 @@ async function getProjects(progressCallback = null) {
|
|||||||
}
|
}
|
||||||
applyCustomSessionNames(project.codexSessions, 'codex');
|
applyCustomSessionNames(project.codexSessions, 'codex');
|
||||||
|
|
||||||
// Also fetch Gemini sessions for this project
|
// Also fetch Gemini sessions for this project (UI + CLI)
|
||||||
try {
|
try {
|
||||||
project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
||||||
|
const cliSessions = await getGeminiCliSessions(actualProjectDir);
|
||||||
|
const uiIds = new Set(uiSessions.map(s => s.id));
|
||||||
|
const mergedGemini = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];
|
||||||
|
project.geminiSessions = mergedGemini;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message);
|
console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message);
|
||||||
project.geminiSessions = [];
|
project.geminiSessions = [];
|
||||||
@@ -584,9 +588,12 @@ async function getProjects(progressCallback = null) {
|
|||||||
}
|
}
|
||||||
applyCustomSessionNames(project.codexSessions, 'codex');
|
applyCustomSessionNames(project.codexSessions, 'codex');
|
||||||
|
|
||||||
// Try to fetch Gemini sessions for manual projects too
|
// Try to fetch Gemini sessions for manual projects too (UI + CLI)
|
||||||
try {
|
try {
|
||||||
project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
||||||
|
const cliSessions = await getGeminiCliSessions(actualProjectDir);
|
||||||
|
const uiIds = new Set(uiSessions.map(s => s.id));
|
||||||
|
project.geminiSessions = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message);
|
console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message);
|
||||||
}
|
}
|
||||||
@@ -1862,6 +1869,675 @@ async function deleteCodexSession(sessionId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchConversations(query, limit = 50, onProjectResult = null, signal = null) {
|
||||||
|
const safeQuery = typeof query === 'string' ? query.trim() : '';
|
||||||
|
const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 50, 200));
|
||||||
|
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
||||||
|
const config = await loadProjectConfig();
|
||||||
|
const results = [];
|
||||||
|
let totalMatches = 0;
|
||||||
|
const words = safeQuery.toLowerCase().split(/\s+/).filter(w => w.length > 0);
|
||||||
|
if (words.length === 0) return { results: [], totalMatches: 0, query: safeQuery };
|
||||||
|
|
||||||
|
const isAborted = () => signal?.aborted === true;
|
||||||
|
|
||||||
|
const isSystemMessage = (textContent) => {
|
||||||
|
return typeof textContent === 'string' && (
|
||||||
|
textContent.startsWith('<command-name>') ||
|
||||||
|
textContent.startsWith('<command-message>') ||
|
||||||
|
textContent.startsWith('<command-args>') ||
|
||||||
|
textContent.startsWith('<local-command-stdout>') ||
|
||||||
|
textContent.startsWith('<system-reminder>') ||
|
||||||
|
textContent.startsWith('Caveat:') ||
|
||||||
|
textContent.startsWith('This session is being continued from a previous') ||
|
||||||
|
textContent.startsWith('Invalid API key') ||
|
||||||
|
textContent.includes('{"subtasks":') ||
|
||||||
|
textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') ||
|
||||||
|
textContent === 'Warmup'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractText = (content) => {
|
||||||
|
if (typeof content === 'string') return content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.filter(part => part.type === 'text' && part.text)
|
||||||
|
.map(part => part.text)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const wordPatterns = words.map(w => new RegExp(`(?<!\\p{L})${escapeRegex(w)}(?!\\p{L})`, 'u'));
|
||||||
|
const allWordsMatch = (textLower) => {
|
||||||
|
return wordPatterns.every(p => p.test(textLower));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSnippet = (text, textLower, snippetLen = 150) => {
|
||||||
|
let firstIndex = -1;
|
||||||
|
let firstWordLen = 0;
|
||||||
|
for (const w of words) {
|
||||||
|
const re = new RegExp(`(?<!\\p{L})${escapeRegex(w)}(?!\\p{L})`, 'u');
|
||||||
|
const m = re.exec(textLower);
|
||||||
|
if (m && (firstIndex === -1 || m.index < firstIndex)) {
|
||||||
|
firstIndex = m.index;
|
||||||
|
firstWordLen = w.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstIndex === -1) firstIndex = 0;
|
||||||
|
const halfLen = Math.floor(snippetLen / 2);
|
||||||
|
let start = Math.max(0, firstIndex - halfLen);
|
||||||
|
let end = Math.min(text.length, firstIndex + halfLen + firstWordLen);
|
||||||
|
let snippet = text.slice(start, end).replace(/\n/g, ' ');
|
||||||
|
const prefix = start > 0 ? '...' : '';
|
||||||
|
const suffix = end < text.length ? '...' : '';
|
||||||
|
snippet = prefix + snippet + suffix;
|
||||||
|
const snippetLower = snippet.toLowerCase();
|
||||||
|
const highlights = [];
|
||||||
|
for (const word of words) {
|
||||||
|
const re = new RegExp(`(?<!\\p{L})${escapeRegex(word)}(?!\\p{L})`, 'gu');
|
||||||
|
let match;
|
||||||
|
while ((match = re.exec(snippetLower)) !== null) {
|
||||||
|
highlights.push({ start: match.index, end: match.index + word.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
highlights.sort((a, b) => a.start - b.start);
|
||||||
|
const merged = [];
|
||||||
|
for (const h of highlights) {
|
||||||
|
const last = merged[merged.length - 1];
|
||||||
|
if (last && h.start <= last.end) {
|
||||||
|
last.end = Math.max(last.end, h.end);
|
||||||
|
} else {
|
||||||
|
merged.push({ ...h });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { snippet, highlights: merged };
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(claudeDir);
|
||||||
|
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
|
||||||
|
const projectDirs = entries.filter(e => e.isDirectory());
|
||||||
|
let scannedProjects = 0;
|
||||||
|
const totalProjects = projectDirs.length;
|
||||||
|
|
||||||
|
for (const projectEntry of projectDirs) {
|
||||||
|
if (totalMatches >= safeLimit || isAborted()) break;
|
||||||
|
|
||||||
|
const projectName = projectEntry.name;
|
||||||
|
const projectDir = path.join(claudeDir, projectName);
|
||||||
|
const displayName = config[projectName]?.displayName
|
||||||
|
|| await generateDisplayName(projectName);
|
||||||
|
|
||||||
|
let files;
|
||||||
|
try {
|
||||||
|
files = await fs.readdir(projectDir);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonlFiles = files.filter(
|
||||||
|
file => file.endsWith('.jsonl') && !file.startsWith('agent-')
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectResult = {
|
||||||
|
projectName,
|
||||||
|
projectDisplayName: displayName,
|
||||||
|
sessions: []
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const file of jsonlFiles) {
|
||||||
|
if (totalMatches >= safeLimit || isAborted()) break;
|
||||||
|
|
||||||
|
const filePath = path.join(projectDir, file);
|
||||||
|
const sessionMatches = new Map();
|
||||||
|
const sessionSummaries = new Map();
|
||||||
|
const pendingSummaries = new Map();
|
||||||
|
const sessionLastMessages = new Map();
|
||||||
|
let currentSessionId = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileStream = fsSync.createReadStream(filePath);
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: fileStream,
|
||||||
|
crlfDelay: Infinity
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const line of rl) {
|
||||||
|
if (totalMatches >= safeLimit || isAborted()) break;
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
let entry;
|
||||||
|
try {
|
||||||
|
entry = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.sessionId) {
|
||||||
|
currentSessionId = entry.sessionId;
|
||||||
|
}
|
||||||
|
if (entry.type === 'summary' && entry.summary) {
|
||||||
|
const sid = entry.sessionId || currentSessionId;
|
||||||
|
if (sid) {
|
||||||
|
sessionSummaries.set(sid, entry.summary);
|
||||||
|
} else if (entry.leafUuid) {
|
||||||
|
pendingSummaries.set(entry.leafUuid, entry.summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pending summary via parentUuid
|
||||||
|
if (entry.parentUuid && currentSessionId && !sessionSummaries.has(currentSessionId)) {
|
||||||
|
const pending = pendingSummaries.get(entry.parentUuid);
|
||||||
|
if (pending) sessionSummaries.set(currentSessionId, pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track last user/assistant message for fallback title
|
||||||
|
if (entry.message?.content && currentSessionId && !entry.isApiErrorMessage) {
|
||||||
|
const role = entry.message.role;
|
||||||
|
if (role === 'user' || role === 'assistant') {
|
||||||
|
const text = extractText(entry.message.content);
|
||||||
|
if (text && !isSystemMessage(text)) {
|
||||||
|
if (!sessionLastMessages.has(currentSessionId)) {
|
||||||
|
sessionLastMessages.set(currentSessionId, {});
|
||||||
|
}
|
||||||
|
const msgs = sessionLastMessages.get(currentSessionId);
|
||||||
|
if (role === 'user') msgs.user = text;
|
||||||
|
else msgs.assistant = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.message?.content) continue;
|
||||||
|
if (entry.message.role !== 'user' && entry.message.role !== 'assistant') continue;
|
||||||
|
if (entry.isApiErrorMessage) continue;
|
||||||
|
|
||||||
|
const text = extractText(entry.message.content);
|
||||||
|
if (!text || isSystemMessage(text)) continue;
|
||||||
|
|
||||||
|
const textLower = text.toLowerCase();
|
||||||
|
if (!allWordsMatch(textLower)) continue;
|
||||||
|
|
||||||
|
const sessionId = entry.sessionId || currentSessionId || file.replace('.jsonl', '');
|
||||||
|
if (!sessionMatches.has(sessionId)) {
|
||||||
|
sessionMatches.set(sessionId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = sessionMatches.get(sessionId);
|
||||||
|
if (matches.length < 2) {
|
||||||
|
const { snippet, highlights } = buildSnippet(text, textLower);
|
||||||
|
matches.push({
|
||||||
|
role: entry.message.role,
|
||||||
|
snippet,
|
||||||
|
highlights,
|
||||||
|
timestamp: entry.timestamp || null,
|
||||||
|
provider: 'claude',
|
||||||
|
messageUuid: entry.uuid || null
|
||||||
|
});
|
||||||
|
totalMatches++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sessionId, matches] of sessionMatches) {
|
||||||
|
projectResult.sessions.push({
|
||||||
|
sessionId,
|
||||||
|
provider: 'claude',
|
||||||
|
sessionSummary: sessionSummaries.get(sessionId) || (() => {
|
||||||
|
const msgs = sessionLastMessages.get(sessionId);
|
||||||
|
const lastMsg = msgs?.user || msgs?.assistant;
|
||||||
|
return lastMsg ? (lastMsg.length > 50 ? lastMsg.substring(0, 50) + '...' : lastMsg) : 'New Session';
|
||||||
|
})(),
|
||||||
|
matches
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search Codex sessions for this project
|
||||||
|
try {
|
||||||
|
const actualProjectDir = await extractProjectDirectory(projectName);
|
||||||
|
if (actualProjectDir && !isAborted() && totalMatches < safeLimit) {
|
||||||
|
await searchCodexSessionsForProject(
|
||||||
|
actualProjectDir, projectResult, words, allWordsMatch, extractText, isSystemMessage,
|
||||||
|
buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }, isAborted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip codex search errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search Gemini sessions for this project
|
||||||
|
try {
|
||||||
|
const actualProjectDir = await extractProjectDirectory(projectName);
|
||||||
|
if (actualProjectDir && !isAborted() && totalMatches < safeLimit) {
|
||||||
|
await searchGeminiSessionsForProject(
|
||||||
|
actualProjectDir, projectResult, words, allWordsMatch,
|
||||||
|
buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip gemini search errors
|
||||||
|
}
|
||||||
|
|
||||||
|
scannedProjects++;
|
||||||
|
if (projectResult.sessions.length > 0) {
|
||||||
|
results.push(projectResult);
|
||||||
|
if (onProjectResult) {
|
||||||
|
onProjectResult({ projectResult, totalMatches, scannedProjects, totalProjects });
|
||||||
|
}
|
||||||
|
} else if (onProjectResult && scannedProjects % 10 === 0) {
|
||||||
|
onProjectResult({ projectResult: null, totalMatches, scannedProjects, totalProjects });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// claudeDir doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return { results, totalMatches, query: safeQuery };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchCodexSessionsForProject(
|
||||||
|
projectPath, projectResult, words, allWordsMatch, extractText, isSystemMessage,
|
||||||
|
buildSnippet, limit, getTotalMatches, addMatches, isAborted
|
||||||
|
) {
|
||||||
|
const normalizedProjectPath = normalizeComparablePath(projectPath);
|
||||||
|
if (!normalizedProjectPath) return;
|
||||||
|
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||||
|
try {
|
||||||
|
await fs.access(codexSessionsDir);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);
|
||||||
|
|
||||||
|
for (const filePath of jsonlFiles) {
|
||||||
|
if (getTotalMatches() >= limit || isAborted()) break;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileStream = fsSync.createReadStream(filePath);
|
||||||
|
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
||||||
|
|
||||||
|
// First pass: read session_meta to check project path match
|
||||||
|
let sessionMeta = null;
|
||||||
|
for await (const line of rl) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.type === 'session_meta' && entry.payload) {
|
||||||
|
sessionMeta = entry.payload;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch { continue; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip sessions that don't belong to this project
|
||||||
|
if (!sessionMeta) continue;
|
||||||
|
const sessionProjectPath = normalizeComparablePath(sessionMeta.cwd);
|
||||||
|
if (sessionProjectPath !== normalizedProjectPath) continue;
|
||||||
|
|
||||||
|
// Second pass: re-read file to find matching messages
|
||||||
|
const fileStream2 = fsSync.createReadStream(filePath);
|
||||||
|
const rl2 = readline.createInterface({ input: fileStream2, crlfDelay: Infinity });
|
||||||
|
let lastUserMessage = null;
|
||||||
|
const matches = [];
|
||||||
|
|
||||||
|
for await (const line of rl2) {
|
||||||
|
if (getTotalMatches() >= limit || isAborted()) break;
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
let entry;
|
||||||
|
try { entry = JSON.parse(line); } catch { continue; }
|
||||||
|
|
||||||
|
let text = null;
|
||||||
|
let role = null;
|
||||||
|
|
||||||
|
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message' && entry.payload.message) {
|
||||||
|
text = entry.payload.message;
|
||||||
|
role = 'user';
|
||||||
|
lastUserMessage = text;
|
||||||
|
} else if (entry.type === 'response_item' && entry.payload?.type === 'message') {
|
||||||
|
const contentParts = entry.payload.content || [];
|
||||||
|
if (entry.payload.role === 'user') {
|
||||||
|
text = contentParts
|
||||||
|
.filter(p => p.type === 'input_text' && p.text)
|
||||||
|
.map(p => p.text)
|
||||||
|
.join(' ');
|
||||||
|
role = 'user';
|
||||||
|
if (text) lastUserMessage = text;
|
||||||
|
} else if (entry.payload.role === 'assistant') {
|
||||||
|
text = contentParts
|
||||||
|
.filter(p => p.type === 'output_text' && p.text)
|
||||||
|
.map(p => p.text)
|
||||||
|
.join(' ');
|
||||||
|
role = 'assistant';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text || !role) continue;
|
||||||
|
const textLower = text.toLowerCase();
|
||||||
|
if (!allWordsMatch(textLower)) continue;
|
||||||
|
|
||||||
|
if (matches.length < 2) {
|
||||||
|
const { snippet, highlights } = buildSnippet(text, textLower);
|
||||||
|
matches.push({ role, snippet, highlights, timestamp: entry.timestamp || null, provider: 'codex' });
|
||||||
|
addMatches(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length > 0) {
|
||||||
|
projectResult.sessions.push({
|
||||||
|
sessionId: sessionMeta.id,
|
||||||
|
provider: 'codex',
|
||||||
|
sessionSummary: lastUserMessage
|
||||||
|
? (lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage)
|
||||||
|
: 'Codex Session',
|
||||||
|
matches
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchGeminiSessionsForProject(
|
||||||
|
projectPath, projectResult, words, allWordsMatch,
|
||||||
|
buildSnippet, limit, getTotalMatches, addMatches
|
||||||
|
) {
|
||||||
|
// 1) Search in-memory sessions (created via UI)
|
||||||
|
for (const [sessionId, session] of sessionManager.sessions) {
|
||||||
|
if (getTotalMatches() >= limit) break;
|
||||||
|
if (session.projectPath !== projectPath) continue;
|
||||||
|
|
||||||
|
const matches = [];
|
||||||
|
for (const msg of session.messages) {
|
||||||
|
if (getTotalMatches() >= limit) break;
|
||||||
|
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
|
||||||
|
|
||||||
|
const text = typeof msg.content === 'string' ? msg.content
|
||||||
|
: Array.isArray(msg.content) ? msg.content.filter(p => p.type === 'text').map(p => p.text).join(' ')
|
||||||
|
: '';
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
const textLower = text.toLowerCase();
|
||||||
|
if (!allWordsMatch(textLower)) continue;
|
||||||
|
|
||||||
|
if (matches.length < 2) {
|
||||||
|
const { snippet, highlights } = buildSnippet(text, textLower);
|
||||||
|
matches.push({
|
||||||
|
role: msg.role, snippet, highlights,
|
||||||
|
timestamp: msg.timestamp ? msg.timestamp.toISOString() : null,
|
||||||
|
provider: 'gemini'
|
||||||
|
});
|
||||||
|
addMatches(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length > 0) {
|
||||||
|
const firstUserMsg = session.messages.find(m => m.role === 'user');
|
||||||
|
const summary = firstUserMsg?.content
|
||||||
|
? (typeof firstUserMsg.content === 'string'
|
||||||
|
? (firstUserMsg.content.length > 50 ? firstUserMsg.content.substring(0, 50) + '...' : firstUserMsg.content)
|
||||||
|
: 'Gemini Session')
|
||||||
|
: 'Gemini Session';
|
||||||
|
|
||||||
|
projectResult.sessions.push({
|
||||||
|
sessionId,
|
||||||
|
provider: 'gemini',
|
||||||
|
sessionSummary: summary,
|
||||||
|
matches
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Search Gemini CLI sessions on disk (~/.gemini/tmp/<project>/chats/*.json)
|
||||||
|
const normalizedProjectPath = normalizeComparablePath(projectPath);
|
||||||
|
if (!normalizedProjectPath) return;
|
||||||
|
|
||||||
|
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
|
||||||
|
try {
|
||||||
|
await fs.access(geminiTmpDir);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackedSessionIds = new Set();
|
||||||
|
for (const [sid] of sessionManager.sessions) {
|
||||||
|
trackedSessionIds.add(sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectDirs;
|
||||||
|
try {
|
||||||
|
projectDirs = await fs.readdir(geminiTmpDir);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const projectDir of projectDirs) {
|
||||||
|
if (getTotalMatches() >= limit) break;
|
||||||
|
|
||||||
|
const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root');
|
||||||
|
let projectRoot;
|
||||||
|
try {
|
||||||
|
projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue;
|
||||||
|
|
||||||
|
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
|
||||||
|
let chatFiles;
|
||||||
|
try {
|
||||||
|
chatFiles = await fs.readdir(chatsDir);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const chatFile of chatFiles) {
|
||||||
|
if (getTotalMatches() >= limit) break;
|
||||||
|
if (!chatFile.endsWith('.json')) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = path.join(chatsDir, chatFile);
|
||||||
|
const data = await fs.readFile(filePath, 'utf8');
|
||||||
|
const session = JSON.parse(data);
|
||||||
|
if (!session.messages || !Array.isArray(session.messages)) continue;
|
||||||
|
|
||||||
|
const cliSessionId = session.sessionId || chatFile.replace('.json', '');
|
||||||
|
if (trackedSessionIds.has(cliSessionId)) continue;
|
||||||
|
|
||||||
|
const matches = [];
|
||||||
|
let firstUserText = null;
|
||||||
|
|
||||||
|
for (const msg of session.messages) {
|
||||||
|
if (getTotalMatches() >= limit) break;
|
||||||
|
|
||||||
|
const role = msg.type === 'user' ? 'user'
|
||||||
|
: (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant'
|
||||||
|
: null;
|
||||||
|
if (!role) continue;
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
if (typeof msg.content === 'string') {
|
||||||
|
text = msg.content;
|
||||||
|
} else if (Array.isArray(msg.content)) {
|
||||||
|
text = msg.content
|
||||||
|
.filter(p => p.text)
|
||||||
|
.map(p => p.text)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
if (role === 'user' && !firstUserText) firstUserText = text;
|
||||||
|
|
||||||
|
const textLower = text.toLowerCase();
|
||||||
|
if (!allWordsMatch(textLower)) continue;
|
||||||
|
|
||||||
|
if (matches.length < 2) {
|
||||||
|
const { snippet, highlights } = buildSnippet(text, textLower);
|
||||||
|
matches.push({
|
||||||
|
role, snippet, highlights,
|
||||||
|
timestamp: msg.timestamp || null,
|
||||||
|
provider: 'gemini'
|
||||||
|
});
|
||||||
|
addMatches(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length > 0) {
|
||||||
|
const summary = firstUserText
|
||||||
|
? (firstUserText.length > 50 ? firstUserText.substring(0, 50) + '...' : firstUserText)
|
||||||
|
: 'Gemini CLI Session';
|
||||||
|
|
||||||
|
projectResult.sessions.push({
|
||||||
|
sessionId: cliSessionId,
|
||||||
|
provider: 'gemini',
|
||||||
|
sessionSummary: summary,
|
||||||
|
matches
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGeminiCliSessions(projectPath) {
|
||||||
|
const normalizedProjectPath = normalizeComparablePath(projectPath);
|
||||||
|
if (!normalizedProjectPath) return [];
|
||||||
|
|
||||||
|
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
|
||||||
|
try {
|
||||||
|
await fs.access(geminiTmpDir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = [];
|
||||||
|
let projectDirs;
|
||||||
|
try {
|
||||||
|
projectDirs = await fs.readdir(geminiTmpDir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const projectDir of projectDirs) {
|
||||||
|
const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root');
|
||||||
|
let projectRoot;
|
||||||
|
try {
|
||||||
|
projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue;
|
||||||
|
|
||||||
|
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
|
||||||
|
let chatFiles;
|
||||||
|
try {
|
||||||
|
chatFiles = await fs.readdir(chatsDir);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const chatFile of chatFiles) {
|
||||||
|
if (!chatFile.endsWith('.json')) continue;
|
||||||
|
try {
|
||||||
|
const filePath = path.join(chatsDir, chatFile);
|
||||||
|
const data = await fs.readFile(filePath, 'utf8');
|
||||||
|
const session = JSON.parse(data);
|
||||||
|
if (!session.messages || !Array.isArray(session.messages)) continue;
|
||||||
|
|
||||||
|
const sessionId = session.sessionId || chatFile.replace('.json', '');
|
||||||
|
const firstUserMsg = session.messages.find(m => m.type === 'user');
|
||||||
|
let summary = 'Gemini CLI Session';
|
||||||
|
if (firstUserMsg) {
|
||||||
|
const text = Array.isArray(firstUserMsg.content)
|
||||||
|
? firstUserMsg.content.filter(p => p.text).map(p => p.text).join(' ')
|
||||||
|
: (typeof firstUserMsg.content === 'string' ? firstUserMsg.content : '');
|
||||||
|
if (text) {
|
||||||
|
summary = text.length > 50 ? text.substring(0, 50) + '...' : text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.push({
|
||||||
|
id: sessionId,
|
||||||
|
summary,
|
||||||
|
messageCount: session.messages.length,
|
||||||
|
lastActivity: session.lastUpdated || session.startTime || null,
|
||||||
|
provider: 'gemini'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions.sort((a, b) =>
|
||||||
|
new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGeminiCliSessionMessages(sessionId) {
|
||||||
|
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
|
||||||
|
let projectDirs;
|
||||||
|
try {
|
||||||
|
projectDirs = await fs.readdir(geminiTmpDir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const projectDir of projectDirs) {
|
||||||
|
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
|
||||||
|
let chatFiles;
|
||||||
|
try {
|
||||||
|
chatFiles = await fs.readdir(chatsDir);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const chatFile of chatFiles) {
|
||||||
|
if (!chatFile.endsWith('.json')) continue;
|
||||||
|
try {
|
||||||
|
const filePath = path.join(chatsDir, chatFile);
|
||||||
|
const data = await fs.readFile(filePath, 'utf8');
|
||||||
|
const session = JSON.parse(data);
|
||||||
|
const fileSessionId = session.sessionId || chatFile.replace('.json', '');
|
||||||
|
if (fileSessionId !== sessionId) continue;
|
||||||
|
|
||||||
|
return (session.messages || []).map(msg => {
|
||||||
|
const role = msg.type === 'user' ? 'user'
|
||||||
|
: (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant'
|
||||||
|
: msg.type;
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
if (typeof msg.content === 'string') {
|
||||||
|
content = msg.content;
|
||||||
|
} else if (Array.isArray(msg.content)) {
|
||||||
|
content = msg.content.filter(p => p.text).map(p => p.text).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
message: { role, content },
|
||||||
|
timestamp: msg.timestamp || null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getProjects,
|
getProjects,
|
||||||
getSessions,
|
getSessions,
|
||||||
@@ -1878,5 +2554,8 @@ export {
|
|||||||
clearProjectDirectoryCache,
|
clearProjectDirectoryCache,
|
||||||
getCodexSessions,
|
getCodexSessions,
|
||||||
getCodexSessionMessages,
|
getCodexSessionMessages,
|
||||||
deleteCodexSession
|
deleteCodexSession,
|
||||||
|
getGeminiCliSessions,
|
||||||
|
getGeminiCliSessionMessages,
|
||||||
|
searchConversations
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import sessionManager from '../sessionManager.js';
|
import sessionManager from '../sessionManager.js';
|
||||||
import { sessionNamesDb } from '../database/db.js';
|
import { sessionNamesDb } from '../database/db.js';
|
||||||
|
import { getGeminiCliSessionMessages } from '../projects.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -12,7 +13,12 @@ router.get('/sessions/:sessionId/messages', async (req, res) => {
|
|||||||
return res.status(400).json({ success: false, error: 'Invalid session ID format' });
|
return res.status(400).json({ success: false, error: 'Invalid session ID format' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = sessionManager.getSessionMessages(sessionId);
|
let messages = sessionManager.getSessionMessages(sessionId);
|
||||||
|
|
||||||
|
// Fallback to Gemini CLI sessions on disk
|
||||||
|
if (messages.length === 0) {
|
||||||
|
messages = await getGeminiCliSessionMessages(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export function useChatSessionState({
|
|||||||
const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false);
|
const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false);
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
|
||||||
|
const searchScrollActiveRef = useRef(false);
|
||||||
const isLoadingSessionRef = useRef(false);
|
const isLoadingSessionRef = useRef(false);
|
||||||
const isLoadingMoreRef = useRef(false);
|
const isLoadingMoreRef = useRef(false);
|
||||||
const allMessagesLoadedRef = useRef(false);
|
const allMessagesLoadedRef = useRef(false);
|
||||||
@@ -301,12 +303,14 @@ export function useChatSessionState({
|
|||||||
const isInitialLoadRef = useRef(true);
|
const isInitialLoadRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pendingInitialScrollRef.current = true;
|
if (!searchScrollActiveRef.current) {
|
||||||
|
pendingInitialScrollRef.current = true;
|
||||||
|
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
|
||||||
|
}
|
||||||
topLoadLockRef.current = false;
|
topLoadLockRef.current = false;
|
||||||
pendingScrollRestoreRef.current = null;
|
pendingScrollRestoreRef.current = null;
|
||||||
prevSessionMessagesLengthRef.current = 0;
|
prevSessionMessagesLengthRef.current = 0;
|
||||||
isInitialLoadRef.current = true;
|
isInitialLoadRef.current = true;
|
||||||
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
|
|
||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
}, [selectedProject?.name, selectedSession?.id]);
|
}, [selectedProject?.name, selectedSession?.id]);
|
||||||
|
|
||||||
@@ -321,9 +325,11 @@ export function useChatSessionState({
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingInitialScrollRef.current = false;
|
pendingInitialScrollRef.current = false;
|
||||||
setTimeout(() => {
|
if (!searchScrollActiveRef.current) {
|
||||||
scrollToBottom();
|
setTimeout(() => {
|
||||||
}, 200);
|
scrollToBottom();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -502,13 +508,28 @@ export function useChatSessionState({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Detect search navigation target from selectedSession object reference change
|
||||||
|
// This must be a separate effect because the loading effect depends on selectedSession?.id
|
||||||
|
// which doesn't change when clicking a search result for the already-loaded session
|
||||||
|
useEffect(() => {
|
||||||
|
const session = selectedSession as Record<string, unknown> | null;
|
||||||
|
const targetSnippet = session?.__searchTargetSnippet;
|
||||||
|
const targetTimestamp = session?.__searchTargetTimestamp;
|
||||||
|
if (typeof targetSnippet === 'string' && targetSnippet) {
|
||||||
|
searchScrollActiveRef.current = true;
|
||||||
|
setSearchTarget({
|
||||||
|
snippet: targetSnippet,
|
||||||
|
timestamp: typeof targetTimestamp === 'string' ? targetTimestamp : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSession?.id) {
|
if (selectedSession?.id) {
|
||||||
pendingViewSessionRef.current = null;
|
pendingViewSessionRef.current = null;
|
||||||
}
|
}
|
||||||
}, [pendingViewSessionRef, selectedSession?.id]);
|
}, [pendingViewSessionRef, selectedSession?.id]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only sync sessionMessages to chatMessages when:
|
// Only sync sessionMessages to chatMessages when:
|
||||||
// 1. Not currently loading (to avoid overwriting user's just-sent message)
|
// 1. Not currently loading (to avoid overwriting user's just-sent message)
|
||||||
@@ -533,6 +554,110 @@ export function useChatSessionState({
|
|||||||
}
|
}
|
||||||
}, [chatMessages, selectedProject]);
|
}, [chatMessages, selectedProject]);
|
||||||
|
|
||||||
|
// Scroll to search target message after messages are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return;
|
||||||
|
|
||||||
|
const target = searchTarget;
|
||||||
|
// Clear immediately to prevent re-triggering
|
||||||
|
setSearchTarget(null);
|
||||||
|
|
||||||
|
const scrollToTarget = async () => {
|
||||||
|
// Always load all messages when navigating from search
|
||||||
|
// (hasMoreMessages may not be set yet due to race with loading effect)
|
||||||
|
if (!allMessagesLoadedRef.current && selectedSession && selectedProject) {
|
||||||
|
const sessionProvider = selectedSession.__provider || 'claude';
|
||||||
|
if (sessionProvider !== 'cursor') {
|
||||||
|
try {
|
||||||
|
const response = await (api.sessionMessages as any)(
|
||||||
|
selectedProject.name,
|
||||||
|
selectedSession.id,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
sessionProvider,
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const allMessages = data.messages || data;
|
||||||
|
setSessionMessages(Array.isArray(allMessages) ? allMessages : []);
|
||||||
|
setHasMoreMessages(false);
|
||||||
|
setTotalMessages(Array.isArray(allMessages) ? allMessages.length : 0);
|
||||||
|
messagesOffsetRef.current = Array.isArray(allMessages) ? allMessages.length : 0;
|
||||||
|
setVisibleMessageCount(Infinity);
|
||||||
|
setAllMessagesLoaded(true);
|
||||||
|
allMessagesLoadedRef.current = true;
|
||||||
|
// Wait for messages to render after state update
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through and scroll in current messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVisibleMessageCount(Infinity);
|
||||||
|
|
||||||
|
// Retry finding the element in the DOM until React finishes rendering all messages
|
||||||
|
const findAndScroll = (retriesLeft: number) => {
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let targetElement: Element | null = null;
|
||||||
|
|
||||||
|
// Match by snippet text content (most reliable)
|
||||||
|
if (target.snippet) {
|
||||||
|
const cleanSnippet = target.snippet.replace(/^\.{3}/, '').replace(/\.{3}$/, '').trim();
|
||||||
|
// Use a contiguous substring from the snippet (don't filter words, it breaks matching)
|
||||||
|
const searchPhrase = cleanSnippet.slice(0, 80).toLowerCase().trim();
|
||||||
|
|
||||||
|
if (searchPhrase.length >= 10) {
|
||||||
|
const messageElements = container.querySelectorAll('.chat-message');
|
||||||
|
for (const el of messageElements) {
|
||||||
|
const text = (el.textContent || '').toLowerCase();
|
||||||
|
if (text.includes(searchPhrase)) {
|
||||||
|
targetElement = el;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to timestamp matching
|
||||||
|
if (!targetElement && target.timestamp) {
|
||||||
|
const targetDate = new Date(target.timestamp).getTime();
|
||||||
|
const messageElements = container.querySelectorAll('[data-message-timestamp]');
|
||||||
|
let closestDiff = Infinity;
|
||||||
|
|
||||||
|
for (const el of messageElements) {
|
||||||
|
const ts = el.getAttribute('data-message-timestamp');
|
||||||
|
if (!ts) continue;
|
||||||
|
const diff = Math.abs(new Date(ts).getTime() - targetDate);
|
||||||
|
if (diff < closestDiff) {
|
||||||
|
closestDiff = diff;
|
||||||
|
targetElement = el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetElement) {
|
||||||
|
targetElement.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
targetElement.classList.add('search-highlight-flash');
|
||||||
|
setTimeout(() => targetElement?.classList.remove('search-highlight-flash'), 4000);
|
||||||
|
searchScrollActiveRef.current = false;
|
||||||
|
} else if (retriesLeft > 0) {
|
||||||
|
setTimeout(() => findAndScroll(retriesLeft - 1), 200);
|
||||||
|
} else {
|
||||||
|
searchScrollActiveRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start polling after a short delay to let React begin rendering
|
||||||
|
setTimeout(() => findAndScroll(15), 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollToTarget();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
|
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
|
||||||
setTokenBudget(null);
|
setTokenBudget(null);
|
||||||
@@ -588,6 +713,10 @@ export function useChatSessionState({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searchScrollActiveRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (autoScrollToBottom) {
|
if (autoScrollToBottom) {
|
||||||
if (!isUserScrolledUp) {
|
if (!isUserScrolledUp) {
|
||||||
setTimeout(() => scrollToBottom(), 50);
|
setTimeout(() => scrollToBottom(), 50);
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={messageRef}
|
ref={messageRef}
|
||||||
|
data-message-timestamp={message.timestamp || undefined}
|
||||||
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
|
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
|
||||||
>
|
>
|
||||||
{message.type === 'user' ? (
|
{message.type === 'user' ? (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type React from 'react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { api } from '../../../utils/api';
|
import { api } from '../../../utils/api';
|
||||||
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
|
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
|
||||||
@@ -19,6 +20,44 @@ import {
|
|||||||
sortProjects,
|
sortProjects,
|
||||||
} from '../utils/utils';
|
} from '../utils/utils';
|
||||||
|
|
||||||
|
type SnippetHighlight = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConversationMatch = {
|
||||||
|
role: string;
|
||||||
|
snippet: string;
|
||||||
|
highlights: SnippetHighlight[];
|
||||||
|
timestamp: string | null;
|
||||||
|
provider?: string;
|
||||||
|
messageUuid?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConversationSession = {
|
||||||
|
sessionId: string;
|
||||||
|
sessionSummary: string;
|
||||||
|
provider?: string;
|
||||||
|
matches: ConversationMatch[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConversationProjectResult = {
|
||||||
|
projectName: string;
|
||||||
|
projectDisplayName: string;
|
||||||
|
sessions: ConversationSession[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConversationSearchResults = {
|
||||||
|
results: ConversationProjectResult[];
|
||||||
|
totalMatches: number;
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchProgress = {
|
||||||
|
scannedProjects: number;
|
||||||
|
totalProjects: number;
|
||||||
|
};
|
||||||
|
|
||||||
type UseSidebarControllerArgs = {
|
type UseSidebarControllerArgs = {
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
@@ -71,6 +110,13 @@ export function useSidebarController({
|
|||||||
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState<SessionDeleteConfirmation | null>(null);
|
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState<SessionDeleteConfirmation | null>(null);
|
||||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||||
const [starredProjects, setStarredProjects] = useState<Set<string>>(() => loadStarredProjects());
|
const [starredProjects, setStarredProjects] = useState<Set<string>>(() => loadStarredProjects());
|
||||||
|
const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects');
|
||||||
|
const [conversationResults, setConversationResults] = useState<ConversationSearchResults | null>(null);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [searchProgress, setSearchProgress] = useState<SearchProgress | null>(null);
|
||||||
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const searchSeqRef = useRef(0);
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
const isSidebarCollapsed = !isMobile && !sidebarVisible;
|
const isSidebarCollapsed = !isMobile && !sidebarVisible;
|
||||||
|
|
||||||
@@ -140,6 +186,116 @@ export function useSidebarController({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Debounced conversation search with SSE streaming
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchFilter.trim();
|
||||||
|
if (searchMode !== 'conversations' || query.length < 2) {
|
||||||
|
searchSeqRef.current += 1;
|
||||||
|
setConversationResults(null);
|
||||||
|
setSearchProgress(null);
|
||||||
|
setIsSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
const seq = ++searchSeqRef.current;
|
||||||
|
|
||||||
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (seq !== searchSeqRef.current) return;
|
||||||
|
|
||||||
|
const url = api.searchConversationsUrl(query);
|
||||||
|
const es = new EventSource(url);
|
||||||
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
|
const accumulated: ConversationProjectResult[] = [];
|
||||||
|
let totalMatches = 0;
|
||||||
|
|
||||||
|
es.addEventListener('result', (evt) => {
|
||||||
|
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(evt.data) as {
|
||||||
|
projectResult: ConversationProjectResult;
|
||||||
|
totalMatches: number;
|
||||||
|
scannedProjects: number;
|
||||||
|
totalProjects: number;
|
||||||
|
};
|
||||||
|
accumulated.push(data.projectResult);
|
||||||
|
totalMatches = data.totalMatches;
|
||||||
|
setConversationResults({ results: [...accumulated], totalMatches, query });
|
||||||
|
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed SSE data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('progress', (evt) => {
|
||||||
|
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(evt.data) as { totalMatches: number; scannedProjects: number; totalProjects: number };
|
||||||
|
totalMatches = data.totalMatches;
|
||||||
|
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed SSE data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('done', () => {
|
||||||
|
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||||
|
es.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
setIsSearching(false);
|
||||||
|
setSearchProgress(null);
|
||||||
|
if (accumulated.length === 0) {
|
||||||
|
setConversationResults({ results: [], totalMatches: 0, query });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('error', () => {
|
||||||
|
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||||
|
es.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
setIsSearching(false);
|
||||||
|
setSearchProgress(null);
|
||||||
|
if (accumulated.length === 0) {
|
||||||
|
setConversationResults({ results: [], totalMatches: 0, query });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [searchFilter, searchMode]);
|
||||||
|
|
||||||
|
const handleTouchClick = useCallback(
|
||||||
|
(callback: () => void) =>
|
||||||
|
(event: React.TouchEvent<HTMLElement>) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest('.overflow-y-auto') || target.closest('[data-scroll-container]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const toggleProject = useCallback((projectName: string) => {
|
const toggleProject = useCallback((projectName: string) => {
|
||||||
setExpandedProjects((prev) => {
|
setExpandedProjects((prev) => {
|
||||||
const next = new Set<string>();
|
const next = new Set<string>();
|
||||||
@@ -466,6 +622,21 @@ export function useSidebarController({
|
|||||||
setEditingName,
|
setEditingName,
|
||||||
setEditingSession,
|
setEditingSession,
|
||||||
setEditingSessionName,
|
setEditingSessionName,
|
||||||
|
searchMode,
|
||||||
|
setSearchMode,
|
||||||
|
conversationResults,
|
||||||
|
isSearching,
|
||||||
|
searchProgress,
|
||||||
|
clearConversationResults: useCallback(() => {
|
||||||
|
searchSeqRef.current += 1;
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
setIsSearching(false);
|
||||||
|
setSearchProgress(null);
|
||||||
|
setConversationResults(null);
|
||||||
|
}, []),
|
||||||
setSearchFilter,
|
setSearchFilter,
|
||||||
setDeleteConfirmation,
|
setDeleteConfirmation,
|
||||||
setSessionDeleteConfirmation,
|
setSessionDeleteConfirmation,
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ function Sidebar({
|
|||||||
editingSession,
|
editingSession,
|
||||||
editingSessionName,
|
editingSessionName,
|
||||||
searchFilter,
|
searchFilter,
|
||||||
|
searchMode,
|
||||||
|
setSearchMode,
|
||||||
|
conversationResults,
|
||||||
|
isSearching,
|
||||||
|
searchProgress,
|
||||||
|
clearConversationResults,
|
||||||
deletingProjects,
|
deletingProjects,
|
||||||
deleteConfirmation,
|
deleteConfirmation,
|
||||||
sessionDeleteConfirmation,
|
sessionDeleteConfirmation,
|
||||||
@@ -220,6 +226,37 @@ function Sidebar({
|
|||||||
searchFilter={searchFilter}
|
searchFilter={searchFilter}
|
||||||
onSearchFilterChange={setSearchFilter}
|
onSearchFilterChange={setSearchFilter}
|
||||||
onClearSearchFilter={() => setSearchFilter('')}
|
onClearSearchFilter={() => setSearchFilter('')}
|
||||||
|
searchMode={searchMode}
|
||||||
|
onSearchModeChange={(mode: 'projects' | 'conversations') => {
|
||||||
|
setSearchMode(mode);
|
||||||
|
if (mode === 'projects') clearConversationResults();
|
||||||
|
}}
|
||||||
|
conversationResults={conversationResults}
|
||||||
|
isSearching={isSearching}
|
||||||
|
searchProgress={searchProgress}
|
||||||
|
onConversationResultClick={(projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => {
|
||||||
|
const resolvedProvider = (provider || 'claude') as SessionProvider;
|
||||||
|
const project = projects.find(p => p.name === projectName);
|
||||||
|
const searchTarget = { __searchTargetTimestamp: messageTimestamp || null, __searchTargetSnippet: messageSnippet || null };
|
||||||
|
const sessionObj = {
|
||||||
|
id: sessionId,
|
||||||
|
__provider: resolvedProvider,
|
||||||
|
__projectName: projectName,
|
||||||
|
...searchTarget,
|
||||||
|
};
|
||||||
|
if (project) {
|
||||||
|
handleProjectSelect(project);
|
||||||
|
const sessions = getProjectSessions(project);
|
||||||
|
const existing = sessions.find(s => s.id === sessionId);
|
||||||
|
if (existing) {
|
||||||
|
handleSessionClick({ ...existing, ...searchTarget }, projectName);
|
||||||
|
} else {
|
||||||
|
handleSessionClick(sessionObj, projectName);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleSessionClick(sessionObj, projectName);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onRefresh={() => {
|
onRefresh={() => {
|
||||||
void refreshProjects();
|
void refreshProjects();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { Folder, MessageSquare, Search } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { ScrollArea } from '../../../../shared/view/ui';
|
import { ScrollArea } from '../../../../shared/view/ui';
|
||||||
import type { Project } from '../../../../types/app';
|
import type { Project } from '../../../../types/app';
|
||||||
@@ -5,6 +7,33 @@ import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
|||||||
import SidebarFooter from './SidebarFooter';
|
import SidebarFooter from './SidebarFooter';
|
||||||
import SidebarHeader from './SidebarHeader';
|
import SidebarHeader from './SidebarHeader';
|
||||||
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
||||||
|
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
|
||||||
|
|
||||||
|
type SearchMode = 'projects' | 'conversations';
|
||||||
|
|
||||||
|
function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) {
|
||||||
|
const parts: ReactNode[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const h of highlights) {
|
||||||
|
if (h.start > cursor) {
|
||||||
|
parts.push(snippet.slice(cursor, h.start));
|
||||||
|
}
|
||||||
|
parts.push(
|
||||||
|
<mark key={h.start} className="bg-yellow-200 dark:bg-yellow-800 text-foreground rounded-sm px-0.5">
|
||||||
|
{snippet.slice(h.start, h.end)}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
cursor = h.end;
|
||||||
|
}
|
||||||
|
if (cursor < snippet.length) {
|
||||||
|
parts.push(snippet.slice(cursor));
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{parts}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type SidebarContentProps = {
|
type SidebarContentProps = {
|
||||||
isPWA: boolean;
|
isPWA: boolean;
|
||||||
@@ -14,6 +43,12 @@ type SidebarContentProps = {
|
|||||||
searchFilter: string;
|
searchFilter: string;
|
||||||
onSearchFilterChange: (value: string) => void;
|
onSearchFilterChange: (value: string) => void;
|
||||||
onClearSearchFilter: () => void;
|
onClearSearchFilter: () => void;
|
||||||
|
searchMode: SearchMode;
|
||||||
|
onSearchModeChange: (mode: SearchMode) => void;
|
||||||
|
conversationResults: ConversationSearchResults | null;
|
||||||
|
isSearching: boolean;
|
||||||
|
searchProgress: SearchProgress | null;
|
||||||
|
onConversationResultClick: (projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
onCreateProject: () => void;
|
onCreateProject: () => void;
|
||||||
@@ -35,6 +70,12 @@ export default function SidebarContent({
|
|||||||
searchFilter,
|
searchFilter,
|
||||||
onSearchFilterChange,
|
onSearchFilterChange,
|
||||||
onClearSearchFilter,
|
onClearSearchFilter,
|
||||||
|
searchMode,
|
||||||
|
onSearchModeChange,
|
||||||
|
conversationResults,
|
||||||
|
isSearching,
|
||||||
|
searchProgress,
|
||||||
|
onConversationResultClick,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
@@ -47,6 +88,9 @@ export default function SidebarContent({
|
|||||||
projectListProps,
|
projectListProps,
|
||||||
t,
|
t,
|
||||||
}: SidebarContentProps) {
|
}: SidebarContentProps) {
|
||||||
|
const showConversationSearch = searchMode === 'conversations' && searchFilter.trim().length >= 2;
|
||||||
|
const hasPartialResults = conversationResults && conversationResults.results.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-full flex-col bg-background/80 backdrop-blur-sm md:w-72 md:select-none"
|
className="flex h-full flex-col bg-background/80 backdrop-blur-sm md:w-72 md:select-none"
|
||||||
@@ -60,6 +104,8 @@ export default function SidebarContent({
|
|||||||
searchFilter={searchFilter}
|
searchFilter={searchFilter}
|
||||||
onSearchFilterChange={onSearchFilterChange}
|
onSearchFilterChange={onSearchFilterChange}
|
||||||
onClearSearchFilter={onClearSearchFilter}
|
onClearSearchFilter={onClearSearchFilter}
|
||||||
|
searchMode={searchMode}
|
||||||
|
onSearchModeChange={onSearchModeChange}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
onCreateProject={onCreateProject}
|
onCreateProject={onCreateProject}
|
||||||
@@ -68,7 +114,103 @@ export default function SidebarContent({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 overflow-y-auto overscroll-contain md:px-1.5 md:py-2">
|
<ScrollArea className="flex-1 overflow-y-auto overscroll-contain md:px-1.5 md:py-2">
|
||||||
<SidebarProjectList {...projectListProps} />
|
{showConversationSearch ? (
|
||||||
|
isSearching && !hasPartialResults ? (
|
||||||
|
<div className="text-center py-12 md:py-8 px-4">
|
||||||
|
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
|
||||||
|
<div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('search.searching')}</p>
|
||||||
|
{searchProgress && (
|
||||||
|
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||||
|
{t('search.projectsScanned', { count: searchProgress.scannedProjects })}/{searchProgress.totalProjects}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : !isSearching && conversationResults && conversationResults.results.length === 0 ? (
|
||||||
|
<div className="text-center py-12 md:py-8 px-4">
|
||||||
|
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
|
||||||
|
<Search className="w-6 h-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('search.noResults')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('search.tryDifferentQuery')}</p>
|
||||||
|
</div>
|
||||||
|
) : hasPartialResults ? (
|
||||||
|
<div className="space-y-3 px-2">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('search.matches', { count: conversationResults.totalMatches })}
|
||||||
|
</p>
|
||||||
|
{isSearching && searchProgress && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-3 h-3 animate-spin rounded-full border-[1.5px] border-muted-foreground/40 border-t-primary" />
|
||||||
|
<p className="text-[10px] text-muted-foreground/60">
|
||||||
|
{searchProgress.scannedProjects}/{searchProgress.totalProjects}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isSearching && searchProgress && (
|
||||||
|
<div className="mx-1 h-0.5 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary/60 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${Math.round((searchProgress.scannedProjects / searchProgress.totalProjects) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{conversationResults.results.map((projectResult) => (
|
||||||
|
<div key={projectResult.projectName} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||||
|
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="text-xs font-medium text-foreground truncate">
|
||||||
|
{projectResult.projectDisplayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{projectResult.sessions.map((session) => (
|
||||||
|
<button
|
||||||
|
key={`${projectResult.projectName}-${session.sessionId}`}
|
||||||
|
className="w-full text-left rounded-md px-2 py-2 hover:bg-accent/50 transition-colors"
|
||||||
|
onClick={() => onConversationResultClick(
|
||||||
|
projectResult.projectName,
|
||||||
|
session.sessionId,
|
||||||
|
session.provider || session.matches[0]?.provider || 'claude',
|
||||||
|
session.matches[0]?.timestamp,
|
||||||
|
session.matches[0]?.snippet
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<MessageSquare className="w-3 h-3 text-primary flex-shrink-0" />
|
||||||
|
<span className="text-xs font-medium text-foreground truncate">
|
||||||
|
{session.sessionSummary}
|
||||||
|
</span>
|
||||||
|
{session.provider && session.provider !== 'claude' && (
|
||||||
|
<span className="text-[9px] px-1 py-0.5 rounded bg-muted text-muted-foreground uppercase flex-shrink-0">
|
||||||
|
{session.provider}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 pl-4">
|
||||||
|
{session.matches.map((match, idx) => (
|
||||||
|
<div key={idx} className="flex items-start gap-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground/60 font-medium uppercase flex-shrink-0 mt-0.5">
|
||||||
|
{match.role === 'user' ? 'U' : 'A'}
|
||||||
|
</span>
|
||||||
|
<HighlightedSnippet
|
||||||
|
snippet={match.snippet}
|
||||||
|
highlights={match.highlights}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
<SidebarProjectList {...projectListProps} />
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { FolderPlus, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
|
import { Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { Button, Input } from '../../../../shared/view/ui';
|
import { Button, Input } from '../../../../shared/view/ui';
|
||||||
import { IS_PLATFORM } from '../../../../constants/config';
|
import { IS_PLATFORM } from '../../../../constants/config';
|
||||||
|
import { cn } from '../../../../lib/utils';
|
||||||
|
|
||||||
|
type SearchMode = 'projects' | 'conversations';
|
||||||
|
|
||||||
type SidebarHeaderProps = {
|
type SidebarHeaderProps = {
|
||||||
isPWA: boolean;
|
isPWA: boolean;
|
||||||
@@ -11,6 +14,8 @@ type SidebarHeaderProps = {
|
|||||||
searchFilter: string;
|
searchFilter: string;
|
||||||
onSearchFilterChange: (value: string) => void;
|
onSearchFilterChange: (value: string) => void;
|
||||||
onClearSearchFilter: () => void;
|
onClearSearchFilter: () => void;
|
||||||
|
searchMode: SearchMode;
|
||||||
|
onSearchModeChange: (mode: SearchMode) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
onCreateProject: () => void;
|
onCreateProject: () => void;
|
||||||
@@ -26,6 +31,8 @@ export default function SidebarHeader({
|
|||||||
searchFilter,
|
searchFilter,
|
||||||
onSearchFilterChange,
|
onSearchFilterChange,
|
||||||
onClearSearchFilter,
|
onClearSearchFilter,
|
||||||
|
searchMode,
|
||||||
|
onSearchModeChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
@@ -101,23 +108,55 @@ export default function SidebarHeader({
|
|||||||
|
|
||||||
{/* Search bar */}
|
{/* Search bar */}
|
||||||
{projectsCount > 0 && !isLoading && (
|
{projectsCount > 0 && !isLoading && (
|
||||||
<div className="relative mt-2.5">
|
<div className="mt-2.5 space-y-2">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
|
{/* Search mode toggle */}
|
||||||
<Input
|
<div className="flex rounded-lg bg-muted/50 p-0.5">
|
||||||
type="text"
|
|
||||||
placeholder={t('projects.searchPlaceholder')}
|
|
||||||
value={searchFilter}
|
|
||||||
onChange={(event) => onSearchFilterChange(event.target.value)}
|
|
||||||
className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-8 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
||||||
/>
|
|
||||||
{searchFilter && (
|
|
||||||
<button
|
<button
|
||||||
onClick={onClearSearchFilter}
|
onClick={() => onSearchModeChange('projects')}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-0.5 hover:bg-accent"
|
aria-pressed={searchMode === 'projects'}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||||
|
searchMode === 'projects'
|
||||||
|
? "bg-background shadow-sm text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3 text-muted-foreground" />
|
<Folder className="w-3 h-3" />
|
||||||
|
{t('search.modeProjects')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button
|
||||||
|
onClick={() => onSearchModeChange('conversations')}
|
||||||
|
aria-pressed={searchMode === 'conversations'}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||||
|
searchMode === 'conversations'
|
||||||
|
? "bg-background shadow-sm text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
{t('search.modeConversations')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/50 pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
|
||||||
|
value={searchFilter}
|
||||||
|
onChange={(event) => onSearchFilterChange(event.target.value)}
|
||||||
|
className="nav-search-input pl-9 pr-8 h-9 text-sm rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
{searchFilter && (
|
||||||
|
<button
|
||||||
|
onClick={onClearSearchFilter}
|
||||||
|
aria-label={t('tooltips.clearSearch')}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 hover:bg-accent rounded-md"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -162,23 +201,54 @@ export default function SidebarHeader({
|
|||||||
|
|
||||||
{/* Mobile search */}
|
{/* Mobile search */}
|
||||||
{projectsCount > 0 && !isLoading && (
|
{projectsCount > 0 && !isLoading && (
|
||||||
<div className="relative mt-2.5">
|
<div className="mt-2.5 space-y-2">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/50" />
|
<div className="flex rounded-lg bg-muted/50 p-0.5">
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder={t('projects.searchPlaceholder')}
|
|
||||||
value={searchFilter}
|
|
||||||
onChange={(event) => onSearchFilterChange(event.target.value)}
|
|
||||||
className="nav-search-input h-10 rounded-xl border-0 pl-10 pr-9 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
||||||
/>
|
|
||||||
{searchFilter && (
|
|
||||||
<button
|
<button
|
||||||
onClick={onClearSearchFilter}
|
onClick={() => onSearchModeChange('projects')}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 hover:bg-accent"
|
aria-pressed={searchMode === 'projects'}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||||
|
searchMode === 'projects'
|
||||||
|
? "bg-background shadow-sm text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
<Folder className="w-3 h-3" />
|
||||||
|
{t('search.modeProjects')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button
|
||||||
|
onClick={() => onSearchModeChange('conversations')}
|
||||||
|
aria-pressed={searchMode === 'conversations'}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||||
|
searchMode === 'conversations'
|
||||||
|
? "bg-background shadow-sm text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
{t('search.modeConversations')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/50 pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
|
||||||
|
value={searchFilter}
|
||||||
|
onChange={(event) => onSearchFilterChange(event.target.value)}
|
||||||
|
className="nav-search-input pl-10 pr-9 h-10 text-sm rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
{searchFilter && (
|
||||||
|
<button
|
||||||
|
onClick={onClearSearchFilter}
|
||||||
|
aria-label={t('tooltips.clearSearch')}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 hover:bg-accent rounded-md"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,8 @@
|
|||||||
"editSessionName": "Manually edit session name",
|
"editSessionName": "Manually edit session name",
|
||||||
"deleteSession": "Delete this session permanently",
|
"deleteSession": "Delete this session permanently",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel",
|
||||||
|
"clearSearch": "Clear search"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"chat": "Chat",
|
"chat": "Chat",
|
||||||
@@ -103,6 +104,18 @@
|
|||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "Update available"
|
"updateAvailable": "Update available"
|
||||||
},
|
},
|
||||||
|
"search": {
|
||||||
|
"modeProjects": "Projects",
|
||||||
|
"modeConversations": "Conversations",
|
||||||
|
"conversationsPlaceholder": "Search in conversations...",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"noResults": "No results found",
|
||||||
|
"tryDifferentQuery": "Try a different search query",
|
||||||
|
"matches_one": "{{count}} match",
|
||||||
|
"matches_other": "{{count}} matches",
|
||||||
|
"projectsScanned_one": "{{count}} project scanned",
|
||||||
|
"projectsScanned_other": "{{count}} projects scanned"
|
||||||
|
},
|
||||||
"deleteConfirmation": {
|
"deleteConfirmation": {
|
||||||
"deleteProject": "Delete Project",
|
"deleteProject": "Delete Project",
|
||||||
"deleteSession": "Delete Session",
|
"deleteSession": "Delete Session",
|
||||||
|
|||||||
@@ -46,7 +46,8 @@
|
|||||||
"editSessionName": "手动编辑会话名称",
|
"editSessionName": "手动编辑会话名称",
|
||||||
"deleteSession": "永久删除此会话",
|
"deleteSession": "永久删除此会话",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"cancel": "取消"
|
"cancel": "取消",
|
||||||
|
"clearSearch": "清除搜索"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"chat": "聊天",
|
"chat": "聊天",
|
||||||
@@ -103,6 +104,18 @@
|
|||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "有可用更新"
|
"updateAvailable": "有可用更新"
|
||||||
},
|
},
|
||||||
|
"search": {
|
||||||
|
"modeProjects": "项目",
|
||||||
|
"modeConversations": "对话",
|
||||||
|
"conversationsPlaceholder": "搜索对话内容...",
|
||||||
|
"searching": "搜索中...",
|
||||||
|
"noResults": "未找到结果",
|
||||||
|
"tryDifferentQuery": "尝试不同的搜索词",
|
||||||
|
"matches_one": "{{count}} 个匹配",
|
||||||
|
"matches_other": "{{count}} 个匹配",
|
||||||
|
"projectsScanned_one": "{{count}} 个项目已扫描",
|
||||||
|
"projectsScanned_other": "{{count}} 个项目已扫描"
|
||||||
|
},
|
||||||
"deleteConfirmation": {
|
"deleteConfirmation": {
|
||||||
"deleteProject": "删除项目",
|
"deleteProject": "删除项目",
|
||||||
"deleteSession": "删除会话",
|
"deleteSession": "删除会话",
|
||||||
|
|||||||
@@ -904,4 +904,23 @@
|
|||||||
summary svg[class*="transition-transform"] {
|
summary svg[class*="transition-transform"] {
|
||||||
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Search result highlight flash */
|
||||||
|
.search-highlight-flash {
|
||||||
|
animation: search-flash 4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes search-flash {
|
||||||
|
0%, 50% {
|
||||||
|
outline: 3px solid hsl(var(--primary));
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: hsl(var(--primary) / 0.06);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
outline: 3px solid transparent;
|
||||||
|
outline-offset: 4px;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/types/global.d.ts
vendored
6
src/types/global.d.ts
vendored
@@ -6,4 +6,10 @@ declare global {
|
|||||||
refreshProjects?: () => void | Promise<void>;
|
refreshProjects?: () => void | Promise<void>;
|
||||||
openSettings?: (tab?: string) => void;
|
openSettings?: (tab?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EventSourceEventMap {
|
||||||
|
result: MessageEvent;
|
||||||
|
progress: MessageEvent;
|
||||||
|
done: MessageEvent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ export const api = {
|
|||||||
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
|
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
|
searchConversationsUrl: (query, limit = 50) => {
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
const params = new URLSearchParams({ q: query, limit: String(limit) });
|
||||||
|
if (token) params.set('token', token);
|
||||||
|
return `/api/search/conversations?${params.toString()}`;
|
||||||
|
},
|
||||||
createProject: (path) =>
|
createProject: (path) =>
|
||||||
authenticatedFetch('/api/projects/create', {
|
authenticatedFetch('/api/projects/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user