feat: mobile logo, expandable history, fix session resume

- Show Codeman logo on mobile as compact home button (was hidden)
- Add "Show More" button for history sessions (initial 4, expand all)
- Deduplicate by projectKey instead of workingDir (lossy decode fix)
- Fix project key decoding: handle '_' encoded as '-' with look-ahead
- Pre-validate resumeSessionId before passing to Claude CLI
- Apply content validation to all session files regardless of size
This commit is contained in:
Teigen
2026-04-03 11:02:43 +08:00
parent c642689bbd
commit 1b10d9b733
4 changed files with 232 additions and 76 deletions
+8 -2
View File
@@ -301,9 +301,15 @@ html.mobile-init .file-browser-panel {
Phone Breakpoint (<430px) Phone Breakpoint (<430px)
============================================================================ */ ============================================================================ */
@media (max-width: 430px) { @media (max-width: 430px) {
/* Hide header brand on phones */ /* Compact header brand on phones — acts as home button */
.header-brand { .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 */ /* Font controls - compact on phones, visibility controlled by JS */
+24 -1
View File
@@ -2224,6 +2224,24 @@ body {
text-align: right; 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 { .welcome-hint {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.8rem; font-size: 0.8rem;
@@ -5485,7 +5503,12 @@ kbd {
/* Responsive */ /* Responsive */
@media (max-width: 600px) { @media (max-width: 600px) {
.header-brand { .header-brand {
display: none; padding-right: 0.4rem;
margin-right: 0.3rem;
}
.header-brand .logo {
font-size: 0.75rem;
} }
.connection-text { .connection-text {
+78 -52
View File
@@ -734,84 +734,110 @@ Object.assign(CodemanApp.prototype, {
}, },
/** /**
* Fetch and deduplicate history sessions (up to 2 per dir, max `limit` total). * Fetch and deduplicate history sessions (up to 3 per project, sorted by date).
* @returns {Promise<Array>} deduplicated session list, sorted by lastModified desc * Uses projectKey for grouping because workingDir decoding is lossy.
* @returns {Promise<Array>} deduplicated session list, most recent first
*/ */
async _fetchHistorySessions(limit = 12) { async _fetchHistorySessions() {
const res = await fetch('/api/history/sessions'); const res = await fetch('/api/history/sessions');
const data = await res.json(); const data = await res.json();
const sessions = data.sessions || []; const sessions = data.sessions || [];
if (sessions.length === 0) return []; if (sessions.length === 0) return [];
const byDir = new Map(); const byProject = new Map();
for (const s of sessions) { for (const s of sessions) {
if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []); const key = s.projectKey || s.workingDir;
byDir.get(s.workingDir).push(s); if (!byProject.has(key)) byProject.set(key, []);
byProject.get(key).push(s);
} }
const items = []; const items = [];
for (const [, group] of byDir) { for (const [, group] of byProject) {
items.push(...group.slice(0, 2)); items.push(...group.slice(0, 3));
} }
items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified)); 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() { async loadHistorySessions() {
const container = document.getElementById('historySessions'); const container = document.getElementById('historySessions');
const list = document.getElementById('historyList'); const list = document.getElementById('historyList');
if (!container || !list) return; if (!container || !list) return;
try { try {
const display = await this._fetchHistorySessions(12); const allSessions = await this._fetchHistorySessions(30);
if (display.length === 0) { if (allSessions.length === 0) {
container.style.display = 'none'; container.style.display = 'none';
return; return;
} }
// Build DOM safely (no innerHTML with user data)
list.replaceChildren(); list.replaceChildren();
for (const s of display) { const initialCount = this._HISTORY_INITIAL_COUNT;
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'); // Render initial items
item.className = 'history-item'; for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) {
item.title = s.workingDir; list.appendChild(this._buildHistoryItem(allSessions[i]));
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir)); }
const textCol = document.createElement('div'); // Add "Show More" button if there are more items
textCol.className = 'history-item-text'; if (allSessions.length > initialCount) {
const moreBtn = document.createElement('button');
const titleSpan = document.createElement('span'); moreBtn.className = 'history-show-more';
titleSpan.className = 'history-item-title'; moreBtn.textContent = `Show ${allSessions.length - initialCount} more`;
titleSpan.textContent = s.firstPrompt || shortDir; moreBtn.addEventListener('click', () => {
for (let i = initialCount; i < allSessions.length; i++) {
const subtitleSpan = document.createElement('span'); list.insertBefore(this._buildHistoryItem(allSessions[i]), moreBtn);
subtitleSpan.className = 'history-item-subtitle'; }
subtitleSpan.textContent = shortDir; moreBtn.remove();
});
textCol.append(titleSpan, subtitleSpan); list.appendChild(moreBtn);
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);
} }
container.style.display = ''; container.style.display = '';
+122 -21
View File
@@ -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 globalNice = await ctx.getGlobalNiceConfig();
const modelConfig = await ctx.getModelConfig(); const modelConfig = await ctx.getModelConfig();
const mode = body.mode || 'claude'; const mode = body.mode || 'claude';
@@ -203,7 +233,7 @@ export function registerSessionRoutes(
claudeMode: claudeModeConfig.claudeMode, claudeMode: claudeModeConfig.claudeMode,
allowedTools: claudeModeConfig.allowedTools, allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
resumeSessionId: body.resumeSessionId, resumeSessionId: validatedResumeId,
}); });
ctx.addSession(session); ctx.addSession(session);
@@ -924,6 +954,74 @@ export function registerSessionRoutes(
return undefined; 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<string> {
const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey;
const segments = encoded.split('-');
const isDir = async (p: string): Promise<boolean> =>
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. */ /** Read the first 16KB of a file for content sniffing. */
async function readFileHead(path: string, buf: Buffer): Promise<string | null> { async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
try { try {
@@ -974,15 +1072,11 @@ export function registerSessionRoutes(
const stat = await fs.stat(projPath).catch(() => null); const stat = await fs.stat(projPath).catch(() => null);
if (!stat?.isDirectory()) continue; if (!stat?.isDirectory()) continue;
// Decode project key to working dir. The encoding replaces '/' with '-', // Decode project key to working dir. Claude CLI encodes '/' as '-',
// which is lossy when path components contain '-'. Do naive decode first, // but path components may also contain '-' (e.g. "AI_project" vs "AI-project").
// then verify it exists. Fall back to HOME if the decoded path is invalid. // Use recursive backtracking: try each '-' as either '/' or literal '-',
const naiveDecode = projDir.replace(/^-/, '/').replace(/-/g, '/'); // verify which decoded path actually exists on disk.
const dirExists = await fs const workingDir = await decodeProjectKey(projDir);
.access(naiveDecode)
.then(() => true)
.catch(() => false);
const workingDir = dirExists ? naiveDecode : process.env.HOME || '/tmp';
const entries = await fs.readdir(projPath); const entries = await fs.readdir(projPath);
for (const entry of entries) { for (const entry of entries) {
@@ -1004,24 +1098,31 @@ export function registerSessionRoutes(
// Read first 16KB to check content and extract first user prompt. // Read first 16KB to check content and extract first user prompt.
let firstPrompt: string | undefined; let firstPrompt: string | undefined;
const head = await readFileHead(filePath, headBuf); 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) { let foundContent = head ? hasConversation(head) : false;
if (
!head || // For large files, head may not contain user messages (e.g. /init followed
(!head.includes('"type":"user"') && // by large system entries). Check the tail as well.
!head.includes('"type":"assistant"') && let tail: string | null = null;
!head.includes('"type":"summary"')) if (!foundContent && fileStat.size > 16384) {
) { const tailBuf = Buffer.alloc(32768);
continue; // No conversation content — skip 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) firstPrompt = extractFirstUserPrompt(head);
// If head scan found no usable prompt (e.g. session started with /init), // If head scan found no usable prompt (e.g. session started with /init),
// try reading the tail for a recent user message. // try reading the tail for a recent user message.
if (!firstPrompt && fileStat.size > 65536) { if (!firstPrompt && fileStat.size > 65536) {
const tailBuf = Buffer.alloc(32768); if (!tail) {
const tail = await readFileTail(filePath, tailBuf, fileStat.size); const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
}
if (tail) firstPrompt = extractFirstUserPrompt(tail); if (tail) firstPrompt = extractFirstUserPrompt(tail);
} }