mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-07 15:07:38 +00:00
Compare commits
39 Commits
main
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af28e73b24 | ||
|
|
786b00ad87 | ||
|
|
3c10355009 | ||
|
|
4cc34358ab | ||
|
|
783ad0c584 | ||
|
|
6e55bd2d75 | ||
|
|
28e9bf7eb0 | ||
|
|
65efda3278 | ||
|
|
525c9f72ca | ||
|
|
7802eb0cac | ||
|
|
a8b67e8ee7 | ||
|
|
3b231c64f2 | ||
|
|
9d8b759250 | ||
|
|
11d72a3318 | ||
|
|
d118f9694c | ||
|
|
b5f7d2eada | ||
|
|
ff176a9368 | ||
|
|
aad99fd935 | ||
|
|
b3ace59728 | ||
|
|
06d8a4f2d1 | ||
|
|
cd122913ce | ||
|
|
b18e0de2f3 | ||
|
|
521ad8ab41 | ||
|
|
d0ec14fb21 | ||
|
|
f68600bb76 | ||
|
|
6f686b9da8 | ||
|
|
5ed4798f58 | ||
|
|
f4686c0ed6 | ||
|
|
2492eb1052 | ||
|
|
86ab1291ff | ||
|
|
2699f0dc4b | ||
|
|
338eada187 | ||
|
|
f2e195bd08 | ||
|
|
040c300c29 | ||
|
|
6f50c54769 | ||
|
|
b9bac8a5e5 | ||
|
|
6e03964b8b | ||
|
|
5b7995751e | ||
|
|
a78bff874b |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"git": {
|
"git": {
|
||||||
"commitMessage": "chore(release): v${version}",
|
"commitMessage": "Release ${version}",
|
||||||
"tagName": "v${version}",
|
"tagName": "v${version}",
|
||||||
"requireBranch": "main",
|
"requireBranch": "main",
|
||||||
"requireCleanWorkingDir": true
|
"requireCleanWorkingDir": true
|
||||||
|
|||||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -3,30 +3,6 @@
|
|||||||
All notable changes to CloudCLI UI will be documented in this file.
|
All notable changes to CloudCLI UI will be documented in this file.
|
||||||
|
|
||||||
|
|
||||||
## [1.23.2](https://github.com/siteboon/claudecodeui/compare/v1.22.1...v1.23.2) (2026-03-06)
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
* add clickable overlay buttons for CLI prompts in Shell terminal ([#480](https://github.com/siteboon/claudecodeui/issues/480)) ([2444209](https://github.com/siteboon/claudecodeui/commit/2444209723701dda2b881cea2501b239e64e51c1)), closes [#427](https://github.com/siteboon/claudecodeui/issues/427)
|
|
||||||
* add terminal shortcuts panel for mobile ([#411](https://github.com/siteboon/claudecodeui/issues/411)) ([b0a3fdf](https://github.com/siteboon/claudecodeui/commit/b0a3fdf95ffdb961261194d10400267251e42f17))
|
|
||||||
* implement session rename with SQLite storage ([#413](https://github.com/siteboon/claudecodeui/issues/413)) ([198e3da](https://github.com/siteboon/claudecodeui/commit/198e3da89b353780f53a91888384da9118995e81)), closes [#72](https://github.com/siteboon/claudecodeui/issues/72) [#358](https://github.com/siteboon/claudecodeui/issues/358)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **chat:** finalize terminal lifecycle to prevent stuck processing/thinking UI ([#483](https://github.com/siteboon/claudecodeui/issues/483)) ([0590c5c](https://github.com/siteboon/claudecodeui/commit/0590c5c178f4791e2b039d525ecca4d220c3dcae))
|
|
||||||
* **codex-history:** prevent AGENTS.md/internal prompt leakage when reloading Codex sessions ([#488](https://github.com/siteboon/claudecodeui/issues/488)) ([64a96b2](https://github.com/siteboon/claudecodeui/commit/64a96b24f853acb802f700810b302f0f5cf00898))
|
|
||||||
* preserve pending permission requests across WebSocket reconnections ([#462](https://github.com/siteboon/claudecodeui/issues/462)) ([4ee88f0](https://github.com/siteboon/claudecodeui/commit/4ee88f0eb0c648b54b05f006c6796fb7b09b0fae))
|
|
||||||
* prevent React 18 batching from losing messages during session sync ([#461](https://github.com/siteboon/claudecodeui/issues/461)) ([688d734](https://github.com/siteboon/claudecodeui/commit/688d73477a50773e43c85addc96212aa6290aea5))
|
|
||||||
* release it script ([dcea8a3](https://github.com/siteboon/claudecodeui/commit/dcea8a329c7d68437e1e72c8c766cf33c74637e9))
|
|
||||||
|
|
||||||
### Styling
|
|
||||||
|
|
||||||
* improve UI for processing banner ([#477](https://github.com/siteboon/claudecodeui/issues/477)) ([2320e1d](https://github.com/siteboon/claudecodeui/commit/2320e1d74b59c65b5b7fc4fa8b05fd9208f4898c))
|
|
||||||
|
|
||||||
### Maintenance
|
|
||||||
|
|
||||||
* remove logging of received WebSocket messages in production ([#487](https://github.com/siteboon/claudecodeui/issues/487)) ([9193feb](https://github.com/siteboon/claudecodeui/commit/9193feb6dc83041f3c365204648a88468bdc001b))
|
|
||||||
|
|
||||||
## [1.22.0](https://github.com/siteboon/claudecodeui/compare/v1.21.0...v1.22.0) (2026-03-03)
|
## [1.22.0](https://github.com/siteboon/claudecodeui/compare/v1.21.0...v1.22.0) (2026-03-03)
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.23.2",
|
"version": "1.22.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.23.2",
|
"version": "1.22.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.23.2",
|
"version": "1.22.0",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
|
|||||||
@@ -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, searchConversations } from './projects.js';
|
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } 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,51 +608,6 @@ 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,13 +481,9 @@ async function getProjects(progressCallback = null) {
|
|||||||
}
|
}
|
||||||
applyCustomSessionNames(project.codexSessions, 'codex');
|
applyCustomSessionNames(project.codexSessions, 'codex');
|
||||||
|
|
||||||
// Also fetch Gemini sessions for this project (UI + CLI)
|
// Also fetch Gemini sessions for this project
|
||||||
try {
|
try {
|
||||||
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
project.geminiSessions = 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 = [];
|
||||||
@@ -588,12 +584,9 @@ async function getProjects(progressCallback = null) {
|
|||||||
}
|
}
|
||||||
applyCustomSessionNames(project.codexSessions, 'codex');
|
applyCustomSessionNames(project.codexSessions, 'codex');
|
||||||
|
|
||||||
// Try to fetch Gemini sessions for manual projects too (UI + CLI)
|
// Try to fetch Gemini sessions for manual projects too
|
||||||
try {
|
try {
|
||||||
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
project.geminiSessions = 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);
|
||||||
}
|
}
|
||||||
@@ -1869,675 +1862,6 @@ 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,
|
||||||
@@ -2554,8 +1878,5 @@ export {
|
|||||||
clearProjectDirectoryCache,
|
clearProjectDirectoryCache,
|
||||||
getCodexSessions,
|
getCodexSessions,
|
||||||
getCodexSessionMessages,
|
getCodexSessionMessages,
|
||||||
deleteCodexSession,
|
deleteCodexSession
|
||||||
getGeminiCliSessions,
|
|
||||||
getGeminiCliSessionMessages,
|
|
||||||
searchConversations
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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();
|
||||||
|
|
||||||
@@ -13,12 +12,7 @@ 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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let messages = sessionManager.getSessionMessages(sessionId);
|
const 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,8 +82,6 @@ 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);
|
||||||
@@ -303,14 +301,12 @@ export function useChatSessionState({
|
|||||||
const isInitialLoadRef = useRef(true);
|
const isInitialLoadRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchScrollActiveRef.current) {
|
pendingInitialScrollRef.current = true;
|
||||||
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]);
|
||||||
|
|
||||||
@@ -325,11 +321,9 @@ export function useChatSessionState({
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingInitialScrollRef.current = false;
|
pendingInitialScrollRef.current = false;
|
||||||
if (!searchScrollActiveRef.current) {
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
scrollToBottom();
|
||||||
scrollToBottom();
|
}, 200);
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -508,28 +502,13 @@ 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)
|
||||||
@@ -554,110 +533,6 @@ 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);
|
||||||
@@ -713,10 +588,6 @@ export function useChatSessionState({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchScrollActiveRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (autoScrollToBottom) {
|
if (autoScrollToBottom) {
|
||||||
if (!isUserScrolledUp) {
|
if (!isUserScrolledUp) {
|
||||||
setTimeout(() => scrollToBottom(), 50);
|
setTimeout(() => scrollToBottom(), 50);
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ 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,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, 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';
|
||||||
@@ -20,44 +19,6 @@ 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;
|
||||||
@@ -110,13 +71,6 @@ 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;
|
||||||
|
|
||||||
@@ -186,116 +140,6 @@ 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>();
|
||||||
@@ -622,21 +466,6 @@ 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,12 +60,6 @@ function Sidebar({
|
|||||||
editingSession,
|
editingSession,
|
||||||
editingSessionName,
|
editingSessionName,
|
||||||
searchFilter,
|
searchFilter,
|
||||||
searchMode,
|
|
||||||
setSearchMode,
|
|
||||||
conversationResults,
|
|
||||||
isSearching,
|
|
||||||
searchProgress,
|
|
||||||
clearConversationResults,
|
|
||||||
deletingProjects,
|
deletingProjects,
|
||||||
deleteConfirmation,
|
deleteConfirmation,
|
||||||
sessionDeleteConfirmation,
|
sessionDeleteConfirmation,
|
||||||
@@ -226,37 +220,6 @@ 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,5 +1,3 @@
|
|||||||
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';
|
||||||
@@ -7,33 +5,6 @@ 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;
|
||||||
@@ -43,12 +14,6 @@ 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;
|
||||||
@@ -70,12 +35,6 @@ export default function SidebarContent({
|
|||||||
searchFilter,
|
searchFilter,
|
||||||
onSearchFilterChange,
|
onSearchFilterChange,
|
||||||
onClearSearchFilter,
|
onClearSearchFilter,
|
||||||
searchMode,
|
|
||||||
onSearchModeChange,
|
|
||||||
conversationResults,
|
|
||||||
isSearching,
|
|
||||||
searchProgress,
|
|
||||||
onConversationResultClick,
|
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
@@ -88,9 +47,6 @@ 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"
|
||||||
@@ -104,8 +60,6 @@ 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}
|
||||||
@@ -114,103 +68,7 @@ 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">
|
||||||
{showConversationSearch ? (
|
<SidebarProjectList {...projectListProps} />
|
||||||
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,10 +1,7 @@
|
|||||||
import { Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
|
import { FolderPlus, 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;
|
||||||
@@ -14,8 +11,6 @@ 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;
|
||||||
@@ -31,8 +26,6 @@ export default function SidebarHeader({
|
|||||||
searchFilter,
|
searchFilter,
|
||||||
onSearchFilterChange,
|
onSearchFilterChange,
|
||||||
onClearSearchFilter,
|
onClearSearchFilter,
|
||||||
searchMode,
|
|
||||||
onSearchModeChange,
|
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
@@ -108,55 +101,23 @@ export default function SidebarHeader({
|
|||||||
|
|
||||||
{/* Search bar */}
|
{/* Search bar */}
|
||||||
{projectsCount > 0 && !isLoading && (
|
{projectsCount > 0 && !isLoading && (
|
||||||
<div className="mt-2.5 space-y-2">
|
<div className="relative mt-2.5">
|
||||||
{/* Search mode toggle */}
|
<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" />
|
||||||
<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-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={() => onSearchModeChange('projects')}
|
onClick={onClearSearchFilter}
|
||||||
aria-pressed={searchMode === 'projects'}
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-0.5 hover:bg-accent"
|
||||||
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"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Folder className="w-3 h-3" />
|
<X className="h-3 w-3 text-muted-foreground" />
|
||||||
{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>
|
||||||
@@ -201,54 +162,23 @@ export default function SidebarHeader({
|
|||||||
|
|
||||||
{/* Mobile search */}
|
{/* Mobile search */}
|
||||||
{projectsCount > 0 && !isLoading && (
|
{projectsCount > 0 && !isLoading && (
|
||||||
<div className="mt-2.5 space-y-2">
|
<div className="relative mt-2.5">
|
||||||
<div className="flex rounded-lg bg-muted/50 p-0.5">
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/50" />
|
||||||
|
<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={() => onSearchModeChange('projects')}
|
onClick={onClearSearchFilter}
|
||||||
aria-pressed={searchMode === 'projects'}
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 hover:bg-accent"
|
||||||
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"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Folder className="w-3 h-3" />
|
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
{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,8 +46,7 @@
|
|||||||
"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",
|
||||||
@@ -104,18 +103,6 @@
|
|||||||
"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,8 +46,7 @@
|
|||||||
"editSessionName": "手动编辑会话名称",
|
"editSessionName": "手动编辑会话名称",
|
||||||
"deleteSession": "永久删除此会话",
|
"deleteSession": "永久删除此会话",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"cancel": "取消",
|
"cancel": "取消"
|
||||||
"clearSearch": "清除搜索"
|
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"chat": "聊天",
|
"chat": "聊天",
|
||||||
@@ -104,18 +103,6 @@
|
|||||||
"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,23 +904,4 @@
|
|||||||
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,10 +6,4 @@ 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,12 +94,6 @@ 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