From 98317acd948d3703c95116e941b1d2d9abaf7f53 Mon Sep 17 00:00:00 2001 From: Teigen Date: Thu, 30 Apr 2026 09:54:36 +0800 Subject: [PATCH] feat: add View all in folder modal for Resume Conversation 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. --- src/web/public/styles.css | 50 ++++++++++ src/web/public/terminal-ui.js | 161 +++++++++++++++++++++++++++++- src/web/routes/session-routes.ts | 162 ++++++++++++++++--------------- 3 files changed, 294 insertions(+), 79 deletions(-) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index ac331541..822a47c5 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2370,6 +2370,56 @@ body { 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 { color: var(--text-muted); font-size: 0.8rem; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index df931cf6..a2f629a1 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -918,8 +918,15 @@ Object.assign(CodemanApp.prototype, { .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 = s.sizeBytes < 1024 ? `${s.sizeBytes}B` @@ -1001,6 +1008,21 @@ Object.assign(CodemanApp.prototype, { 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) => { ev.stopPropagation(); const expanded = item.classList.toggle('expanded'); @@ -1064,9 +1086,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) { // Close the run mode menu if open document.getElementById('runModeMenu')?.classList.remove('active'); + // Close folder history modal if open + this._closeFolderHistoryModal(); try { this.terminal.clear(); this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 607be493..1798a29f 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1416,96 +1416,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 { + 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 results: Array<{ - sessionId: string; - workingDir: string; - projectKey: string; - sizeBytes: number; - lastModified: string; - firstPrompt?: string; - }> = []; 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 { const projectDirs = await fs.readdir(projectsDir); for (const projDir of projectDirs) { const projPath = join(projectsDir, projDir); - const stat = await fs.stat(projPath).catch(() => null); - if (!stat?.isDirectory()) continue; - - // 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, - }); - } + const list = await scanProjectDir(projPath, projDir, headBuf); + results.push(...list); } } catch { // Projects dir may not exist } - // Sort by lastModified descending results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()); - return { sessions: results.slice(0, 50) }; });