Merge pull request #62 from TeigenZhang/feat/mobile-response-viewer

feat: mobile response viewer with markdown rendering
This commit is contained in:
Ark0N
2026-04-11 06:53:47 +02:00
committed by GitHub
7 changed files with 722 additions and 76 deletions
+118
View File
@@ -869,6 +869,124 @@ class CodemanApp {
}
}
// ═══════════════════════════════════════════════════════════════
// Response Viewer — native-scroll panel for reading full Claude responses
// ═══════════════════════════════════════════════════════════════
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
_renderMarkdown(text) {
if (typeof marked !== 'undefined' && marked.parse) {
try {
return marked.parse(text, { breaks: true, gfm: true });
} catch { /* fall through */ }
}
// Fallback: escape HTML and preserve whitespace
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
}
async toggleResponseViewer() {
const viewer = document.getElementById('responseViewer');
const backdrop = document.getElementById('responseViewerBackdrop');
if (!viewer) return;
const isOpen = viewer.classList.contains('visible');
if (isOpen) {
viewer.classList.remove('visible');
backdrop.classList.remove('visible');
return;
}
if (!this.activeSessionId) return;
try {
// Source 1: Transcript JSONL (best quality — clean structured text from Claude)
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`);
const data = await res.json();
let lastResponse = data.text || '';
// Source 2: Terminal buffer fallback (strip ANSI codes)
if (!lastResponse) {
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
const termData = await termRes.json();
if (termData.terminalBuffer) {
lastResponse = termData.terminalBuffer
.replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, '')
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
.replace(/\x1b[()][A-Z0-9]/g, '')
.replace(/\x1b[>=<]/g, '')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
.replace(/[ \t]+$/gm, '')
.replace(/\n{4,}/g, '\n\n\n')
.trim();
}
}
const body = document.getElementById('responseViewerBody');
body.innerHTML = this._renderMarkdown(lastResponse);
// Reset state for fresh open
const title = document.getElementById('responseViewerTitle');
const moreBtn = document.getElementById('responseViewerMore');
if (title) title.textContent = 'Last Response';
if (moreBtn) { moreBtn.style.display = ''; moreBtn.textContent = 'More'; }
viewer.classList.add('visible');
backdrop.classList.add('visible');
body.scrollTop = 0;
} catch (err) {
console.error('Failed to load response:', err);
}
}
async loadFullContext() {
if (!this.activeSessionId) return;
const moreBtn = document.getElementById('responseViewerMore');
if (moreBtn) moreBtn.textContent = '...';
try {
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=full`);
const data = await res.json();
const messages = data.messages || [];
const body = document.getElementById('responseViewerBody');
const title = document.getElementById('responseViewerTitle');
if (!body) return;
if (messages.length === 0) {
body.textContent = 'No conversation history available';
return;
}
// Render conversation thread
body.innerHTML = '';
for (const msg of messages) {
const div = document.createElement('div');
div.className = 'rv-message';
const role = document.createElement('div');
role.className = 'rv-role ' + (msg.role === 'user' ? 'rv-role-user' : 'rv-role-assistant');
role.textContent = msg.role === 'user' ? 'You' : 'Claude';
div.appendChild(role);
const text = document.createElement('div');
text.className = 'rv-text';
text.innerHTML = this._renderMarkdown(msg.text);
div.appendChild(text);
body.appendChild(div);
}
if (title) title.textContent = `Conversation (${messages.length} messages)`;
if (moreBtn) moreBtn.style.display = 'none';
// Scroll to bottom (latest message)
body.scrollTop = body.scrollHeight;
} catch (err) {
console.error('Failed to load context:', err);
} finally {
if (moreBtn) moreBtn.textContent = 'More';
}
}
async _onSessionNeedsRefresh() {
// Server sends this after SSE backpressure clears — terminal data was dropped,
// so reload the buffer to recover from any display corruption.
+15
View File
@@ -26,6 +26,7 @@
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
<script defer src="vendor/xterm-zerolag-input.js"></script>
<script defer src="vendor/marked.min.js"></script>
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
@@ -92,6 +93,7 @@
<span class="stat-value" id="statMem">--</span>
</div>
</div>
<button class="btn-icon-header btn-response-viewer-header" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
@@ -331,6 +333,19 @@
</div>
</div>
<!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) -->
<div class="response-viewer" id="responseViewer">
<div class="response-viewer-header">
<span id="responseViewerTitle">Last Response</span>
<div class="response-viewer-actions">
<button class="response-viewer-more" id="responseViewerMore" onclick="app.loadFullContext()">More</button>
<button class="response-viewer-close" onclick="app.toggleResponseViewer()">&times;</button>
</div>
</div>
<div class="response-viewer-body" id="responseViewerBody"></div>
</div>
<div class="response-viewer-backdrop" id="responseViewerBackdrop" onclick="app.toggleResponseViewer()"></div>
<!-- Bottom Toolbar -->
<footer class="toolbar">
<div class="toolbar-left">
+22 -2
View File
@@ -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 */
@@ -1189,6 +1195,20 @@ html.mobile-init .file-browser-panel {
touch-action: none;
}
/* Response viewer — show eye icon in header on mobile */
.btn-response-viewer-header {
display: inline-flex !important;
}
.response-viewer {
padding-bottom: var(--safe-area-bottom, 0px);
}
.response-viewer-body {
font-size: 12px;
padding: 12px;
}
/* Compact welcome overlay for mobile */
.welcome-content {
max-width: calc(100vw - 1.5rem);
+250 -1
View File
@@ -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 {
@@ -7741,6 +7764,232 @@ kbd {
padding-left: 0.5rem;
}
/* ═══════════════════════════════════════════════════════════════
Response Viewer — native-scroll overlay for reading Claude responses
═══════════════════════════════════════════════════════════════ */
/* Hidden on desktop — only shown on mobile via mobile.css override */
.btn-response-viewer-header {
display: none !important;
}
.response-viewer {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 85vh;
background: #1a1a2e;
border-top: 1px solid #333;
border-radius: 12px 12px 0 0;
z-index: 5000;
flex-direction: column;
transform: translateY(100%);
transition: transform 0.25s ease-out;
}
.response-viewer.visible {
display: flex;
transform: translateY(0);
}
.response-viewer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #333;
flex-shrink: 0;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
}
.response-viewer-actions {
display: flex;
align-items: center;
gap: 8px;
}
.response-viewer-more {
background: #2a2a4a;
border: 1px solid #444;
color: #aaa;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
}
.response-viewer-more:active {
background: #3a3a5a;
}
.response-viewer-close {
background: none;
border: none;
color: #888;
font-size: 22px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
/* Conversation thread messages */
.rv-message {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #2a2a3a;
}
.rv-message:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.rv-role {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.rv-role-user {
color: #5c7cfa;
}
.rv-role-assistant {
color: #51cf66;
}
/* Markdown rendered content inside response viewer */
.rv-text {
word-break: break-word;
line-height: 1.6;
}
.rv-text p {
margin: 0 0 0.6em;
}
.rv-text p:last-child {
margin-bottom: 0;
}
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4 {
color: #e0e0e0;
margin: 1em 0 0.4em;
line-height: 1.3;
}
.rv-text h1 { font-size: 1.3em; }
.rv-text h2 { font-size: 1.15em; }
.rv-text h3 { font-size: 1.05em; }
.rv-text code {
background: #2a2a3e;
padding: 1px 5px;
border-radius: 3px;
font-size: 0.9em;
}
.rv-text pre {
background: #1e1e2e;
border: 1px solid #333;
border-radius: 6px;
padding: 10px 12px;
overflow-x: auto;
margin: 0.6em 0;
}
.rv-text pre code {
background: none;
padding: 0;
font-size: 0.85em;
line-height: 1.5;
}
.rv-text ul, .rv-text ol {
margin: 0.4em 0;
padding-left: 1.4em;
}
.rv-text li {
margin-bottom: 0.2em;
}
.rv-text blockquote {
border-left: 3px solid #444;
margin: 0.6em 0;
padding: 0.3em 0 0.3em 12px;
color: #999;
}
.rv-text strong {
color: #f0f0f0;
}
.rv-text a {
color: #5c7cfa;
text-decoration: none;
}
.rv-text table {
border-collapse: collapse;
margin: 0.6em 0;
width: 100%;
font-size: 0.9em;
}
.rv-text th, .rv-text td {
border: 1px solid #333;
padding: 4px 8px;
text-align: left;
}
.rv-text th {
background: #2a2a3e;
color: #e0e0e0;
}
.rv-text hr {
border: none;
border-top: 1px solid #333;
margin: 1em 0;
}
.response-viewer-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 16px;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
font-size: 13px;
line-height: 1.5;
color: #d4d4d4;
}
.response-viewer-body:empty::after {
content: 'No response yet';
color: #555;
font-style: italic;
}
.response-viewer-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 4999;
}
.response-viewer-backdrop.visible {
display: block;
}
/* ═══════════════════════════════════════════════════════════════
CJK IME Input
═══════════════════════════════════════════════════════════════ */
+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).
* @returns {Promise<Array>} 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<Array>} 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 initialCount = this._HISTORY_INITIAL_COUNT;
const item = document.createElement('div');
item.className = 'history-item';
item.title = s.workingDir;
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
// Render initial items
for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) {
list.appendChild(this._buildHistoryItem(allSessions[i]));
}
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);
// 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 = '';
File diff suppressed because one or more lines are too long
+233 -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 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);
@@ -505,6 +535,117 @@ export function registerSessionRoutes(
return { success: true };
});
// ========== Get Last Response (from transcript JSONL) ==========
app.get('/api/sessions/:id/last-response', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
// The Claude conversation ID (used as JSONL filename)
const claudeSessionId = session.claudeSessionId || session.id;
// Scan ~/.claude/projects/*/ for the transcript file
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
let transcriptText = '';
let transcriptTimestamp = '';
try {
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
try {
const content = await fs.readFile(jsonlPath, 'utf8');
const lines = content.trim().split('\n');
// Search from end for last assistant message with text
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
if (entry.type === 'assistant' && entry.message?.content) {
const blocks = Array.isArray(entry.message.content)
? entry.message.content
: [{ type: 'text', text: String(entry.message.content) }];
const textBlocks = blocks
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
.map((b: { type: string; text?: string }) => b.text);
if (textBlocks.length > 0) {
transcriptText = textBlocks.join('\n\n');
transcriptTimestamp = entry.timestamp || '';
break;
}
}
} catch {
// Skip unparseable lines
}
}
if (transcriptText) break; // Found it, stop scanning directories
} catch {
// File doesn't exist in this project dir, continue
}
}
} catch {
// projects dir doesn't exist
}
// If ?context=full, return all user+assistant messages for conversation view
const query = req.query as { context?: string };
if (query.context === 'full' && transcriptText) {
const allMessages: Array<{ role: string; text: string; timestamp?: string }> = [];
try {
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
try {
const content = await fs.readFile(jsonlPath, 'utf8');
const lines = content.trim().split('\n');
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.content) {
const text =
typeof entry.message.content === 'string'
? entry.message.content
: (entry.message.content as Array<{ type: string; text?: string }>)
.filter((b) => b.type === 'text' && b.text)
.map((b) => b.text)
.join('\n');
// Skip system/command messages
if (text && !text.startsWith('<local-command') && !text.startsWith('<command-name>')) {
allMessages.push({ role: 'user', text, timestamp: entry.timestamp });
}
} else if (entry.type === 'assistant' && entry.message?.content) {
const blocks = Array.isArray(entry.message.content)
? entry.message.content
: [{ type: 'text', text: String(entry.message.content) }];
const text = blocks
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
.map((b: { type: string; text?: string }) => b.text)
.join('\n\n');
if (text) {
allMessages.push({ role: 'assistant', text, timestamp: entry.timestamp });
}
}
} catch {
/* skip */
}
}
if (allMessages.length > 0) break;
} catch {
/* continue */
}
}
} catch {
/* ignore */
}
return { text: transcriptText, timestamp: transcriptTimestamp, messages: allMessages };
}
return {
text: transcriptText,
timestamp: transcriptTimestamp,
};
});
// ========== Get Terminal Buffer ==========
// Query params:
@@ -924,6 +1065,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<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. */
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
try {
@@ -974,15 +1183,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 +1209,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);
}