mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
feat: add View all in folder modal for Resume Conversation (#94)
Drill into a single project's complete history when the homepage's
3-per-project dedup hides older conversations.
- Backend: /api/history/sessions accepts projectKey/offset/limit;
single-folder mode bypasses the 50-cap and returns { sessions, total }.
projectKey is validated against ^[A-Za-z0-9_-]+$ to prevent traversal.
- Frontend: detail panel adds "View all in this folder" button that
opens a modal listing 20 sessions per page with Show more pagination.
- Modal items reuse _buildHistoryItem with showViewAll:false to avoid
recursive entry points.
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
This commit is contained in:
@@ -2369,6 +2369,56 @@ body {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-detail-actions {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-view-all-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
background: rgba(99, 179, 237, 0.08);
|
||||||
|
border: 1px solid rgba(99, 179, 237, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(99, 179, 237, 0.95);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-smooth), border-color var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-view-all-btn:hover {
|
||||||
|
background: rgba(99, 179, 237, 0.15);
|
||||||
|
border-color: rgba(99, 179, 237, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Folder history modal */
|
||||||
|
.folder-history-modal .modal-body {
|
||||||
|
padding: 0.75rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-history-subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-history-empty {
|
||||||
|
padding: 2rem 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.welcome-hint {
|
.welcome-hint {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|||||||
@@ -917,8 +917,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
.replace(/^\/Users\/[^/]+\//, '~/');
|
.replace(/^\/Users\/[^/]+\//, '~/');
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Build a single history item DOM element */
|
/**
|
||||||
_buildHistoryItem(s, cases) {
|
* Build a single history item DOM element.
|
||||||
|
* @param {object} s session record
|
||||||
|
* @param {Array} cases linked cases (for #caseName label)
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {boolean} [options.showViewAll=true] show "View all in folder" button in detail panel
|
||||||
|
*/
|
||||||
|
_buildHistoryItem(s, cases, options) {
|
||||||
|
const showViewAll = options?.showViewAll !== false;
|
||||||
const size =
|
const size =
|
||||||
s.sizeBytes < 1024
|
s.sizeBytes < 1024
|
||||||
? `${s.sizeBytes}B`
|
? `${s.sizeBytes}B`
|
||||||
@@ -1000,6 +1007,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
detail.append(promptRow, pathRow, metaRow);
|
detail.append(promptRow, pathRow, metaRow);
|
||||||
|
|
||||||
|
if (showViewAll && s.projectKey) {
|
||||||
|
const actionRow = document.createElement('div');
|
||||||
|
actionRow.className = 'history-detail-row history-detail-actions';
|
||||||
|
const viewAllBtn = document.createElement('button');
|
||||||
|
viewAllBtn.type = 'button';
|
||||||
|
viewAllBtn.className = 'history-view-all-btn';
|
||||||
|
viewAllBtn.textContent = 'View all in this folder';
|
||||||
|
viewAllBtn.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.openFolderHistoryModal(s.projectKey, s.workingDir, cases);
|
||||||
|
});
|
||||||
|
actionRow.appendChild(viewAllBtn);
|
||||||
|
detail.appendChild(actionRow);
|
||||||
|
}
|
||||||
|
|
||||||
expandBtn.addEventListener('click', (ev) => {
|
expandBtn.addEventListener('click', (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const expanded = item.classList.toggle('expanded');
|
const expanded = item.classList.toggle('expanded');
|
||||||
@@ -1063,9 +1085,144 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Page size for the folder history modal */
|
||||||
|
_FOLDER_HISTORY_PAGE_SIZE: 20,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a modal showing all history sessions in a single folder.
|
||||||
|
* Paginated by FOLDER_HISTORY_PAGE_SIZE; "Show more" loads next page.
|
||||||
|
*/
|
||||||
|
openFolderHistoryModal(projectKey, workingDir, cases) {
|
||||||
|
// Close any existing instance first
|
||||||
|
this._closeFolderHistoryModal();
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal active folder-history-modal';
|
||||||
|
modal.id = 'folderHistoryModal';
|
||||||
|
|
||||||
|
const backdrop = document.createElement('div');
|
||||||
|
backdrop.className = 'modal-backdrop';
|
||||||
|
backdrop.addEventListener('click', () => this._closeFolderHistoryModal());
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'modal-content modal-lg';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'modal-header';
|
||||||
|
const title = document.createElement('h3');
|
||||||
|
title.textContent = 'Folder History';
|
||||||
|
const subtitle = document.createElement('div');
|
||||||
|
subtitle.className = 'folder-history-subtitle';
|
||||||
|
subtitle.textContent = this._shortenHomePath(workingDir);
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.className = 'modal-close';
|
||||||
|
closeBtn.setAttribute('aria-label', 'Close');
|
||||||
|
closeBtn.innerHTML = '×';
|
||||||
|
closeBtn.addEventListener('click', () => this._closeFolderHistoryModal());
|
||||||
|
header.append(title, closeBtn);
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'modal-body';
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'folder-history-list';
|
||||||
|
list.setAttribute('data-loading', 'true');
|
||||||
|
list.textContent = 'Loading...';
|
||||||
|
body.append(subtitle, list);
|
||||||
|
|
||||||
|
content.append(header, body);
|
||||||
|
modal.append(backdrop, content);
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Track state for pagination
|
||||||
|
this._folderHistoryState = {
|
||||||
|
projectKey,
|
||||||
|
workingDir,
|
||||||
|
cases: cases || [],
|
||||||
|
offset: 0,
|
||||||
|
total: null,
|
||||||
|
list,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
this._folderHistoryEscHandler = (ev) => {
|
||||||
|
if (ev.key === 'Escape') this._closeFolderHistoryModal();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', this._folderHistoryEscHandler);
|
||||||
|
|
||||||
|
this._loadFolderHistoryPage();
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadFolderHistoryPage() {
|
||||||
|
const state = this._folderHistoryState;
|
||||||
|
if (!state) return;
|
||||||
|
const { projectKey, cases, list } = state;
|
||||||
|
const limit = this._FOLDER_HISTORY_PAGE_SIZE;
|
||||||
|
const offset = state.offset;
|
||||||
|
|
||||||
|
// Remove existing "Show more" button while loading
|
||||||
|
const existingMore = list.querySelector('.folder-history-more');
|
||||||
|
if (existingMore) existingMore.remove();
|
||||||
|
|
||||||
|
// First page: clear loading placeholder
|
||||||
|
if (offset === 0) {
|
||||||
|
list.replaceChildren();
|
||||||
|
list.removeAttribute('data-loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/api/history/sessions?projectKey=${encodeURIComponent(projectKey)}&offset=${offset}&limit=${limit}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
const sessions = data.sessions || [];
|
||||||
|
state.total = typeof data.total === 'number' ? data.total : sessions.length + offset;
|
||||||
|
|
||||||
|
if (offset === 0 && sessions.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'folder-history-empty';
|
||||||
|
empty.textContent = 'No conversations found in this folder.';
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of sessions) {
|
||||||
|
list.appendChild(this._buildHistoryItem(s, cases, { showViewAll: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.offset = offset + sessions.length;
|
||||||
|
|
||||||
|
// Add "Show more" if there are more sessions
|
||||||
|
if (state.offset < state.total) {
|
||||||
|
const remaining = state.total - state.offset;
|
||||||
|
const moreBtn = document.createElement('button');
|
||||||
|
moreBtn.className = 'history-show-more folder-history-more';
|
||||||
|
moreBtn.textContent = `Show ${Math.min(limit, remaining)} more (${remaining} remaining)`;
|
||||||
|
moreBtn.addEventListener('click', () => this._loadFolderHistoryPage());
|
||||||
|
list.appendChild(moreBtn);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[loadFolderHistoryPage]', err);
|
||||||
|
const errorEl = document.createElement('div');
|
||||||
|
errorEl.className = 'folder-history-empty';
|
||||||
|
errorEl.textContent = 'Failed to load folder history.';
|
||||||
|
list.appendChild(errorEl);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_closeFolderHistoryModal() {
|
||||||
|
const modal = document.getElementById('folderHistoryModal');
|
||||||
|
if (modal) modal.remove();
|
||||||
|
if (this._folderHistoryEscHandler) {
|
||||||
|
document.removeEventListener('keydown', this._folderHistoryEscHandler);
|
||||||
|
this._folderHistoryEscHandler = null;
|
||||||
|
}
|
||||||
|
this._folderHistoryState = null;
|
||||||
|
},
|
||||||
|
|
||||||
async resumeHistorySession(sessionId, workingDir) {
|
async resumeHistorySession(sessionId, workingDir) {
|
||||||
// Close the run mode menu if open
|
// Close the run mode menu if open
|
||||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||||
|
// Close folder history modal if open
|
||||||
|
this._closeFolderHistoryModal();
|
||||||
try {
|
try {
|
||||||
this.terminal.clear();
|
this.terminal.clear();
|
||||||
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
|
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
|
||||||
|
|||||||
@@ -1448,96 +1448,104 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/history/sessions', async () => {
|
type HistorySession = {
|
||||||
|
sessionId: string;
|
||||||
|
workingDir: string;
|
||||||
|
projectKey: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
lastModified: string;
|
||||||
|
firstPrompt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scan a single project directory and return all valid history sessions in it.
|
||||||
|
// Reused by both the global overview and the single-folder drill-down.
|
||||||
|
async function scanProjectDir(projPath: string, projDir: string, headBuf: Buffer): Promise<HistorySession[]> {
|
||||||
|
const out: HistorySession[] = [];
|
||||||
|
const stat = await fs.stat(projPath).catch(() => null);
|
||||||
|
if (!stat?.isDirectory()) return out;
|
||||||
|
|
||||||
|
const workingDir = await decodeProjectKey(projDir);
|
||||||
|
const entries = await fs.readdir(projPath).catch(() => [] as string[]);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.endsWith('.jsonl')) continue;
|
||||||
|
const sessionId = entry.replace('.jsonl', '');
|
||||||
|
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
|
||||||
|
|
||||||
|
const filePath = join(projPath, entry);
|
||||||
|
const fileStat = await fs.stat(filePath).catch(() => null);
|
||||||
|
if (!fileStat) continue;
|
||||||
|
if (fileStat.size < 4000) continue;
|
||||||
|
|
||||||
|
let firstPrompt: string | undefined;
|
||||||
|
const head = await readFileHead(filePath, headBuf);
|
||||||
|
const hasConversation = (text: string) =>
|
||||||
|
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
||||||
|
|
||||||
|
let foundContent = head ? hasConversation(head) : false;
|
||||||
|
let tail: string | null = null;
|
||||||
|
if (!foundContent && fileStat.size > 16384) {
|
||||||
|
const tailBuf = Buffer.alloc(32768);
|
||||||
|
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||||
|
if (tail) foundContent = hasConversation(tail);
|
||||||
|
}
|
||||||
|
if (!foundContent) continue;
|
||||||
|
|
||||||
|
if (head) firstPrompt = extractFirstUserPrompt(head);
|
||||||
|
if (!firstPrompt && fileStat.size > 65536) {
|
||||||
|
if (!tail) {
|
||||||
|
const tailBuf = Buffer.alloc(32768);
|
||||||
|
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||||
|
}
|
||||||
|
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
sessionId,
|
||||||
|
workingDir,
|
||||||
|
projectKey: projDir,
|
||||||
|
sizeBytes: fileStat.size,
|
||||||
|
lastModified: fileStat.mtime.toISOString(),
|
||||||
|
firstPrompt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/history/sessions', async (req) => {
|
||||||
|
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
|
||||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||||
const results: Array<{
|
|
||||||
sessionId: string;
|
|
||||||
workingDir: string;
|
|
||||||
projectKey: string;
|
|
||||||
sizeBytes: number;
|
|
||||||
lastModified: string;
|
|
||||||
firstPrompt?: string;
|
|
||||||
}> = [];
|
|
||||||
const headBuf = Buffer.alloc(16384);
|
const headBuf = Buffer.alloc(16384);
|
||||||
|
|
||||||
|
// Single-folder drill-down: when projectKey is provided, scan only that
|
||||||
|
// directory, bypass the 50-cap, and honor offset/limit pagination.
|
||||||
|
if (query.projectKey) {
|
||||||
|
// Validate projectKey format to prevent path traversal
|
||||||
|
if (!/^[A-Za-z0-9_-]+$/.test(query.projectKey)) {
|
||||||
|
return { sessions: [], total: 0 };
|
||||||
|
}
|
||||||
|
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
|
||||||
|
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
|
||||||
|
const projPath = join(projectsDir, query.projectKey);
|
||||||
|
const all = await scanProjectDir(projPath, query.projectKey, headBuf);
|
||||||
|
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||||
|
return { sessions: all.slice(offset, offset + limit), total: all.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global overview: scan all projects, return up to 50 most-recent sessions.
|
||||||
|
const results: HistorySession[] = [];
|
||||||
try {
|
try {
|
||||||
const projectDirs = await fs.readdir(projectsDir);
|
const projectDirs = await fs.readdir(projectsDir);
|
||||||
for (const projDir of projectDirs) {
|
for (const projDir of projectDirs) {
|
||||||
const projPath = join(projectsDir, projDir);
|
const projPath = join(projectsDir, projDir);
|
||||||
const stat = await fs.stat(projPath).catch(() => null);
|
const list = await scanProjectDir(projPath, projDir, headBuf);
|
||||||
if (!stat?.isDirectory()) continue;
|
results.push(...list);
|
||||||
|
|
||||||
// Decode project key to working dir. Claude CLI encodes '/' as '-',
|
|
||||||
// but path components may also contain '-' (e.g. "AI_project" vs "AI-project").
|
|
||||||
// Use recursive backtracking: try each '-' as either '/' or literal '-',
|
|
||||||
// verify which decoded path actually exists on disk.
|
|
||||||
const workingDir = await decodeProjectKey(projDir);
|
|
||||||
|
|
||||||
const entries = await fs.readdir(projPath);
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.endsWith('.jsonl')) continue;
|
|
||||||
const sessionId = entry.replace('.jsonl', '');
|
|
||||||
// Only valid UUIDs
|
|
||||||
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
|
|
||||||
|
|
||||||
const filePath = join(projPath, entry);
|
|
||||||
const fileStat = await fs.stat(filePath).catch(() => null);
|
|
||||||
if (!fileStat) continue;
|
|
||||||
// Skip files too small to contain real conversation (metadata-only sessions
|
|
||||||
// like file-history-snapshot entries are typically < 4KB)
|
|
||||||
if (fileStat.size < 4000) continue;
|
|
||||||
|
|
||||||
// Quick content check: verify actual conversation data exists.
|
|
||||||
// Sessions with only file-history-snapshot or hook_progress entries have
|
|
||||||
// no "user"/"assistant" messages and will fail claude --resume.
|
|
||||||
// Read first 16KB to check content and extract first user prompt.
|
|
||||||
let firstPrompt: string | undefined;
|
|
||||||
const head = await readFileHead(filePath, headBuf);
|
|
||||||
const hasConversation = (text: string) =>
|
|
||||||
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
|
||||||
|
|
||||||
let foundContent = head ? hasConversation(head) : false;
|
|
||||||
|
|
||||||
// For large files, head may not contain user messages (e.g. /init followed
|
|
||||||
// by large system entries). Check the tail as well.
|
|
||||||
let tail: string | null = null;
|
|
||||||
if (!foundContent && fileStat.size > 16384) {
|
|
||||||
const tailBuf = Buffer.alloc(32768);
|
|
||||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
|
||||||
if (tail) foundContent = hasConversation(tail);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!foundContent) continue; // No conversation content — skip
|
|
||||||
|
|
||||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
|
||||||
|
|
||||||
// If head scan found no usable prompt (e.g. session started with /init),
|
|
||||||
// try reading the tail for a recent user message.
|
|
||||||
if (!firstPrompt && fileStat.size > 65536) {
|
|
||||||
if (!tail) {
|
|
||||||
const tailBuf = Buffer.alloc(32768);
|
|
||||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
|
||||||
}
|
|
||||||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
sessionId,
|
|
||||||
workingDir,
|
|
||||||
projectKey: projDir,
|
|
||||||
sizeBytes: fileStat.size,
|
|
||||||
lastModified: fileStat.mtime.toISOString(),
|
|
||||||
firstPrompt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Projects dir may not exist
|
// Projects dir may not exist
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by lastModified descending
|
|
||||||
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||||
|
|
||||||
return { sessions: results.slice(0, 50) };
|
return { sessions: results.slice(0, 50) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user