From 3cb7b510f866256e0efa1226ea3534a8bd790f66 Mon Sep 17 00:00:00 2001 From: Teigen Date: Fri, 10 Apr 2026 19:07:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20mobile=20response=20viewer=20=E2=80=94?= =?UTF-8?q?=20read=20full=20Claude=20responses=20via=20native=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/web/public/app.js | 106 ++++++++++++++++++++++++ src/web/public/index.html | 14 ++++ src/web/public/mobile.css | 14 ++++ src/web/public/styles.css | 137 +++++++++++++++++++++++++++++++ src/web/routes/session-routes.ts | 111 +++++++++++++++++++++++++ 5 files changed, 382 insertions(+) diff --git a/src/web/public/app.js b/src/web/public/app.js index dac3df42..72bde78d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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() { // Server sends this after SSE backpressure clears — terminal data was dropped, // so reload the buffer to recover from any display corruption. diff --git a/src/web/public/index.html b/src/web/public/index.html index 42f95d3f..624d9ab6 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -92,6 +92,7 @@ -- + + + + +
+ +
+