diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index dd52bb32..7d00c41f 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -301,9 +301,15 @@ html.mobile-init .file-browser-panel { Phone Breakpoint (<430px) ============================================================================ */ @media (max-width: 430px) { - /* Hide header brand on phones */ + /* Compact header brand on phones — acts as home button */ .header-brand { - display: none; + padding-right: 0.25rem; + margin-right: 0.2rem; + border-right: none; + } + + .header-brand .logo { + font-size: 0.7rem; } /* Font controls - compact on phones, visibility controlled by JS */ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 7b4b9ef2..ce95401e 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2224,6 +2224,24 @@ body { text-align: right; } +.history-show-more { + width: 100%; + padding: 0.5rem; + margin-top: 0.25rem; + background: rgba(255, 255, 255, 0.04); + border: 1px dashed rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: var(--text-muted); + font-size: 0.75rem; + cursor: pointer; + transition: background var(--transition-smooth), color var(--transition-smooth); +} + +.history-show-more:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text); +} + .welcome-hint { color: var(--text-muted); font-size: 0.8rem; @@ -5485,7 +5503,12 @@ kbd { /* Responsive */ @media (max-width: 600px) { .header-brand { - display: none; + padding-right: 0.4rem; + margin-right: 0.3rem; + } + + .header-brand .logo { + font-size: 0.75rem; } .connection-text { diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 20b3f3fc..82d114ef 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -734,84 +734,110 @@ Object.assign(CodemanApp.prototype, { }, /** - * Fetch and deduplicate history sessions (up to 2 per dir, max `limit` total). - * @returns {Promise} deduplicated session list, sorted by lastModified desc + * Fetch and deduplicate history sessions (up to 3 per project, sorted by date). + * Uses projectKey for grouping because workingDir decoding is lossy. + * @returns {Promise} deduplicated session list, most recent first */ - async _fetchHistorySessions(limit = 12) { + async _fetchHistorySessions() { const res = await fetch('/api/history/sessions'); const data = await res.json(); const sessions = data.sessions || []; if (sessions.length === 0) return []; - const byDir = new Map(); + const byProject = new Map(); for (const s of sessions) { - if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []); - byDir.get(s.workingDir).push(s); + const key = s.projectKey || s.workingDir; + if (!byProject.has(key)) byProject.set(key, []); + byProject.get(key).push(s); } const items = []; - for (const [, group] of byDir) { - items.push(...group.slice(0, 2)); + for (const [, group] of byProject) { + items.push(...group.slice(0, 3)); } items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified)); - return items.slice(0, limit); + return items; }, + /** Build a single history item DOM element */ + _buildHistoryItem(s) { + const size = + s.sizeBytes < 1024 + ? `${s.sizeBytes}B` + : s.sizeBytes < 1048576 + ? `${(s.sizeBytes / 1024).toFixed(0)}K` + : `${(s.sizeBytes / 1048576).toFixed(1)}M`; + const date = new Date(s.lastModified); + const timeStr = + date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + + ' ' + + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }); + const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/'); + + const item = document.createElement('div'); + item.className = 'history-item'; + item.title = s.workingDir; + item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir)); + + const textCol = document.createElement('div'); + textCol.className = 'history-item-text'; + + const titleSpan = document.createElement('span'); + titleSpan.className = 'history-item-title'; + titleSpan.textContent = s.firstPrompt || shortDir; + + const subtitleSpan = document.createElement('span'); + subtitleSpan.className = 'history-item-subtitle'; + subtitleSpan.textContent = shortDir; + + textCol.append(titleSpan, subtitleSpan); + + const metaSpan = document.createElement('span'); + metaSpan.className = 'history-item-meta'; + metaSpan.textContent = timeStr; + + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'history-item-size'; + sizeSpan.textContent = size; + + item.append(textCol, metaSpan, sizeSpan); + return item; + }, + + /** Number of history items shown before "Show More" */ + _HISTORY_INITIAL_COUNT: 4, + async loadHistorySessions() { const container = document.getElementById('historySessions'); const list = document.getElementById('historyList'); if (!container || !list) return; try { - const display = await this._fetchHistorySessions(12); - if (display.length === 0) { + const allSessions = await this._fetchHistorySessions(30); + if (allSessions.length === 0) { container.style.display = 'none'; return; } - // Build DOM safely (no innerHTML with user data) list.replaceChildren(); - for (const s of display) { - const size = - s.sizeBytes < 1024 - ? `${s.sizeBytes}B` - : s.sizeBytes < 1048576 - ? `${(s.sizeBytes / 1024).toFixed(0)}K` - : `${(s.sizeBytes / 1048576).toFixed(1)}M`; - const date = new Date(s.lastModified); - const timeStr = - date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + - ' ' + - date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }); - const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/'); - - const item = document.createElement('div'); - item.className = 'history-item'; - item.title = s.workingDir; - item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir)); - - const textCol = document.createElement('div'); - textCol.className = 'history-item-text'; - - const titleSpan = document.createElement('span'); - titleSpan.className = 'history-item-title'; - titleSpan.textContent = s.firstPrompt || shortDir; - - const subtitleSpan = document.createElement('span'); - subtitleSpan.className = 'history-item-subtitle'; - subtitleSpan.textContent = shortDir; - - textCol.append(titleSpan, subtitleSpan); - - const metaSpan = document.createElement('span'); - metaSpan.className = 'history-item-meta'; - metaSpan.textContent = timeStr; - - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'history-item-size'; - sizeSpan.textContent = size; - - item.append(textCol, metaSpan, sizeSpan); - list.appendChild(item); + const initialCount = this._HISTORY_INITIAL_COUNT; + + // Render initial items + for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) { + list.appendChild(this._buildHistoryItem(allSessions[i])); + } + + // Add "Show More" button if there are more items + if (allSessions.length > initialCount) { + const moreBtn = document.createElement('button'); + moreBtn.className = 'history-show-more'; + moreBtn.textContent = `Show ${allSessions.length - initialCount} more`; + moreBtn.addEventListener('click', () => { + for (let i = initialCount; i < allSessions.length; i++) { + list.insertBefore(this._buildHistoryItem(allSessions[i]), moreBtn); + } + moreBtn.remove(); + }); + list.appendChild(moreBtn); } container.style.display = ''; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 6784e19f..0c0a064e 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -182,6 +182,36 @@ export function registerSessionRoutes( } } + // Pre-validate resumeSessionId: check that the conversation file actually exists + // in Claude's projects directory. If not, skip resume to avoid confusing + // "No conversation found" errors from Claude CLI. + let validatedResumeId = body.resumeSessionId; + if (validatedResumeId) { + const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects'); + let found = false; + try { + const projectDirs = await fs.readdir(projectsDir); + for (const projDir of projectDirs) { + const sessionFile = join(projectsDir, projDir, `${validatedResumeId}.jsonl`); + try { + const stat = await fs.stat(sessionFile); + if (stat.size > 4000) { + found = true; + break; + } + } catch { + // File doesn't exist in this project dir + } + } + } catch { + // Projects dir doesn't exist + } + if (!found) { + console.log(`[Session] Resume session ${validatedResumeId} not found on disk, starting fresh`); + validatedResumeId = undefined; + } + } + const globalNice = await ctx.getGlobalNiceConfig(); const modelConfig = await ctx.getModelConfig(); const mode = body.mode || 'claude'; @@ -203,7 +233,7 @@ export function registerSessionRoutes( claudeMode: claudeModeConfig.claudeMode, allowedTools: claudeModeConfig.allowedTools, openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, - resumeSessionId: body.resumeSessionId, + resumeSessionId: validatedResumeId, }); ctx.addSession(session); @@ -924,6 +954,74 @@ export function registerSessionRoutes( return undefined; } + /** + * Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror") + * back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror"). + * + * Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be + * any of: '/' (path separator), '_' (underscore), or '-' (literal dash). + * + * Strategy: look-ahead matching. At each '-', try consuming multiple segments + * joined by '_' or '-' to find an existing child directory, then recurse. + * E.g. for segments [AI, project, Mirror] inside /Workspace: + * try /Workspace/AI (no) -> /Workspace/AI_project (yes!) -> continue with [Mirror] + */ + async function decodeProjectKey(projKey: string): Promise { + const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey; + const segments = encoded.split('-'); + + const isDir = async (p: string): Promise => + fs + .stat(p) + .then((s) => s.isDirectory()) + .catch(() => false); + + let current = ''; + let i = 0; + + while (i < segments.length) { + // Try progressively longer child names by joining segments with '_' or '-' + let matched = false; + // Limit look-ahead to avoid excessive fs checks (max 4 segments per component) + const maxLook = Math.min(i + 4, segments.length); + for (let end = i; end < maxLook; end++) { + // Build candidate child name from segments[i..end] + // Try all separator combinations: for 2+ segments, try '_' first then '-' + const candidates: string[] = []; + if (end === i) { + candidates.push(segments[i]); + } else { + // Build with underscores between joined segments + candidates.push(segments.slice(i, end + 1).join('_')); + // Build with dashes (literal) + candidates.push(segments.slice(i, end + 1).join('-')); + } + + for (const child of candidates) { + const candidate = current + '/' + child; + if (await isDir(candidate)) { + current = candidate; + i = end + 1; + matched = true; + break; + } + } + if (matched) break; + } + if (!matched) { + // No directory match found — append as-is and move on + current = current + '/' + segments[i]; + i++; + } + } + + const finalExists = await fs + .access(current) + .then(() => true) + .catch(() => false); + return finalExists ? current : process.env.HOME || '/tmp'; + } + /** Read the first 16KB of a file for content sniffing. */ async function readFileHead(path: string, buf: Buffer): Promise { try { @@ -974,15 +1072,11 @@ export function registerSessionRoutes( const stat = await fs.stat(projPath).catch(() => null); if (!stat?.isDirectory()) continue; - // Decode project key to working dir. The encoding replaces '/' with '-', - // which is lossy when path components contain '-'. Do naive decode first, - // then verify it exists. Fall back to HOME if the decoded path is invalid. - const naiveDecode = projDir.replace(/^-/, '/').replace(/-/g, '/'); - const dirExists = await fs - .access(naiveDecode) - .then(() => true) - .catch(() => false); - const workingDir = dirExists ? naiveDecode : process.env.HOME || '/tmp'; + // 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) { @@ -1004,24 +1098,31 @@ export function registerSessionRoutes( // 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"'); - if (fileStat.size < 50000) { - if ( - !head || - (!head.includes('"type":"user"') && - !head.includes('"type":"assistant"') && - !head.includes('"type":"summary"')) - ) { - continue; // No conversation content — skip - } + 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) { - const tailBuf = Buffer.alloc(32768); - const tail = await readFileTail(filePath, tailBuf, fileStat.size); + if (!tail) { + const tailBuf = Buffer.alloc(32768); + tail = await readFileTail(filePath, tailBuf, fileStat.size); + } if (tail) firstPrompt = extractFirstUserPrompt(tail); }