Increase thumbnail refresh cadence

This commit is contained in:
GitHub Copilot
2026-02-20 13:26:19 +00:00
parent bb0bc77997
commit 0d8cf7d49c
2 changed files with 105 additions and 6 deletions
+47 -4
View File
@@ -28,7 +28,7 @@ const (
wsReadTimeout = 90 * time.Second wsReadTimeout = 90 * time.Second
wsPingPeriod = 30 * time.Second wsPingPeriod = 30 * time.Second
stdinWriteTimeout = 2 * time.Second stdinWriteTimeout = 2 * time.Second
screenshotCacheSeconds = 300 * time.Millisecond screenshotCacheSeconds = 250 * time.Millisecond
maxScreenshotCacheTTL = 20 * time.Second maxScreenshotCacheTTL = 20 * time.Second
screenshotEvictInterval = 60 * time.Second screenshotEvictInterval = 60 * time.Second
) )
@@ -305,7 +305,7 @@ func (s *LocalServer) markRouteActivity(routeKey string) {
s.mu.Lock() s.mu.Lock()
s.routeLastActivity[routeKey] = now s.routeLastActivity[routeKey] = now
last := s.routeLastSSE[routeKey] last := s.routeLastSSE[routeKey]
if now.Sub(last) < 500*time.Millisecond { if now.Sub(last) < 250*time.Millisecond {
s.mu.Unlock() s.mu.Unlock()
return 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 { 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:hover { border-color: #475569; }
.tile.selected { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.3); } .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-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; } .tile-title { display: flex; align-items: center; gap: 8px; }
.sparkline { opacity: 0.9; } .sparkline { opacity: 0.9; }
@@ -1154,6 +1155,8 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
const screenshotDownloadQuery = %q; const screenshotDownloadQuery = %q;
const screenshotDownloadExt = %q; const screenshotDownloadExt = %q;
let cardsBySlug = {}; let cardsBySlug = {};
const bellStoragePrefix = 'webterm:bell:';
const dashboardTitle = document.title;
let searchQuery = ''; let searchQuery = '';
let activeResultIndex = -1; let activeResultIndex = -1;
@@ -1169,6 +1172,41 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
const grid = document.getElementById('grid'); const grid = document.getElementById('grid');
const subtitle = document.getElementById('subtitle'); 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) { function downloadSanitizedScreenshot(slug) {
if (!slug) return; if (!slug) return;
const link = document.createElement('a'); const link = document.createElement('a');
@@ -1182,6 +1220,9 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
function makeTile(tile) { function makeTile(tile) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'tile'; card.className = 'tile';
if (hasBell(tile.slug)) {
card.classList.add('bell');
}
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'tile-header'; header.className = 'tile-header';
const titleSpan = document.createElement('div'); const titleSpan = document.createElement('div');
@@ -1330,6 +1371,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
function openTile(tile) { function openTile(tile) {
if (!tile || !tile.slug) return; if (!tile || !tile.slug) return;
clearBellState(tile.slug);
const url = '/?route_key=' + encodeURIComponent(tile.slug); const url = '/?route_key=' + encodeURIComponent(tile.slug);
const target = 'webterm-' + tile.slug; const target = 'webterm-' + tile.slug;
let win = window.open(url, target); let win = window.open(url, target);
@@ -1505,7 +1547,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
const pendingRefresh = {}; const pendingRefresh = {};
const lastRefresh = {}; const lastRefresh = {};
const REFRESH_DEBOUNCE_MS = 500; const REFRESH_DEBOUNCE_MS = 250;
function scheduleRefreshTile(slug) { function scheduleRefreshTile(slug) {
const now = Date.now(); const now = Date.now();
@@ -1567,6 +1609,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
refreshAll(); refreshAll();
renderFloatingResults(); renderFloatingResults();
refreshSparklines(); refreshSparklines();
updateDashboardTitle();
} }
let source = null; let source = null;
@@ -1647,7 +1690,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
fontFamily = "var(--webterm-mono)" fontFamily = "var(--webterm-mono)"
} }
escapedFont := strings.ReplaceAll(fontFamily, `"`, "&quot;") escapedFont := strings.ReplaceAll(fontFamily, `"`, "&quot;")
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 cacheBust := "?v=" + Version
page := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>%s</title><link rel="stylesheet" href="/static/monospace.css%s"><style>html,body{width:100%%;height:100%%}body{background:%s;margin:0;padding:0;overflow:hidden;font-family:var(--webterm-mono)}.webterm-terminal{width:100%%;height:100%%;display:block;overflow:hidden}</style></head><body><div id="terminal" class="webterm-terminal" %s></div><script type="module" src="/static/js/terminal.js%s"></script></body></html>`, htmlEscape(app.Name), cacheBust, themeBG, dataAttrs, cacheBust) page := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>%s</title><link rel="stylesheet" href="/static/monospace.css%s"><style>html,body{width:100%%;height:100%%}body{background:%s;margin:0;padding:0;overflow:hidden;font-family:var(--webterm-mono)}.webterm-terminal{width:100%%;height:100%%;display:block;overflow:hidden}</style></head><body><div id="terminal" class="webterm-terminal" %s></div><script type="module" src="/static/js/terminal.js%s"></script></body></html>`, htmlEscape(app.Name), cacheBust, themeBG, dataAttrs, cacheBust)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
+58 -2
View File
@@ -14,6 +14,7 @@ const MAX_MESSAGE_QUEUE_SIZE = 1000;
const RESOURCE_CLEANUP_INTERVAL_MS = 30_000; const RESOURCE_CLEANUP_INTERVAL_MS = 30_000;
/** Maximum bytes to buffer while the tab is hidden (256 KB) */ /** Maximum bytes to buffer while the tab is hidden (256 KB) */
const MAX_HIDDEN_BUFFER_BYTES = 256 * 1024; const MAX_HIDDEN_BUFFER_BYTES = 256 * 1024;
const BELL_EMOJI = "🔔";
/** Shared Ghostty WASM instance (loaded once, reused across all terminals) */ /** Shared Ghostty WASM instance (loaded once, reused across all terminals) */
let sharedGhostty: Ghostty | null = null; let sharedGhostty: Ghostty | null = null;
@@ -682,6 +683,9 @@ class WebTerminal {
private isTabHidden = false; private isTabHidden = false;
private hiddenBuffer: Uint8Array[] = []; private hiddenBuffer: Uint8Array[] = [];
private hiddenBufferBytes = 0; private hiddenBufferBytes = 0;
private baseTitle: string;
private bellActive = false;
private routeKey: string;
private static sharedTextEncoder = new TextEncoder(); private static sharedTextEncoder = new TextEncoder();
private constructor( private constructor(
@@ -690,7 +694,9 @@ class WebTerminal {
terminal: Terminal, terminal: Terminal,
fitAddon: FitAddon, fitAddon: FitAddon,
fontFamily: string, fontFamily: string,
fontSize: number fontSize: number,
routeKey: string,
baseTitle: string
) { ) {
this.element = container; this.element = container;
this.wsUrl = wsUrl; this.wsUrl = wsUrl;
@@ -698,6 +704,8 @@ class WebTerminal {
this.fitAddon = fitAddon; this.fitAddon = fitAddon;
this.fontFamily = fontFamily; this.fontFamily = fontFamily;
this.fontSize = fontSize; this.fontSize = fontSize;
this.routeKey = routeKey;
this.baseTitle = baseTitle;
} }
/** Register an event listener that will be removed on dispose */ /** Register an event listener that will be removed on dispose */
@@ -711,6 +719,36 @@ class WebTerminal {
this.boundHandlers.push({ target, type, handler, options }); 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 */ /** Create and initialize a WebTerminal instance */
static async create( static async create(
container: HTMLElement, container: HTMLElement,
@@ -742,13 +780,22 @@ class WebTerminal {
// Open terminal (initializes rendering - WASM already loaded) // Open terminal (initializes rendering - WASM already loaded)
terminal.open(container); 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( const instance = new WebTerminal(
container, container,
wsUrl, wsUrl,
terminal, terminal,
fitAddon, fitAddon,
fontFamily, fontFamily,
fontSize fontSize,
routeKey,
baseTitle
); );
instance.initialize(); instance.initialize();
return instance; return instance;
@@ -780,9 +827,14 @@ class WebTerminal {
// Handle terminal input // Handle terminal input
this.terminal.onData((data) => { this.terminal.onData((data) => {
this.clearBellState();
this.send(["stdin", data]); this.send(["stdin", data]);
}); });
this.terminal.onBell(() => {
this.setBellActive();
});
// Handle resize // Handle resize
this.terminal.onResize((size) => { this.terminal.onResize((size) => {
if (this.isValidSize(size.cols, size.rows)) { if (this.isValidSize(size.cols, size.rows)) {
@@ -807,8 +859,12 @@ class WebTerminal {
// Connect WebSocket // Connect WebSocket
this.connect(); this.connect();
if (document.hasFocus()) {
this.clearBellState();
}
const restoreFocus = () => { const restoreFocus = () => {
this.clearBellState();
if (isMobileDevice()) { if (isMobileDevice()) {
this.focusMobileInput(); this.focusMobileInput();
} else { } else {