diff --git a/public/index.html b/public/index.html
index a91712b..0692106 100644
--- a/public/index.html
+++ b/public/index.html
@@ -220,6 +220,13 @@
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ .session-output.scrolled-up .scroll-to-bottom-btn {
+ opacity: 1;
+ pointer-events: auto;
}
.scroll-to-bottom-btn:hover {
@@ -235,9 +242,10 @@
.terminal {
font-family: "SF Mono", Monaco, "Cascadia Code", "Courier New", monospace;
font-size: var(--font-size);
- line-height: 1.5;
+ line-height: 1.3;
white-space: var(--wrap-mode);
- word-wrap: break-word;
+ word-wrap: normal;
+ overflow-wrap: normal;
color: #e0e0e0;
}
@@ -1012,7 +1020,7 @@
columns: 1, // 1, 2, 3, or 'fit'
fontSize: 12, // px value
terminalHeight: 400, // px value
- wrapText: true, // true = pre-wrap, false = pre (horizontal scroll)
+ wrapText: false, // true = pre-wrap, false = pre (horizontal scroll)
},
};
@@ -1043,26 +1051,30 @@
}
const sessions = await res.json();
sessions.forEach(session => {
- state.sessions.set(session.id, {
+ const existing = state.sessions.get(session.id);
+ const merged = {
id: session.id,
cwd: session.cwd,
command: session.command,
- output: '',
- outputRenderedLength: 0,
- expanded: false,
- state: 'ready',
- prompts: 0,
- completions: 0,
- tools: 0,
- compressions: 0,
- thinking_seconds: 0,
- work_seconds: 0,
- mode: 'normal',
- model: null,
- idle_since: null,
- git_branch: null,
- git_files_json: null,
- });
+ // Preserve any output already received via SSE before fetch completed
+ output: existing?.output ?? '',
+ outputRenderedLength: existing?.outputRenderedLength ?? 0,
+ // Preserve expansion state
+ expanded: existing?.expanded ?? false,
+ state: existing?.state ?? 'ready',
+ prompts: existing?.prompts ?? 0,
+ completions: existing?.completions ?? 0,
+ tools: existing?.tools ?? 0,
+ compressions: existing?.compressions ?? 0,
+ thinking_seconds: existing?.thinking_seconds ?? 0,
+ work_seconds: existing?.work_seconds ?? 0,
+ mode: existing?.mode ?? 'normal',
+ model: existing?.model ?? null,
+ idle_since: existing?.idle_since ?? null,
+ git_branch: existing?.git_branch ?? null,
+ git_files_json: existing?.git_files_json ?? null,
+ };
+ state.sessions.set(session.id, merged);
});
renderSessions();
} catch (error) {
@@ -1135,13 +1147,36 @@
es.addEventListener('initial_state', (e) => {
const data = JSON.parse(e.data);
- const session = state.sessions.get(data.session_id);
- if (session) {
- // Replace output with current terminal state
- session.output = data.html;
- session.outputRenderedLength = 0; // Force re-render
- renderSessionOutput(data.session_id);
+ let session = state.sessions.get(data.session_id);
+ if (!session) {
+ // Create a placeholder session so we don't drop the state
+ session = {
+ id: data.session_id,
+ cwd: '',
+ command: 'claude',
+ output: '',
+ outputRenderedLength: 0,
+ expanded: false,
+ state: 'ready',
+ prompts: 0,
+ completions: 0,
+ tools: 0,
+ compressions: 0,
+ thinking_seconds: 0,
+ work_seconds: 0,
+ mode: 'normal',
+ model: null,
+ idle_since: null,
+ git_branch: null,
+ git_files_json: null,
+ };
+ state.sessions.set(data.session_id, session);
+ renderSessions();
}
+ // Replace output with current terminal state
+ session.output = data.html;
+ session.outputRenderedLength = 0; // Force re-render
+ renderSessionOutput(data.session_id);
});
es.addEventListener('output', (e) => {
@@ -1226,6 +1261,10 @@
// Wait for DOM to update before measuring
setTimeout(() => {
handleSessionResize(Number(sessionId));
+ // After mount, ensure we start at bottom and initialize UI state
+ scrollToBottom(Number(sessionId));
+ updateScrollButton(Number(sessionId));
+ initSessionOutputUI(Number(sessionId));
}, 0);
}
}
@@ -1426,6 +1465,12 @@
`).join('');
+
+ // Initialize scroll handlers and button visibility
+ sessionsToRender.forEach(s => {
+ initSessionOutputUI(s.id);
+ updateScrollButton(s.id);
+ });
}
function renderSessionOutput(sessionId) {
@@ -1434,17 +1479,47 @@
const $output = document.getElementById(`output-${sessionId}`);
if ($output) {
- // Always full replace since serializeAsHTML() returns full screen state
- $output.innerHTML = session.output;
+ const $outputContainer = document.getElementById(`session-output-${sessionId}`);
+ const shouldStickToBottom =
+ !!($outputContainer && session.expanded && isAtBottom($outputContainer));
+
+ // Only mutate DOM when expanded to reduce work
+ if (session.expanded) {
+ $output.innerHTML = session.output;
+ }
session.outputRenderedLength = session.output.length;
- const $outputContainer = document.getElementById(`session-output-${sessionId}`);
- if ($outputContainer && session.expanded) {
+ if ($outputContainer && session.expanded && shouldStickToBottom) {
$outputContainer.scrollTop = $outputContainer.scrollHeight;
}
+
+ // Update scroll-to-bottom button visibility
+ updateScrollButton(sessionId);
}
}
+ function isAtBottom(el, threshold = 8) {
+ return el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
+ }
+
+ function updateScrollButton(sessionId) {
+ const $outputContainer = document.getElementById(`session-output-${sessionId}`);
+ if (!$outputContainer) return;
+ const nearBottom = isAtBottom($outputContainer);
+ if (nearBottom) {
+ $outputContainer.classList.remove('scrolled-up');
+ } else {
+ $outputContainer.classList.add('scrolled-up');
+ }
+ }
+
+ function initSessionOutputUI(sessionId) {
+ const $outputContainer = document.getElementById(`session-output-${sessionId}`);
+ if (!$outputContainer) return;
+ // Attach scroll listener to toggle the button visibility
+ $outputContainer.addEventListener('scroll', () => updateScrollButton(sessionId));
+ }
+
function updateSessionCard(sessionId) {
const session = state.sessions.get(sessionId);
if (!session) return;