diff --git a/CHANGELOG.md b/CHANGELOG.md index 64537af..070a373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.10.0] - 2025-09-13 + +### Added +- Tiled View (MVP): view two sessions side‑by‑side with independent terminals and sockets. +- Resizable splitter between panes with persistent split position. +- Per‑pane session picker and close controls; layout and assignments persist in localStorage. + +### Changed +- Settings font size now applies to all visible panes in tiled view. + +### Notes +- Client‑side only; no server/CLI changes required. Default remains single‑pane; toggle via new tile button in the top bar. + +## [2.9.0] - 2025-09-13 + +### Added +- Theme toggle in Settings with persistence (Dark/Light). +- Early theme application to avoid flash of incorrect theme on load. + +### Changed +- Default theme set to Dark; Light can be selected in Settings. + +### Notes +- UI-only change; no server/CLI APIs modified. + ## [2.8.0] - 2025-09-13 ### Added @@ -133,14 +158,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Startup logs show configured aliases. - README updated with alias usage examples. -## [2.9.0] - 2025-09-13 - -### Added -- Theme toggle in Settings with persistence (Dark/Light). -- Early theme application to avoid flash of incorrect theme on load. - -### Changed -- Default theme set to Dark; Light can be selected in Settings. - -### Notes -- UI-only change; no server/CLI APIs modified. diff --git a/package-lock.json b/package-lock.json index 1416143..79bfe83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-web", - "version": "2.9.0", + "version": "2.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-web", - "version": "2.9.0", + "version": "2.10.0", "license": "MIT", "dependencies": { "@ngrok/ngrok": "^1.4.0", diff --git a/package.json b/package.json index dbd5592..d0d2b6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-web", - "version": "2.9.0", + "version": "2.10.0", "description": "Web-based interface for Claude Code CLI accessible via browser", "main": "src/server.js", "bin": { diff --git a/src/public/app.js b/src/public/app.js index 69bc850..5fad65e 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -32,6 +32,7 @@ class ClaudeCodeWebInterface { this.sessionTimer = null; this.sessionTimerInterval = null; + this.paneManager = null; this.init(); } @@ -72,6 +73,8 @@ class ClaudeCodeWebInterface { this.setupTerminal(); this.setupUI(); this.setupPlanDetector(); + // Pane manager after UI exists + this.paneManager = new PaneManager(this); this.loadSettings(); this.applyAliasesToUI(); this.disablePullToRefresh(); @@ -109,6 +112,7 @@ class ClaudeCodeWebInterface { window.addEventListener('resize', () => { this.fitTerminal(); + if (this.paneManager?.enabled) this.paneManager.panes.forEach(p => p.fit()); }); window.addEventListener('beforeunload', () => { @@ -151,7 +155,7 @@ class ClaudeCodeWebInterface { // Plan modal title const planTitle = document.querySelector('#planModal .modal-header h2'); - if (planTitle) planTitle.innerHTML = `${window.icons?.clipboard?.(18) || ''} ${this.getAlias('claude')}'s Plan`; + if (planTitle) planTitle.innerHTML = `${window.icons?.clipboard?.(18) || ''} ${this.getAlias('claude')}'s Plan`; } detectMobile() { @@ -432,6 +436,19 @@ class ClaudeCodeWebInterface { if (settingsBtn) settingsBtn.addEventListener('click', () => this.showSettings()); if (retryBtn) retryBtn.addEventListener('click', () => this.reconnect()); + // Tile view toggle + const tileToggle = document.getElementById('tileViewToggle'); + if (tileToggle) { + tileToggle.addEventListener('click', () => { + if (!this.paneManager) return; + if (this.paneManager.enabled) { + this.paneManager.disable(); + } else { + this.paneManager.enable(); + } + }); + } + // Mobile menu event listeners if (closeMenuBtn) closeMenuBtn.addEventListener('click', () => this.closeMobileMenu()); if (settingsBtnMobile) { @@ -947,6 +964,8 @@ class ClaudeCodeWebInterface { message.plan, message.limits ); + // Also refresh pane session selectors when sessions list changes + if (this.paneManager) this.paneManager.refreshSessionSelects(); break; default: @@ -1155,6 +1174,7 @@ class ClaudeCodeWebInterface { } this.terminal.options.fontSize = settings.fontSize; + if (this.paneManager?.panes) this.paneManager.panes.forEach(p => { if (p.terminal) p.terminal.options.fontSize = settings.fontSize; p.fit();}); this.fitTerminal(); } @@ -2248,5 +2268,6 @@ document.head.appendChild(style); document.addEventListener('DOMContentLoaded', () => { const app = new ClaudeCodeWebInterface(); + window.app = app; app.startHeartbeat(); }); diff --git a/src/public/index.html b/src/public/index.html index f1b3fa2..b7b804c 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -106,12 +106,21 @@ - + + + + + + + + + - + + @@ -130,7 +139,7 @@ - + @@ -158,6 +167,32 @@ Connection Error + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -329,6 +364,7 @@ Sessions + diff --git a/src/public/panes.js b/src/public/panes.js new file mode 100644 index 0000000..7ceca63 --- /dev/null +++ b/src/public/panes.js @@ -0,0 +1,216 @@ +class ClaudePane { + constructor(index, app) { + this.index = index; + this.app = app; // reference to main app for auth and session list + this.terminal = null; + this.fitAddon = null; + this.webLinksAddon = null; + this.socket = null; + this.sessionId = null; + this.container = document.getElementById(`tileTerminal${index}`); + } + + async setSession(sessionId) { + if (this.sessionId === sessionId) return; + this.disconnect(); + this.sessionId = sessionId; + if (!sessionId) return; + this.ensureTerminal(); + await this.connect(); + } + + ensureTerminal() { + if (this.terminal) return; + this.terminal = new Terminal({ + fontFamily: this.app?.terminal?.options?.fontFamily || "JetBrains Mono, monospace", + fontSize: this.app?.terminal?.options?.fontSize || 14, + cursorBlink: true, + convertEol: true, + allowProposedApi: true, + theme: this.app?.terminal?.options?.theme + }); + this.fitAddon = new FitAddon.FitAddon(); + this.webLinksAddon = new WebLinksAddon.WebLinksAddon(); + this.terminal.loadAddon(this.fitAddon); + this.terminal.loadAddon(this.webLinksAddon); + this.terminal.open(this.container); + this.fit(); + window.addEventListener('resize', () => this.fit()); + } + + fit() { + try { this.fitAddon?.fit(); } catch (_) {} + } + + async connect() { + if (!this.sessionId) return; + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + let wsUrl = `${protocol}//${location.host}`; + wsUrl += `?sessionId=${encodeURIComponent(this.sessionId)}`; + wsUrl = window.authManager.getWebSocketUrl(wsUrl); + this.socket = new WebSocket(wsUrl); + + this.socket.onopen = () => { + // size sync + const { cols, rows } = this.terminal; + this.socket.send(JSON.stringify({ type: 'resize', cols, rows })); + this.terminal.focus(); + }; + this.socket.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type === 'output') { + const filtered = msg.data.replace(/\x1b\[\[?[IO]/g, ''); + this.terminal.write(filtered); + } + }; + this.socket.onclose = () => {}; + this.socket.onerror = () => {}; + + this.terminal.onData((data) => { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + const filtered = data.replace(/\x1b\[\[?[IO]/g, ''); + if (filtered) this.socket.send(JSON.stringify({ type: 'input', data: filtered })); + } + }); + this.terminal.onResize(({ cols, rows }) => { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify({ type: 'resize', cols, rows })); + } + }); + } + + disconnect() { + try { this.socket?.close(); } catch (_) {} + this.socket = null; + try { this.terminal?.clear(); } catch (_) {} + } +} + +class PaneManager { + constructor(app) { + this.app = app; + this.enabled = false; + this.grid = document.getElementById('tileGrid'); + this.container = document.getElementById('tilesContainer'); + this.resizer = document.getElementById('tileResizer'); + this.panes = [new ClaudePane(0, app), new ClaudePane(1, app)]; + this.splitPos = 50; // percentage + this.restoreFromStorage(); + this.bindUI(); + } + + bindUI() { + if (this.resizer) { + let dragging = false; + const onMove = (e) => { + if (!dragging) return; + const rect = this.grid.getBoundingClientRect(); + const x = e.clientX || (e.touches && e.touches[0]?.clientX); + const pct = Math.max(15, Math.min(85, ((x - rect.left) / rect.width) * 100)); + this.splitPos = pct; + this.applySplit(); + }; + this.resizer.addEventListener('mousedown', () => { dragging = true; document.body.style.userSelect = 'none'; }); + window.addEventListener('mouseup', () => { dragging = false; document.body.style.userSelect = ''; }); + window.addEventListener('mousemove', onMove); + this.resizer.addEventListener('touchstart', () => { dragging = true; }, { passive: true }); + window.addEventListener('touchend', () => { dragging = false; }, { passive: true }); + window.addEventListener('touchmove', onMove, { passive: false }); + } + document.querySelectorAll('.tile-close').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.index, 10); + this.clearPane(idx); + }); + }); + // Populate selects and handle change + this.refreshSessionSelects(); + document.querySelectorAll('.tile-session-select').forEach(sel => { + sel.addEventListener('change', (e) => { + const idx = parseInt(sel.dataset.index, 10); + const id = e.target.value || null; + this.assignSession(idx, id); + }); + }); + } + + enable() { + this.enabled = true; + document.getElementById('terminalContainer').style.display = 'none'; + this.container.style.display = 'flex'; + this.applySplit(); + // Default: left pane uses current active session + const active = this.app?.currentClaudeSessionId; + if (active) this.assignSession(0, active); + this.persist(); + } + disable() { + this.enabled = false; + this.container.style.display = 'none'; + document.getElementById('terminalContainer').style.display = ''; + this.persist(); + } + + applySplit() { + const a = Math.round(this.splitPos); + const b = 100 - a; + this.grid.style.gridTemplateColumns = `${a}% 6px ${b}%`; + this.panes.forEach(p => p.fit()); + } + + refreshSessionSelects() { + let sessions = (this.app?.claudeSessions) || []; + // Fallback to SessionTabManager if app.claudeSessions is not kept up to date + if ((!sessions || sessions.length === 0) && this.app?.sessionTabManager?.activeSessions) { + sessions = Array.from(this.app.sessionTabManager.activeSessions.values()).map(s => ({ id: s.id, name: s.name })); + } + document.querySelectorAll('.tile-session-select').forEach(sel => { + const current = sel.value; + sel.innerHTML = `Select session…` + sessions.map(s => `${s.name}`).join(''); + if (current) sel.value = current; + }); + } + + assignSession(index, sessionId) { + const sel = document.querySelector(`.tile-session-select[data-index="${index}"]`); + if (sel && sel.value !== sessionId) sel.value = sessionId || ''; + this.panes[index].setSession(sessionId); + this.persist(); + } + + clearPane(index) { + this.panes[index].setSession(null); + const sel = document.querySelector(`.tile-session-select[data-index="${index}"]`); + if (sel) sel.value = ''; + this.persist(); + } + + persist() { + try { + const state = { + enabled: this.enabled, + split: this.splitPos, + sessions: this.panes.map(p => p.sessionId) + }; + localStorage.setItem('cc-web-tiles', JSON.stringify(state)); + } catch (_) {} + } + + restoreFromStorage() { + try { + const raw = localStorage.getItem('cc-web-tiles'); + if (!raw) return; + const st = JSON.parse(raw); + if (typeof st.split === 'number') this.splitPos = st.split; + if (st.enabled) { + setTimeout(() => this.enable(), 0); + // sessions will be assigned after app loads sessions; do it lazily + setTimeout(() => { + (st.sessions || []).forEach((id, i) => id && this.assignSession(i, id)); + }, 500); + } + } catch (_) {} + } +} + +window.PaneManager = PaneManager; diff --git a/src/public/session-manager.js b/src/public/session-manager.js index 47b1deb..895afb4 100644 --- a/src/public/session-manager.js +++ b/src/public/session-manager.js @@ -195,6 +195,8 @@ class SessionTabManager { this.setupOverflowDropdown(); await this.loadSessions(); this.updateTabOverflow(); + // Update pane session pickers if present + if (window.app?.paneManager) window.app.paneManager.refreshSessionSelects(); // Show notification permission prompt after a slight delay setTimeout(() => { @@ -501,6 +503,9 @@ class SessionTabManager { console.log('[SessionManager.loadSessions] Final tabs.size:', this.tabs.size); + // Refresh pane selects on load/update + if (window.app?.paneManager) window.app.paneManager.refreshSessionSelects(); + return sessions; } catch (error) { console.error('Failed to load sessions:', error); diff --git a/src/public/style.css b/src/public/style.css index b3c99e7..4d9bc77 100644 --- a/src/public/style.css +++ b/src/public/style.css @@ -458,6 +458,14 @@ body { margin-left: 4px; } +.tab-actions { display: flex; gap: 6px; align-items: center; } +.tab-tile { + width: 28px; height: 28px; display:flex; align-items:center; justify-content:center; + background: transparent; border: 1px solid var(--border); border-radius: 4px; + color: var(--text-secondary); cursor: pointer; transition: all .2s; flex-shrink: 0; margin-left: 4px; +} +.tab-tile:hover { background: var(--bg-tertiary); color: var(--text-primary); } + .tab-new:hover { background-color: var(--bg-tertiary); color: var(--text-primary); @@ -906,6 +914,26 @@ body { overflow: hidden; } +/* Tiled panes */ +.tiles-container { flex: 1; display: flex; min-height: 0; } +.tile-grid { + display: grid; grid-template-columns: 1fr 6px 1fr; grid-template-rows: 100%; width: 100%; +} +.tile-pane { display:flex; flex-direction: column; min-width: 0; border-left: 1px solid var(--border); } +.tile-pane:first-child { border-left: none; } +.tile-toolbar { display:flex; align-items:center; gap: 8px; padding: 6px 8px; background: var(--bg-secondary); border-bottom:1px solid var(--border); } +.tile-toolbar .spacer { flex:1; } +.tile-session-select { background: var(--bg-tertiary); color: var(--text-primary); border:1px solid var(--border); border-radius:6px; padding:6px 8px; font-family: var(--font-mono); font-size:12px; } +.tile-close { background: transparent; border:1px solid var(--border); border-radius:4px; color: var(--text-secondary); width:26px; height:26px; display:flex; align-items:center; justify-content:center; cursor:pointer; } +.tile-close:hover { background: var(--bg-tertiary); color: var(--text-primary); } +.tile-terminal { flex:1; min-height:0; position: relative; } +.tile-terminal .xterm { height: 100%; } + +.resizer { + background: var(--border); cursor: col-resize; width: 6px; height: 100%; +} +.resizer:hover { background: var(--border-hover); } + #terminal { width: 100%; height: 100%;