mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
Merge pull request #62 from TeigenZhang/feat/mobile-response-viewer
feat: mobile response viewer with markdown rendering
This commit is contained in:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
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() {
|
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.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
|
<!-- 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-addon-unicode11.min.js"></script>
|
||||||
<script defer src="vendor/xterm-zerolag-input.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 -->
|
<!-- 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>
|
<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) -->
|
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||||
@@ -92,6 +93,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 +333,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">
|
||||||
|
|||||||
@@ -301,9 +301,15 @@ html.mobile-init .file-browser-panel {
|
|||||||
Phone Breakpoint (<430px)
|
Phone Breakpoint (<430px)
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@media (max-width: 430px) {
|
@media (max-width: 430px) {
|
||||||
/* Hide header brand on phones */
|
/* Compact header brand on phones — acts as home button */
|
||||||
.header-brand {
|
.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 */
|
/* Font controls - compact on phones, visibility controlled by JS */
|
||||||
@@ -1189,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);
|
||||||
|
|||||||
+250
-1
@@ -2224,6 +2224,24 @@ body {
|
|||||||
text-align: right;
|
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 {
|
.welcome-hint {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -5485,7 +5503,12 @@ kbd {
|
|||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.header-brand {
|
.header-brand {
|
||||||
display: none;
|
padding-right: 0.4rem;
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-brand .logo {
|
||||||
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-text {
|
.connection-text {
|
||||||
@@ -7741,6 +7764,232 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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
|
CJK IME Input
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|||||||
@@ -734,84 +734,110 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and deduplicate history sessions (up to 2 per dir, max `limit` total).
|
* Fetch and deduplicate history sessions (up to 3 per project, sorted by date).
|
||||||
* @returns {Promise<Array>} deduplicated session list, sorted by lastModified desc
|
* 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 res = await fetch('/api/history/sessions');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const sessions = data.sessions || [];
|
const sessions = data.sessions || [];
|
||||||
if (sessions.length === 0) return [];
|
if (sessions.length === 0) return [];
|
||||||
|
|
||||||
const byDir = new Map();
|
const byProject = new Map();
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []);
|
const key = s.projectKey || s.workingDir;
|
||||||
byDir.get(s.workingDir).push(s);
|
if (!byProject.has(key)) byProject.set(key, []);
|
||||||
|
byProject.get(key).push(s);
|
||||||
}
|
}
|
||||||
const items = [];
|
const items = [];
|
||||||
for (const [, group] of byDir) {
|
for (const [, group] of byProject) {
|
||||||
items.push(...group.slice(0, 2));
|
items.push(...group.slice(0, 3));
|
||||||
}
|
}
|
||||||
items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
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() {
|
async loadHistorySessions() {
|
||||||
const container = document.getElementById('historySessions');
|
const container = document.getElementById('historySessions');
|
||||||
const list = document.getElementById('historyList');
|
const list = document.getElementById('historyList');
|
||||||
if (!container || !list) return;
|
if (!container || !list) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const display = await this._fetchHistorySessions(12);
|
const allSessions = await this._fetchHistorySessions(30);
|
||||||
if (display.length === 0) {
|
if (allSessions.length === 0) {
|
||||||
container.style.display = 'none';
|
container.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build DOM safely (no innerHTML with user data)
|
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
for (const s of display) {
|
const initialCount = this._HISTORY_INITIAL_COUNT;
|
||||||
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');
|
// Render initial items
|
||||||
item.className = 'history-item';
|
for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) {
|
||||||
item.title = s.workingDir;
|
list.appendChild(this._buildHistoryItem(allSessions[i]));
|
||||||
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
|
}
|
||||||
|
|
||||||
const textCol = document.createElement('div');
|
// Add "Show More" button if there are more items
|
||||||
textCol.className = 'history-item-text';
|
if (allSessions.length > initialCount) {
|
||||||
|
const moreBtn = document.createElement('button');
|
||||||
const titleSpan = document.createElement('span');
|
moreBtn.className = 'history-show-more';
|
||||||
titleSpan.className = 'history-item-title';
|
moreBtn.textContent = `Show ${allSessions.length - initialCount} more`;
|
||||||
titleSpan.textContent = s.firstPrompt || shortDir;
|
moreBtn.addEventListener('click', () => {
|
||||||
|
for (let i = initialCount; i < allSessions.length; i++) {
|
||||||
const subtitleSpan = document.createElement('span');
|
list.insertBefore(this._buildHistoryItem(allSessions[i]), moreBtn);
|
||||||
subtitleSpan.className = 'history-item-subtitle';
|
}
|
||||||
subtitleSpan.textContent = shortDir;
|
moreBtn.remove();
|
||||||
|
});
|
||||||
textCol.append(titleSpan, subtitleSpan);
|
list.appendChild(moreBtn);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
container.style.display = '';
|
container.style.display = '';
|
||||||
|
|||||||
Vendored
+6
File diff suppressed because one or more lines are too long
@@ -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 globalNice = await ctx.getGlobalNiceConfig();
|
||||||
const modelConfig = await ctx.getModelConfig();
|
const modelConfig = await ctx.getModelConfig();
|
||||||
const mode = body.mode || 'claude';
|
const mode = body.mode || 'claude';
|
||||||
@@ -203,7 +233,7 @@ export function registerSessionRoutes(
|
|||||||
claudeMode: claudeModeConfig.claudeMode,
|
claudeMode: claudeModeConfig.claudeMode,
|
||||||
allowedTools: claudeModeConfig.allowedTools,
|
allowedTools: claudeModeConfig.allowedTools,
|
||||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||||
resumeSessionId: body.resumeSessionId,
|
resumeSessionId: validatedResumeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.addSession(session);
|
ctx.addSession(session);
|
||||||
@@ -505,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:
|
||||||
@@ -924,6 +1065,74 @@ export function registerSessionRoutes(
|
|||||||
return undefined;
|
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. */
|
/** Read the first 16KB of a file for content sniffing. */
|
||||||
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
|
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
@@ -974,15 +1183,11 @@ export function registerSessionRoutes(
|
|||||||
const stat = await fs.stat(projPath).catch(() => null);
|
const stat = await fs.stat(projPath).catch(() => null);
|
||||||
if (!stat?.isDirectory()) continue;
|
if (!stat?.isDirectory()) continue;
|
||||||
|
|
||||||
// Decode project key to working dir. The encoding replaces '/' with '-',
|
// Decode project key to working dir. Claude CLI encodes '/' as '-',
|
||||||
// which is lossy when path components contain '-'. Do naive decode first,
|
// but path components may also contain '-' (e.g. "AI_project" vs "AI-project").
|
||||||
// then verify it exists. Fall back to HOME if the decoded path is invalid.
|
// Use recursive backtracking: try each '-' as either '/' or literal '-',
|
||||||
const naiveDecode = projDir.replace(/^-/, '/').replace(/-/g, '/');
|
// verify which decoded path actually exists on disk.
|
||||||
const dirExists = await fs
|
const workingDir = await decodeProjectKey(projDir);
|
||||||
.access(naiveDecode)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
const workingDir = dirExists ? naiveDecode : process.env.HOME || '/tmp';
|
|
||||||
|
|
||||||
const entries = await fs.readdir(projPath);
|
const entries = await fs.readdir(projPath);
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -1004,24 +1209,31 @@ export function registerSessionRoutes(
|
|||||||
// Read first 16KB to check content and extract first user prompt.
|
// Read first 16KB to check content and extract first user prompt.
|
||||||
let firstPrompt: string | undefined;
|
let firstPrompt: string | undefined;
|
||||||
const head = await readFileHead(filePath, headBuf);
|
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) {
|
let foundContent = head ? hasConversation(head) : false;
|
||||||
if (
|
|
||||||
!head ||
|
// For large files, head may not contain user messages (e.g. /init followed
|
||||||
(!head.includes('"type":"user"') &&
|
// by large system entries). Check the tail as well.
|
||||||
!head.includes('"type":"assistant"') &&
|
let tail: string | null = null;
|
||||||
!head.includes('"type":"summary"'))
|
if (!foundContent && fileStat.size > 16384) {
|
||||||
) {
|
const tailBuf = Buffer.alloc(32768);
|
||||||
continue; // No conversation content — skip
|
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) firstPrompt = extractFirstUserPrompt(head);
|
||||||
|
|
||||||
// If head scan found no usable prompt (e.g. session started with /init),
|
// If head scan found no usable prompt (e.g. session started with /init),
|
||||||
// try reading the tail for a recent user message.
|
// try reading the tail for a recent user message.
|
||||||
if (!firstPrompt && fileStat.size > 65536) {
|
if (!firstPrompt && fileStat.size > 65536) {
|
||||||
const tailBuf = Buffer.alloc(32768);
|
if (!tail) {
|
||||||
const tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
const tailBuf = Buffer.alloc(32768);
|
||||||
|
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||||
|
}
|
||||||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user