Skip to content

Commit 14f7d82

Browse files
authored
Merge pull request #62 from TeigenZhang/feat/mobile-response-viewer
feat: mobile response viewer with markdown rendering
2 parents 7101e64 + d32f4de commit 14f7d82

7 files changed

Lines changed: 724 additions & 78 deletions

File tree

src/web/public/app.js

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

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

src/web/public/index.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
2727
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
2828
<script defer src="vendor/xterm-zerolag-input.js"></script>
29+
<script defer src="vendor/marked.min.js"></script>
2930
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
3031
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
3132
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
@@ -92,6 +93,7 @@
9293
<span class="stat-value" id="statMem">--</span>
9394
</div>
9495
</div>
96+
<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>
9597
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications">
9698
<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>
9799
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
@@ -331,6 +333,19 @@ <h3 class="history-title">Resume Conversation</h3>
331333
</div>
332334
</div>
333335

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

src/web/public/mobile.css

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,9 +301,15 @@ html.mobile-init .file-browser-panel {
301301
Phone Breakpoint (<430px)
302302
============================================================================ */
303303
@media (max-width: 430px) {
304-
/* Hide header brand on phones */
304+
/* Compact header brand on phones — acts as home button */
305305
.header-brand {
306-
display: none;
306+
padding-right: 0.25rem;
307+
margin-right: 0.2rem;
308+
border-right: none;
309+
}
310+
311+
.header-brand .logo {
312+
font-size: 0.7rem;
307313
}
308314

309315
/* Font controls - compact on phones, visibility controlled by JS */
@@ -1189,6 +1195,20 @@ html.mobile-init .file-browser-panel {
11891195
touch-action: none;
11901196
}
11911197

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+
11921212
/* Compact welcome overlay for mobile */
11931213
.welcome-content {
11941214
max-width: calc(100vw - 1.5rem);

0 commit comments

Comments
 (0)