Skip to content

Commit 3cb7b51

Browse files
TeigenTeigen
authored andcommitted
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
1 parent fd74a42 commit 3cb7b51

5 files changed

Lines changed: 382 additions & 0 deletions

File tree

src/web/public/app.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,112 @@ class CodemanApp {
869869
}
870870
}
871871

872+
// ═══════════════════════════════════════════════════════════════
873+
// Response Viewer — native-scroll panel for reading full Claude responses
874+
// ═══════════════════════════════════════════════════════════════
875+
876+
async toggleResponseViewer() {
877+
const viewer = document.getElementById('responseViewer');
878+
const backdrop = document.getElementById('responseViewerBackdrop');
879+
if (!viewer) return;
880+
881+
const isOpen = viewer.classList.contains('visible');
882+
if (isOpen) {
883+
viewer.classList.remove('visible');
884+
backdrop.classList.remove('visible');
885+
return;
886+
}
887+
888+
if (!this.activeSessionId) return;
889+
try {
890+
// Source 1: Transcript JSONL (best quality — clean structured text from Claude)
891+
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`);
892+
const data = await res.json();
893+
let lastResponse = data.text || '';
894+
895+
// Source 2: Terminal buffer fallback (strip ANSI codes)
896+
if (!lastResponse) {
897+
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
898+
const termData = await termRes.json();
899+
if (termData.terminalBuffer) {
900+
lastResponse = termData.terminalBuffer
901+
.replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, '')
902+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
903+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
904+
.replace(/\x1b[()][A-Z0-9]/g, '')
905+
.replace(/\x1b[>=<]/g, '')
906+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
907+
.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
908+
.replace(/[ \t]+$/gm, '')
909+
.replace(/\n{4,}/g, '\n\n\n')
910+
.trim();
911+
}
912+
}
913+
914+
const body = document.getElementById('responseViewerBody');
915+
body.textContent = lastResponse;
916+
917+
// Reset state for fresh open
918+
const title = document.getElementById('responseViewerTitle');
919+
const moreBtn = document.getElementById('responseViewerMore');
920+
if (title) title.textContent = 'Last Response';
921+
if (moreBtn) { moreBtn.style.display = ''; moreBtn.textContent = 'More'; }
922+
923+
viewer.classList.add('visible');
924+
backdrop.classList.add('visible');
925+
body.scrollTop = 0;
926+
} catch (err) {
927+
console.error('Failed to load response:', err);
928+
}
929+
}
930+
931+
async loadFullContext() {
932+
if (!this.activeSessionId) return;
933+
const moreBtn = document.getElementById('responseViewerMore');
934+
if (moreBtn) moreBtn.textContent = '...';
935+
try {
936+
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=full`);
937+
const data = await res.json();
938+
const messages = data.messages || [];
939+
const body = document.getElementById('responseViewerBody');
940+
const title = document.getElementById('responseViewerTitle');
941+
if (!body) return;
942+
943+
if (messages.length === 0) {
944+
body.textContent = 'No conversation history available';
945+
return;
946+
}
947+
948+
// Render conversation thread
949+
body.innerHTML = '';
950+
for (const msg of messages) {
951+
const div = document.createElement('div');
952+
div.className = 'rv-message';
953+
954+
const role = document.createElement('div');
955+
role.className = 'rv-role ' + (msg.role === 'user' ? 'rv-role-user' : 'rv-role-assistant');
956+
role.textContent = msg.role === 'user' ? 'You' : 'Claude';
957+
div.appendChild(role);
958+
959+
const text = document.createElement('div');
960+
text.className = 'rv-text';
961+
text.textContent = msg.text;
962+
div.appendChild(text);
963+
964+
body.appendChild(div);
965+
}
966+
967+
if (title) title.textContent = `Conversation (${messages.length} messages)`;
968+
if (moreBtn) moreBtn.style.display = 'none';
969+
// Scroll to bottom (latest message)
970+
body.scrollTop = body.scrollHeight;
971+
} catch (err) {
972+
console.error('Failed to load context:', err);
973+
} finally {
974+
if (moreBtn) moreBtn.textContent = 'More';
975+
}
976+
}
977+
872978
async _onSessionNeedsRefresh() {
873979
// Server sends this after SSE backpressure clears — terminal data was dropped,
874980
// so reload the buffer to recover from any display corruption.

src/web/public/index.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
<span class="stat-value" id="statMem">--</span>
9393
</div>
9494
</div>
95+
<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>
9596
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications">
9697
<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>
9798
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
@@ -331,6 +332,19 @@ <h3 class="history-title">Resume Conversation</h3>
331332
</div>
332333
</div>
333334

335+
<!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) -->
336+
<div class="response-viewer" id="responseViewer">
337+
<div class="response-viewer-header">
338+
<span id="responseViewerTitle">Last Response</span>
339+
<div class="response-viewer-actions">
340+
<button class="response-viewer-more" id="responseViewerMore" onclick="app.loadFullContext()">More</button>
341+
<button class="response-viewer-close" onclick="app.toggleResponseViewer()">&times;</button>
342+
</div>
343+
</div>
344+
<div class="response-viewer-body" id="responseViewerBody"></div>
345+
</div>
346+
<div class="response-viewer-backdrop" id="responseViewerBackdrop" onclick="app.toggleResponseViewer()"></div>
347+
334348
<!-- Bottom Toolbar -->
335349
<footer class="toolbar">
336350
<div class="toolbar-left">

