diff --git a/webterm/server.go b/webterm/server.go index 50c4e8b..bd6e2ac 100644 --- a/webterm/server.go +++ b/webterm/server.go @@ -28,7 +28,7 @@ const ( wsReadTimeout = 90 * time.Second wsPingPeriod = 30 * time.Second stdinWriteTimeout = 2 * time.Second - screenshotCacheSeconds = 300 * time.Millisecond + screenshotCacheSeconds = 250 * time.Millisecond maxScreenshotCacheTTL = 20 * time.Second screenshotEvictInterval = 60 * time.Second ) @@ -305,7 +305,7 @@ func (s *LocalServer) markRouteActivity(routeKey string) { s.mu.Lock() s.routeLastActivity[routeKey] = now last := s.routeLastSSE[routeKey] - if now.Sub(last) < 500*time.Millisecond { + if now.Sub(last) < 250*time.Millisecond { s.mu.Unlock() return } @@ -1112,6 +1112,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { .tile { background: #1e293b; border: 1px solid #334155; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 6px rgba(0,0,0,0.4); cursor: pointer; transition: border-color 0.15s; } .tile:hover { border-color: #475569; } .tile.selected { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.3); } + .tile.bell { border-color: #f59e0b; box-shadow: 0 0 0 2px rgba(245,158,11,0.35); } .tile-header { padding: 10px 12px; font-weight: bold; border-bottom: 1px solid #334155; display: flex; align-items: center; justify-content: space-between; } .tile-title { display: flex; align-items: center; gap: 8px; } .sparkline { opacity: 0.9; } @@ -1154,6 +1155,8 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { const screenshotDownloadQuery = %q; const screenshotDownloadExt = %q; let cardsBySlug = {}; + const bellStoragePrefix = 'webterm:bell:'; + const dashboardTitle = document.title; let searchQuery = ''; let activeResultIndex = -1; @@ -1169,6 +1172,41 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { const grid = document.getElementById('grid'); const subtitle = document.getElementById('subtitle'); + function bellStorageKey(slug) { + return bellStoragePrefix + slug; + } + + function hasBell(slug) { + if (!slug) return false; + return Boolean(localStorage.getItem(bellStorageKey(slug))); + } + + function updateDashboardTitle() { + const anyBell = tiles.some((tile) => tile && tile.slug && hasBell(tile.slug)); + document.title = anyBell ? '🔔 ' + dashboardTitle : dashboardTitle; + } + + function applyBellState(slug) { + if (!slug) return; + const card = cardsBySlug[slug]; + if (card) { + card.classList.toggle('bell', hasBell(slug)); + } + updateDashboardTitle(); + } + + function clearBellState(slug) { + if (!slug) return; + localStorage.removeItem(bellStorageKey(slug)); + applyBellState(slug); + } + + window.addEventListener('storage', (event) => { + if (!event.key || !event.key.startsWith(bellStoragePrefix)) return; + const slug = event.key.slice(bellStoragePrefix.length); + applyBellState(slug); + }); + function downloadSanitizedScreenshot(slug) { if (!slug) return; const link = document.createElement('a'); @@ -1182,6 +1220,9 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { function makeTile(tile) { const card = document.createElement('div'); card.className = 'tile'; + if (hasBell(tile.slug)) { + card.classList.add('bell'); + } const header = document.createElement('div'); header.className = 'tile-header'; const titleSpan = document.createElement('div'); @@ -1330,6 +1371,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { function openTile(tile) { if (!tile || !tile.slug) return; + clearBellState(tile.slug); const url = '/?route_key=' + encodeURIComponent(tile.slug); const target = 'webterm-' + tile.slug; let win = window.open(url, target); @@ -1505,7 +1547,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { const pendingRefresh = {}; const lastRefresh = {}; - const REFRESH_DEBOUNCE_MS = 500; + const REFRESH_DEBOUNCE_MS = 250; function scheduleRefreshTile(slug) { const now = Date.now(); @@ -1567,6 +1609,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { refreshAll(); renderFloatingResults(); refreshSparklines(); + updateDashboardTitle(); } let source = null; @@ -1647,7 +1690,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { fontFamily = "var(--webterm-mono)" } escapedFont := strings.ReplaceAll(fontFamily, `"`, """) - dataAttrs := fmt.Sprintf(`data-session-websocket-url="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s"`, htmlAttrEscape(wsURL), s.fontSize, htmlAttrEscape(theme), escapedFont) + dataAttrs := fmt.Sprintf(`data-session-websocket-url="%s" data-session-route-key="%s" data-session-name="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s"`, htmlAttrEscape(wsURL), htmlAttrEscape(routeKey), htmlAttrEscape(app.Name), s.fontSize, htmlAttrEscape(theme), escapedFont) cacheBust := "?v=" + Version page := fmt.Sprintf(`%s
`, htmlEscape(app.Name), cacheBust, themeBG, dataAttrs, cacheBust) w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/webterm/static/js/terminal.ts b/webterm/static/js/terminal.ts index ffbb712..277850f 100644 --- a/webterm/static/js/terminal.ts +++ b/webterm/static/js/terminal.ts @@ -14,6 +14,7 @@ const MAX_MESSAGE_QUEUE_SIZE = 1000; const RESOURCE_CLEANUP_INTERVAL_MS = 30_000; /** Maximum bytes to buffer while the tab is hidden (256 KB) */ const MAX_HIDDEN_BUFFER_BYTES = 256 * 1024; +const BELL_EMOJI = "🔔"; /** Shared Ghostty WASM instance (loaded once, reused across all terminals) */ let sharedGhostty: Ghostty | null = null; @@ -682,6 +683,9 @@ class WebTerminal { private isTabHidden = false; private hiddenBuffer: Uint8Array[] = []; private hiddenBufferBytes = 0; + private baseTitle: string; + private bellActive = false; + private routeKey: string; private static sharedTextEncoder = new TextEncoder(); private constructor( @@ -690,7 +694,9 @@ class WebTerminal { terminal: Terminal, fitAddon: FitAddon, fontFamily: string, - fontSize: number + fontSize: number, + routeKey: string, + baseTitle: string ) { this.element = container; this.wsUrl = wsUrl; @@ -698,6 +704,8 @@ class WebTerminal { this.fitAddon = fitAddon; this.fontFamily = fontFamily; this.fontSize = fontSize; + this.routeKey = routeKey; + this.baseTitle = baseTitle; } /** Register an event listener that will be removed on dispose */ @@ -711,6 +719,36 @@ class WebTerminal { this.boundHandlers.push({ target, type, handler, options }); } + private bellStorageKey(): string | null { + if (!this.routeKey) { + return null; + } + return `webterm:bell:${this.routeKey}`; + } + + private setBellActive(): void { + if (!this.bellActive) { + this.bellActive = true; + document.title = `${BELL_EMOJI} ${this.baseTitle}`; + } + const key = this.bellStorageKey(); + if (key) { + localStorage.setItem(key, String(Date.now())); + } + } + + private clearBellState(): void { + const key = this.bellStorageKey(); + if (key) { + localStorage.removeItem(key); + } + if (!this.bellActive) { + return; + } + this.bellActive = false; + document.title = this.baseTitle; + } + /** Create and initialize a WebTerminal instance */ static async create( container: HTMLElement, @@ -742,13 +780,22 @@ class WebTerminal { // Open terminal (initializes rendering - WASM already loaded) terminal.open(container); + const routeKey = container.dataset.sessionRouteKey + ?? new URLSearchParams(window.location.search).get("route_key") + ?? ""; + const rawTitle = container.dataset.sessionName?.trim() || document.title; + const baseTitle = rawTitle.startsWith(`${BELL_EMOJI} `) + ? rawTitle.slice(BELL_EMOJI.length + 1) + : rawTitle; const instance = new WebTerminal( container, wsUrl, terminal, fitAddon, fontFamily, - fontSize + fontSize, + routeKey, + baseTitle ); instance.initialize(); return instance; @@ -780,9 +827,14 @@ class WebTerminal { // Handle terminal input this.terminal.onData((data) => { + this.clearBellState(); this.send(["stdin", data]); }); + this.terminal.onBell(() => { + this.setBellActive(); + }); + // Handle resize this.terminal.onResize((size) => { if (this.isValidSize(size.cols, size.rows)) { @@ -807,8 +859,12 @@ class WebTerminal { // Connect WebSocket this.connect(); + if (document.hasFocus()) { + this.clearBellState(); + } const restoreFocus = () => { + this.clearBellState(); if (isMobileDevice()) { this.focusMobileInput(); } else {