diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index d33a1eb..6e2a51b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -14,7 +14,7 @@ Claude Code fires a hook event (e.g. "I just used the Read tool")
hook-handler.py receives the event JSON on stdin
|
v
-hook-handler.py POSTs it to http://localhost:3000/api/hooks
+hook-handler.py POSTs it to http://localhost:4700/api/hooks
|
v
The server updates the SQLite DB and broadcasts via WebSocket
@@ -54,7 +54,7 @@ Critical design constraint: **it must never block Claude Code.** If the server i
### 3. The Server (`server/`)
-A FastAPI app running on port 3000. It does four things:
+A FastAPI app running on port 4700. It does four things:
| Component | File | What It Does |
|-----------|------|-------------|
diff --git a/CLAUDE.md b/CLAUDE.md
index da4bb57..a9f9096 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,7 +12,7 @@ Web-based dashboard for monitoring and managing multiple Claude Code sessions.
## Running
```bash
source .venv/bin/activate
-uvicorn server.main:app --port 3000 --reload
+uvicorn server.main:app --port 4700 --reload
```
## Testing
diff --git a/Makefile b/Makefile
index 4ac39f5..7344cb6 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
.DEFAULT_GOAL := help
-PORT := 3000
+PORT := 4700
# Prefer project .venv via uv so `make up` works without `source .venv/bin/activate`
UV_RUN := uv run
@@ -11,12 +11,13 @@ help: ## Show this help
setup install: ## Full local setup: uv venv + dev deps, Playwright Chromium, Claude hooks
uv sync --extra dev
uv run python -m playwright install chromium
- bash scripts/setup.sh
+ bash scripts/setup.sh $(PORT)
@echo ""
@echo "Setup complete. Start the app: make up"
@echo "If Playwright/e2e fails (missing OS libs), run: uv run python -m playwright install --with-deps chromium"
-up: ## Start the server (port 3000; override with PORT=3001)
+up: ## Start the server (default 4700; override with PORT=XXXX)
+ @bash scripts/setup.sh $(PORT)
$(UV_RUN) uvicorn server.main:app --port $(PORT) --reload
down: ## Stop the server
diff --git a/README.md b/README.md
index 4c85aad..1ad03a6 100644
--- a/README.md
+++ b/README.md
@@ -51,15 +51,15 @@ pip install .
bash scripts/setup.sh
# Start the server
-uvicorn server.main:app --port 3000
+uvicorn server.main:app --port 4700
```
-Then open **http://localhost:3000** in your browser.
+Then open **http://localhost:4700** in your browser.
## Architecture
```
-Browser (localhost:3000)
+Browser (localhost:4700)
|
|-- REST API (/api/*) -- Sessions, history, search, analytics
|-- WebSocket (/ws/*) -- Real-time dashboard updates
@@ -122,7 +122,7 @@ source .venv/bin/activate
pytest
# Run with auto-reload
-uvicorn server.main:app --port 3000 --reload
+uvicorn server.main:app --port 4700 --reload
```
## Uninstalling
diff --git a/public/css/style.css b/public/css/style.css
index 0bb2ae0..0521703 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -1638,14 +1638,28 @@ body {
.history-table tbody tr:hover { background: var(--surface-3); }
-/* ---- Transcript Detail View ---- */
-.transcript-container { max-width: 800px; margin: 0 auto; }
+/* ---- Transcript Detail View (History tab) ---- */
+#transcript-view {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - 120px);
+}
+
+#transcript-view .transcript-header {
+ flex-shrink: 0;
+}
+
+#transcript-view .transcript-live {
+ flex: 1;
+ min-height: 0;
+}
.transcript-header {
display: flex;
align-items: center;
gap: 12px;
- margin-bottom: 20px;
+ margin-bottom: 12px;
+ padding: 0 4px;
}
.transcript-message {
diff --git a/public/index.html b/public/index.html
index d6b781c..836b444 100644
--- a/public/index.html
+++ b/public/index.html
@@ -6,7 +6,6 @@
-
`;
@@ -73,11 +72,18 @@ const History = {
});
document.getElementById('transcript-back').addEventListener('click', () => {
- document.getElementById('transcript-view').style.display = 'none';
- document.getElementById('history-content').style.display = 'block';
+ this.closeTranscript();
+ window.history.back();
});
},
+ closeTranscript() {
+ const tv = document.getElementById('transcript-view');
+ const hc = document.getElementById('history-content');
+ if (tv) tv.style.display = 'none';
+ if (hc) hc.style.display = 'block';
+ },
+
async fetchSessions() {
try {
const resp = await fetch(`/api/history?limit=${this.pageSize}&offset=${this.currentOffset}`);
@@ -179,13 +185,26 @@ const History = {
}
},
- async showTranscript(sessionId, title) {
+ showTranscriptDirect(sessionId, title) {
+ // Open transcript without pushing browser state (used by popstate handler)
+ this._openTranscript(sessionId, title);
+ },
+
+ showTranscript(sessionId, title) {
+ this._openTranscript(sessionId, title);
+ window.history.pushState(
+ { view: 'history', transcript: true, sessionId, sessionTitle: title },
+ '', `#history/${sessionId}`
+ );
+ },
+
+ async _openTranscript(sessionId, title) {
document.getElementById('history-content').style.display = 'none';
document.getElementById('transcript-view').style.display = 'block';
document.getElementById('transcript-title').textContent = title || sessionId;
try {
- const resp = await fetch(`/api/sessions/${sessionId}/transcript`);
+ const resp = await fetch(`/api/sessions/${sessionId}/transcript?limit=1000`);
const data = await resp.json();
this.renderTranscript(data.transcripts);
} catch (e) {
@@ -202,20 +221,10 @@ const History = {
return;
}
- container.innerHTML = transcripts.map(t => {
- const role = t.role || 'unknown';
- const isCollapsible = role === 'tool_use' || role === 'tool_result';
- const time = t.timestamp ? this._formatTime(t.timestamp) : '';
-
- return `
-
-
${this._esc(role)}
-
${time}
-
-
${this._esc(t.content || '')}
-
- `;
- }).join('');
+ // Use the same rich renderer as the live transcript view
+ const planGroups = SessionViewer._detectPlanGroups(transcripts);
+ const agentGroups = SessionViewer._detectAgentGroups(transcripts);
+ container.innerHTML = SessionViewer._renderTranscriptWithGroups(transcripts, planGroups, agentGroups);
},
_formatDate(dateStr) {
diff --git a/public/js/terminal.js b/public/js/terminal.js
index 1e99178..36f24be 100644
--- a/public/js/terminal.js
+++ b/public/js/terminal.js
@@ -1,18 +1,15 @@
/**
- * Terminal / Session Viewer
+ * Session Viewer — Live Transcript Display
*
- * When a session was launched via the Command Center (tmux), shows an interactive terminal.
- * For all other sessions, shows a live transcript that auto-updates.
+ * Shows a live-updating transcript when clicking a session card.
+ * Uses incremental fetching (after_id) to append new messages
+ * without re-rendering the entire DOM.
*/
const SessionViewer = {
- ws: null,
- term: null,
- fitAddon: null,
currentSessionId: null,
_pollInterval: null,
- _lastTranscriptCount: 0,
- _mode: null, // 'terminal' or 'transcript'
+ _lastTranscriptId: 0,
open(sessionId, title) {
this.currentSessionId = sessionId;
@@ -23,92 +20,10 @@ const SessionViewer = {
overlay.style.display = 'flex';
this._cleanup();
-
- // Try terminal first, fall back to transcript
- this._tryTerminal(sessionId);
- },
-
- _tryTerminal(sessionId) {
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
- const url = `${proto}//${location.host}/ws/terminal/${sessionId}`;
-
- this.ws = new WebSocket(url);
- let gotError = false;
-
- this.ws.onmessage = (event) => {
- if (typeof event.data === 'string') {
- try {
- const msg = JSON.parse(event.data);
- if (msg.type === 'error') {
- gotError = true;
- this.ws.close();
- // Fall back to transcript view
- this._showTranscript(sessionId);
- return;
- }
- if (msg.type === 'pong') return;
- } catch {
- // Not JSON — real terminal data
- if (!this.term) this._initXterm();
- this.term.write(event.data);
- }
- } else if (event.data instanceof Blob) {
- if (!this.term) this._initXterm();
- event.data.arrayBuffer().then(buf => {
- this.term.write(new Uint8Array(buf));
- });
- }
- };
-
- this.ws.onopen = () => {
- // Wait for first message to determine mode
- };
-
- this.ws.onclose = () => {
- if (!gotError && this.term) {
- this.term.writeln('\x1b[2m--- Disconnected ---\x1b[0m');
- }
- };
-
- this.ws.onerror = () => {
- this._showTranscript(sessionId);
- };
- },
-
- _initXterm() {
- this._mode = 'terminal';
- const container = document.getElementById('terminal-container');
- container.innerHTML = '';
-
- this.term = new window.Terminal({
- cursorBlink: true,
- fontSize: 14,
- fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
- theme: {
- background: '#0f0f1a',
- foreground: '#e0e0e0',
- cursor: '#7c3aed',
- selectionBackground: 'rgba(124, 58, 237, 0.3)',
- },
- });
-
- this.fitAddon = new window.FitAddon.FitAddon();
- this.term.loadAddon(this.fitAddon);
- this.term.open(container);
- this.fitAddon.fit();
-
- this._resizeHandler = () => { if (this.fitAddon) this.fitAddon.fit(); };
- window.addEventListener('resize', this._resizeHandler);
-
- this.term.onData((data) => {
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
- this.ws.send(data);
- }
- });
+ this._showTranscript(sessionId);
},
async _showTranscript(sessionId) {
- this._mode = 'transcript';
const container = document.getElementById('terminal-container');
container.innerHTML = `
@@ -118,34 +33,47 @@ const SessionViewer = {
`;
this._lastTranscriptId = 0;
- await this._fetchTranscript(sessionId);
+ await this._fetchTranscript(sessionId, true);
// Poll for new transcript entries every 2 seconds
this._pollInterval = setInterval(() => {
- this._fetchTranscript(sessionId);
+ this._fetchTranscript(sessionId, false);
}, 2000);
},
- async _fetchTranscript(sessionId) {
+ async _fetchTranscript(sessionId, isInitial) {
try {
- const resp = await fetch(`/api/sessions/${sessionId}/transcript?limit=1000`);
+ const url = isInitial
+ ? `/api/sessions/${sessionId}/transcript?limit=1000`
+ : `/api/sessions/${sessionId}/transcript?after_id=${this._lastTranscriptId}&limit=200`;
+
+ const resp = await fetch(url);
const data = await resp.json();
const transcripts = data.transcripts || [];
- // Detect changes by comparing last entry's ID
- const lastId = transcripts.length > 0 ? transcripts[transcripts.length - 1].id : 0;
- if (lastId === this._lastTranscriptId) return;
+ if (transcripts.length === 0) return;
+
+ const lastId = transcripts[transcripts.length - 1].id;
this._lastTranscriptId = lastId;
const container = document.getElementById('transcript-live-messages');
if (!container) return;
- const planGroups = this._detectPlanGroups(transcripts);
- const agentGroups = this._detectAgentGroups(transcripts);
- container.innerHTML = this._renderTranscriptWithGroups(transcripts, planGroups, agentGroups);
-
- // Auto-scroll to bottom
- container.scrollTop = container.scrollHeight;
+ if (isInitial) {
+ // Full render for initial load (supports plan/agent grouping)
+ const planGroups = this._detectPlanGroups(transcripts);
+ const agentGroups = this._detectAgentGroups(transcripts);
+ container.innerHTML = this._renderTranscriptWithGroups(transcripts, planGroups, agentGroups);
+ container.scrollTop = container.scrollHeight;
+ } else {
+ // Incremental: append only new messages without touching existing DOM
+ const wasAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
+ const html = transcripts.map(t => this._renderMessage(t)).join('');
+ container.insertAdjacentHTML('beforeend', html);
+ if (wasAtBottom) {
+ container.scrollTop = container.scrollHeight;
+ }
+ }
} catch (e) {
console.error('Failed to fetch transcript:', e);
}
@@ -159,18 +87,10 @@ const SessionViewer = {
},
_cleanup() {
- if (this.ws) { this.ws.close(); this.ws = null; }
- if (this.term) { this.term.dispose(); this.term = null; }
- if (this.fitAddon) { this.fitAddon = null; }
- if (this._resizeHandler) {
- window.removeEventListener('resize', this._resizeHandler);
- this._resizeHandler = null;
- }
if (this._pollInterval) {
clearInterval(this._pollInterval);
this._pollInterval = null;
}
- this._mode = null;
this._lastTranscriptId = 0;
const container = document.getElementById('terminal-container');
if (container) container.innerHTML = '';
@@ -220,7 +140,6 @@ const SessionViewer = {
const toolItems = toolBlocks.map(b => {
const content = b.lines.join('\n').trim();
const preview = this._toolPreview(b.name, content);
- const uid = Math.random().toString(36).slice(2, 8);
return `