src/web/public/mobile.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,6 +1195,20 @@ html.mobile-init .file-browser-panel {
11951195
touch-action: none;
11961196
}
11971197

1198+
/* Response viewer — show eye icon in header on mobile */
1199+
.btn-response-viewer-header {
1200+
display: inline-flex !important;
1201+
}
1202+
1203+
.response-viewer {
1204+
padding-bottom: var(--safe-area-bottom, 0px);
1205+
}
1206+
1207+
.response-viewer-body {
1208+
font-size: 12px;
1209+
padding: 12px;
1210+
}
1211+
11981212
/* Compact welcome overlay for mobile */
11991213
.welcome-content {
12001214
max-width: calc(100vw - 1.5rem);

src/web/public/styles.css

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7764,6 +7764,143 @@ kbd {
77647764
padding-left: 0.5rem;
77657765
}
77667766

7767+
/* ═══════════════════════════════════════════════════════════════
7768+
Response Viewer — native-scroll overlay for reading Claude responses
7769+
═══════════════════════════════════════════════════════════════ */
7770+
7771+
/* Hidden on desktop — only shown on mobile via mobile.css override */
7772+
.btn-response-viewer-header {
7773+
display: none !important;
7774+
}
7775+
7776+
.response-viewer {
7777+
display: none;
7778+
position: fixed;
7779+
bottom: 0;
7780+
left: 0;
7781+
right: 0;
7782+
max-height: 85vh;
7783+
background: #1a1a2e;
7784+
border-top: 1px solid #333;
7785+
border-radius: 12px 12px 0 0;
7786+
z-index: 5000;
7787+
flex-direction: column;
7788+
transform: translateY(100%);
7789+
transition: transform 0.25s ease-out;
7790+
}
7791+
7792+
.response-viewer.visible {
7793+
display: flex;
7794+
transform: translateY(0);
7795+
}
7796+
7797+
.response-viewer-header {
7798+
display: flex;
7799+
align-items: center;
7800+
justify-content: space-between;
7801+
padding: 12px 16px;
7802+
border-bottom: 1px solid #333;
7803+
flex-shrink: 0;
7804+
font-size: 14px;
7805+
font-weight: 600;
7806+
color: #e0e0e0;
7807+
}
7808+
7809+
.response-viewer-actions {
7810+
display: flex;
7811+
align-items: center;
7812+
gap: 8px;
7813+
}
7814+
7815+
.response-viewer-more {
7816+
background: #2a2a4a;
7817+
border: 1px solid #444;
7818+
color: #aaa;
7819+
font-size: 12px;
7820+
padding: 3px 10px;
7821+
border-radius: 4px;
7822+
cursor: pointer;
7823+
}
7824+
7825+
.response-viewer-more:active {
7826+
background: #3a3a5a;
7827+
}
7828+
7829+
.response-viewer-close {
7830+
background: none;
7831+
border: none;
7832+
color: #888;
7833+
font-size: 22px;
7834+
cursor: pointer;
7835+
padding: 0 4px;
7836+
line-height: 1;
7837+
}
7838+
7839+
/* Conversation thread messages */
7840+
.rv-message {
7841+
margin-bottom: 16px;
7842+
padding-bottom: 16px;
7843+
border-bottom: 1px solid #2a2a3a;
7844+
}
7845+
7846+
.rv-message:last-child {
7847+
border-bottom: none;
7848+
margin-bottom: 0;
7849+
padding-bottom: 0;
7850+
}
7851+
7852+
.rv-role {
7853+
font-size: 11px;
7854+
font-weight: 600;
7855+
text-transform: uppercase;
7856+
letter-spacing: 0.5px;
7857+
margin-bottom: 6px;
7858+
}
7859+
7860+
.rv-role-user {
7861+
color: #5c7cfa;
7862+
}
7863+
7864+
.rv-role-assistant {
7865+
color: #51cf66;
7866+
}
7867+
7868+
.rv-text {
7869+
white-space: pre-wrap;
7870+
word-break: break-word;
7871+
}
7872+
7873+
.response-viewer-body {
7874+
flex: 1;
7875+
overflow-y: auto;
7876+
-webkit-overflow-scrolling: touch;
7877+
padding: 16px;
7878+
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
7879+
font-size: 13px;
7880+
line-height: 1.5;
7881+
color: #d4d4d4;
7882+
white-space: pre-wrap;
7883+
word-break: break-word;
7884+
}
7885+
7886+
.response-viewer-body:empty::after {
7887+
content: 'No response yet';
7888+
color: #555;
7889+
font-style: italic;
7890+
}
7891+
7892+
.response-viewer-backdrop {
7893+
display: none;
7894+
position: fixed;
7895+
inset: 0;
7896+
background: rgba(0, 0, 0, 0.5);
7897+
z-index: 4999;
7898+
}
7899+
7900+
.response-viewer-backdrop.visible {
7901+
display: block;
7902+
}
7903+
77677904
/* ═══════════════════════════════════════════════════════════════
77687905
CJK IME Input
77697906
═══════════════════════════════════════════════════════════════ */

0 commit comments

Comments
 (0)