mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
feat: mobile response viewer — read full Claude responses via native scroll
Claude Code's Ink framework uses alternate screen buffer + VPA cursor positioning, resulting in near-zero xterm.js scrollback on mobile. Instead of fighting terminal scrollback, this adds a native scrollable overlay that reads structured responses from Claude's JSONL transcripts. - New API: GET /api/sessions/:id/last-response reads transcript JSONL - ?context=full returns full conversation thread (user + assistant) - Fallback to terminal buffer with ANSI stripping if no transcript - Response viewer panel: bottom sheet with native iOS/Android scroll - "More" button loads full conversation context as threaded view - Eye icon in header bar (mobile only), no toolbar space impact
This commit is contained in:
@@ -869,6 +869,112 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Response Viewer — native-scroll panel for reading full Claude responses
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
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.textContent = 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.textContent = 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() {
|
async _onSessionNeedsRefresh() {
|
||||||
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
||||||
// so reload the buffer to recover from any display corruption.
|
// so reload the buffer to recover from any display corruption.
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
<span class="stat-value" id="statMem">--</span>
|
<span class="stat-value" id="statMem">--</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<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>
|
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||||
@@ -331,6 +332,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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()">×</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 -->
|
<!-- Bottom Toolbar -->
|
||||||
<footer class="toolbar">
|
<footer class="toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
|
|||||||
@@ -1195,6 +1195,20 @@ html.mobile-init .file-browser-panel {
|
|||||||
touch-action: none;
|
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 */
|
/* Compact welcome overlay for mobile */
|
||||||
.welcome-content {
|
.welcome-content {
|
||||||
max-width: calc(100vw - 1.5rem);
|
max-width: calc(100vw - 1.5rem);
|
||||||
|
|||||||
@@ -7764,6 +7764,143 @@ kbd {
|
|||||||
padding-left: 0.5rem;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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
|
CJK IME Input
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|||||||
@@ -535,6 +535,117 @@ export function registerSessionRoutes(
|
|||||||
return { success: true };
|
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 ==========
|
// ========== Get Terminal Buffer ==========
|
||||||
|
|
||||||
// Query params:
|
// Query params:
|
||||||
|
|||||||
Reference in New Issue
Block a user