Files
web-terminal/public/main.ts
Yaojia Wang 1dd12b035a feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
Four small, high-delight features that turn passive capture into glanceable signals.

- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
  into the existing concurrent per-repo metadata pass (git rev-list --count
  --left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
  (Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
  on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
  frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
  /config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
  totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
  GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.

All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
2026-07-12 21:27:20 +02:00

135 lines
4.9 KiB
TypeScript

/**
* public/main.ts — esbuild entry point for the browser frontend.
*
* Bootstraps the multi-tab app: a tab bar (#tabs) + a utility toolbar (#toolbar)
* over a stack of terminal panes (#term), each tab an independent TerminalSession.
* The mobile key bar routes input to the active tab.
*
* Per-session terminal/WS/reconnect logic lives in terminal-session.ts;
* tab management + persistence in tabs.ts.
*/
// CSS side-effect import (esbuild → public/build/main.css; typed via css.d.ts).
import '@xterm/xterm/css/xterm.css'
import { mountKeybar } from './keybar.js'
import { TabApp } from './tabs.js'
import { mountSearch } from './search.js'
import { mountQrConnect } from './qr.js'
import { mountSettings, loadSettings, type Settings } from './settings.js'
import { mountDashboard } from './dashboard.js'
import { mountHistory } from './history.js'
import { mountShortcuts } from './shortcuts.js'
import { mountShareSession } from './share.js'
import { mountGridToggle, matchFocusCycleKey } from './grid-layout.js'
import { mountGridPresets } from './grid-presets.js'
import { mountDigest } from './digest.js'
const paneHost = document.getElementById('term')
const tabs = document.getElementById('tabs')
const toolbar = document.getElementById('toolbar')
if (!paneHost) throw new Error('#term element not found in DOM')
if (!tabs) throw new Error('#tabs element not found in DOM')
if (!toolbar) throw new Error('#toolbar element not found in DOM')
const app = new TabApp(paneHost, tabs)
// v0.4: a share link (?join=<id>) opens/focuses that shared session, then we
// strip the param so a refresh doesn't keep re-opening it.
const joinId = new URLSearchParams(location.search).get('join')
if (joinId) {
app.openSession(joinId)
history.replaceState(null, '', location.pathname)
}
// Apply saved theme/font (M3) to the restored tabs.
let settings: Settings = loadSettings()
app.applySettings(settings)
// Key bar (mobile + desktop) sends to whichever tab is active. onVoiceTrigger
// activates the full voice path (dictation + context-gated confirm/reject, VC)
// and un-dormants the 🎤 button (createVoiceInput / matchCommand are otherwise
// unreachable dead code without this wiring).
mountKeybar((data) => app.sendToActive(data), {
onVoiceTrigger: (action) => app.handleVoiceTrigger(action),
})
// Multi-device: when this device regains focus, re-assert the size of every
// VISIBLE pane so a shared session snaps to THIS screen (latest-writer-wins) —
// full-screen on whichever device you're using (all quadrants in a split grid).
window.addEventListener('focus', () => app.refitVisible())
document.addEventListener('visibilitychange', () => {
if (!document.hidden) app.refitVisible()
})
// v2: Ctrl+` cycles the focused quadrant in a split grid (Ctrl+Shift+` reverses).
// Capture phase so xterm doesn't swallow it; left untouched (no preventDefault)
// in single mode so the terminal keeps the keystroke.
document.addEventListener(
'keydown',
(e) => {
const dir = matchFocusCycleKey(e)
if (dir === null) return
if (app.getGridLayout() === 'single') return // let the terminal keep the key
e.preventDefault()
app.cycleFocus(dir)
},
{ capture: true },
)
// Toolbar utilities.
mountSearch(toolbar, {
find: (query, dir) => app.findInActive(query, dir),
clear: () => app.clearActiveSearch(),
})
mountSettings(
toolbar,
() => settings,
(s) => {
settings = s
app.applySettings(s)
},
)
mountDashboard(toolbar, {
snapshot: () => app.snapshot(),
focus: (idx) => app.focusTab(idx),
})
mountHistory(toolbar, {
resume: (cwd, id) => app.newTabForResume(cwd, id),
})
mountShortcuts(toolbar)
mountShareSession(toolbar, () => app.activeSessionId())
// Split-grid layout toggle (desktop-only; hidden below GRID_MIN_WIDTH). The
// control drives app.setGridLayout and is registered back so a programmatic
// layout change (e.g. auto-fallback on window narrow) re-syncs its pressed state.
const gridToggle = mountGridToggle(toolbar, {
getLayout: () => app.getGridLayout(),
setLayout: (layout) => app.setGridLayout(layout),
})
app.setGridToggle(gridToggle)
// Saved layout presets (desktop-only, next to the layout toggle).
mountGridPresets(toolbar, {
current: () => app.gridArrangement(),
apply: (preset) => app.applyGridPreset(preset),
})
// Session management lives on the home Sessions chooser now (open / kill /
// new), so the standalone /manage.html page was removed.
mountQrConnect(toolbar)
// W3(c): "while you were away" reconnect digest — one compact dismissible banner
// summarising what finished / needs input / got stuck since this device's last
// visit. Best-effort (no banner on fetch failure); advances its own last-seen.
void mountDigest(document.body)
// PWA: register the service worker (installable + offline shell, M4).
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
void navigator.serviceWorker.register('./sw.js').catch(() => {
// SW registration is best-effort; the app works without it.
})
})
}