feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build

Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
Yaojia Wang
2026-06-30 17:42:18 +02:00
parent 4f1d3ebc6b
commit d6809c65c4
54 changed files with 13171 additions and 200 deletions

347
public/diff.ts Normal file
View File

@@ -0,0 +1,347 @@
/**
* public/diff.ts — N-diff-ui: diff viewer (render-only).
*
* This module is RENDER-ONLY: it receives pre-parsed DiffResult/DiffFile/DiffLine
* structures from the server (GET /projects/diff) and renders them as DOM elements.
* Zero diff parsing lives here (parsing is in src/http/diff.ts, review #2).
*
* Security: SEC-H4 — ALL diff content is set via textContent / el(). Zero innerHTML
* anywhere in this file. <script>, & and ANSI escape sequences appear as plain text.
*/
import type { DiffResult, DiffFile, DiffHunk, DiffLine } from '../src/types.js'
/* ── constants ───────────────────────────────────────────────────────────────── */
const EMPTY_MESSAGE = 'No changes'
/* ── DOM helper ──────────────────────────────────────────────────────────────── */
/** Create an element with an optional CSS class and text content. */
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text
return node
}
/* ── normalizeDiffResult ─────────────────────────────────────────────────────── */
/**
* Coerce an untrusted API response into a DiffResult, or return null.
* Never throws. Filters out any file entries that are not valid objects.
*/
export function normalizeDiffResult(raw: unknown): DiffResult | null {
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (!Array.isArray(o['files'])) return null
if (typeof o['staged'] !== 'boolean') return null
if (typeof o['truncated'] !== 'boolean') return null
const files = (o['files'] as unknown[])
.map(normalizeFile)
.filter((f): f is DiffFile => f !== null)
return { files, staged: o['staged'], truncated: o['truncated'] }
}
function normalizeFile(raw: unknown): DiffFile | null {
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (typeof o['oldPath'] !== 'string') return null
if (typeof o['newPath'] !== 'string') return null
if (typeof o['status'] !== 'string') return null
if (typeof o['added'] !== 'number') return null
if (typeof o['removed'] !== 'number') return null
if (typeof o['binary'] !== 'boolean') return null
if (!Array.isArray(o['hunks'])) return null
const hunks = (o['hunks'] as unknown[])
.map(normalizeHunk)
.filter((h): h is DiffHunk => h !== null)
return {
oldPath: o['oldPath'],
newPath: o['newPath'],
status: o['status'] as DiffFile['status'],
added: o['added'],
removed: o['removed'],
binary: o['binary'],
hunks,
}
}
function normalizeHunk(raw: unknown): DiffHunk | null {
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (typeof o['header'] !== 'string') return null
if (!Array.isArray(o['lines'])) return null
const lines = (o['lines'] as unknown[])
.map(normalizeLine)
.filter((l): l is DiffLine => l !== null)
return { header: o['header'], lines }
}
function normalizeLine(raw: unknown): DiffLine | null {
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (typeof o['kind'] !== 'string') return null
if (typeof o['text'] !== 'string') return null
return { kind: o['kind'] as DiffLine['kind'], text: o['text'] }
}
/* ── fetchDiff ───────────────────────────────────────────────────────────────── */
/**
* Fetch a diff from the server for the given repo path.
* Returns null on any error or invalid response (best-effort).
*/
export async function fetchDiff(repoPath: string, staged: boolean): Promise<DiffResult | null> {
try {
const url = `/projects/diff?path=${encodeURIComponent(repoPath)}&staged=${staged}`
const res = await fetch(url)
if (!res.ok) return null
const data: unknown = await res.json()
return normalizeDiffResult(data)
} catch {
return null
}
}
/* ── renderDiffFile ──────────────────────────────────────────────────────────── */
/** CSS class prefix for diff line kinds. */
const LINE_KIND_CLASS: Record<DiffLine['kind'], string> = {
added: 'df-added',
removed: 'df-removed',
context: 'df-context',
hunk: 'df-hunk',
meta: 'df-meta',
}
/**
* Render a single DiffFile into an HTMLElement.
*
* Security: ALL text content is set via textContent — zero innerHTML.
* <script>, ANSI sequences, & etc. are rendered as literal characters.
*/
export function renderDiffFile(file: DiffFile): HTMLElement {
const section = el('div', 'df-file')
// ── file header ──────────────────────────────────────────────────────────
const header = el('div', 'df-file-header')
const pathEl = el('span', 'df-path')
if (file.status === 'renamed' && file.oldPath !== file.newPath) {
pathEl.textContent = `${file.oldPath}${file.newPath}`
} else {
pathEl.textContent = file.newPath
}
const statsEl = el('span', 'df-stats')
const addedEl = el('span', 'df-stat-added', `+${file.added}`)
const removedEl = el('span', 'df-stat-removed', `-${file.removed}`)
statsEl.append(addedEl, removedEl)
const statusEl = el('span', `df-status df-status-${file.status}`, file.status)
header.append(pathEl, statsEl, statusEl)
section.append(header)
// ── binary indicator ─────────────────────────────────────────────────────
if (file.binary) {
section.append(el('div', 'df-binary', 'Binary file'))
return section
}
// ── hunks ─────────────────────────────────────────────────────────────────
for (const hunk of file.hunks) {
section.append(renderHunk(hunk))
}
return section
}
function renderHunk(hunk: DiffHunk): HTMLElement {
const hunkEl = el('div', 'df-hunk-block')
// Hunk header (e.g. "@@ -1,3 +1,4 @@")
hunkEl.append(el('div', 'df-hunk-header', hunk.header))
const linesEl = el('div', 'df-lines')
for (const line of hunk.lines) {
linesEl.append(renderLine(line))
}
hunkEl.append(linesEl)
return hunkEl
}
function renderLine(line: DiffLine): HTMLElement {
const cls = LINE_KIND_CLASS[line.kind] ?? 'df-context'
return el('div', `df-line ${cls}`, line.text)
}
/* ── renderDiff ──────────────────────────────────────────────────────────────── */
/**
* Render a full DiffResult: all files grouped, with empty state and truncated warning.
*
* Security: ALL content via textContent — zero innerHTML.
*/
export function renderDiff(result: DiffResult): HTMLElement {
const container = el('div', 'df-result')
// Truncated warning
if (result.truncated) {
container.append(
el('div', 'df-truncated-warning', 'Result truncated — diff is too large to display fully.'),
)
}
// Empty state
if (result.files.length === 0) {
container.append(el('div', 'df-empty', EMPTY_MESSAGE))
return container
}
for (const file of result.files) {
container.append(renderDiffFile(file))
}
return container
}
/* ── mountDiffViewer ─────────────────────────────────────────────────────────── */
/** Handle returned by mountDiffViewer for programmatic control. */
export interface DiffViewerHandle {
/** Switch to the working-tree (unstaged) diff. */
showWorking(): void
/** Switch to the staged diff. */
showStaged(): void
/** Programmatically trigger the onClose callback. */
close(): void
/** Remove all DOM content from the container (cleanup). */
destroy(): void
}
export interface MountDiffViewerOpts {
/** Called when the viewer's close button or close() is invoked. */
onClose?: () => void
}
/**
* Mount a diff viewer into `container`.
*
* Renders a toolbar (Working / Staged toggle + Close), then fetches and renders
* the diff below it. The toggle re-fetches when switched.
*
* Security: SEC-H4 — all content via textContent/el(). Zero innerHTML.
*/
export function mountDiffViewer(
container: HTMLElement,
repoPath: string,
opts: MountDiffViewerOpts,
): DiffViewerHandle {
let destroyed = false
let staged = false
// ── skeleton ──────────────────────────────────────────────────────────────
const root = el('div', 'df-viewer')
// Toolbar
const toolbar = el('div', 'df-toolbar')
const workingBtn = el('button', 'df-tab df-tab-active', 'Working')
const stagedBtn = el('button', 'df-tab', 'Staged')
const closeBtn = el('button', 'df-close', '✕ Close')
toolbar.append(workingBtn, stagedBtn, closeBtn)
root.append(toolbar)
// Content area
const content = el('div', 'df-content')
root.append(content)
container.append(root)
// ── event handlers ────────────────────────────────────────────────────────
workingBtn.addEventListener('click', () => {
if (!staged) return
staged = false
workingBtn.className = 'df-tab df-tab-active'
stagedBtn.className = 'df-tab'
void loadDiff()
})
stagedBtn.addEventListener('click', () => {
if (staged) return
staged = true
stagedBtn.className = 'df-tab df-tab-active'
workingBtn.className = 'df-tab'
void loadDiff()
})
closeBtn.addEventListener('click', () => {
opts.onClose?.()
})
// ── fetch + render ────────────────────────────────────────────────────────
async function loadDiff(): Promise<void> {
if (destroyed) return
content.textContent = 'Loading…'
const result = await fetchDiff(repoPath, staged)
if (destroyed) return
content.textContent = ''
if (result === null) {
content.append(el('div', 'df-error', 'Failed to load diff.'))
} else {
content.append(renderDiff(result))
}
}
void loadDiff()
// ── handle ────────────────────────────────────────────────────────────────
return {
showWorking() {
if (staged) {
staged = false
workingBtn.className = 'df-tab df-tab-active'
stagedBtn.className = 'df-tab'
void loadDiff()
}
},
showStaged() {
if (!staged) {
staged = true
stagedBtn.className = 'df-tab df-tab-active'
workingBtn.className = 'df-tab'
void loadDiff()
}
},
close() {
opts.onClose?.()
},
destroy() {
destroyed = true
container.textContent = ''
},
}
}

View File

@@ -22,9 +22,10 @@
* Tab \t complete / toggle
* ← → \x1b[D/C cursor
* / / slash-command launcher
* 🎤 (voice) push-to-talk mic (A2; only when SpeechRecognition supported)
*/
import type { MountKeybar } from '../src/types.js'
import { isSpeechSupported } from './voice.js'
/** Key name → byte string mapping (pure data, for testing). */
export const KEY_MAP = {
@@ -78,8 +79,32 @@ export const KEYBAR_BUTTONS: KeybarButton[] = [
{ label: '/', caption: '命令', keyName: 'slash', title: 'Slash — command launcher' },
]
/** Mount the key bar: create buttons in #keybar, bind touchstart → onSend(bytes) + preventDefault. */
export const mountKeybar: MountKeybar = (onSend) => {
/**
* Options for mountKeybar (A2 voice extension).
*
* All fields are optional so existing callers that pass only `onSend`
* continue to work unchanged.
*/
export interface KeybarOpts {
/**
* Push-to-talk callback. Called with 'start' on touchstart/mousedown and
* 'stop' on touchend/mouseup. The 🎤 button is only rendered when speech
* recognition is supported by the browser AND this callback is supplied.
*
* SEC-L2: Chrome Web Speech forwards audio to Google's servers — the button
* title discloses this so the user can make an informed choice.
*/
onVoiceTrigger?: (action: 'start' | 'stop') => void
}
/**
* Mount the key bar: create buttons in #keybar, bind touchstart → onSend(bytes) +
* preventDefault (keeps the soft keyboard hidden on mobile).
*
* A2: when SpeechRecognition is supported and opts.onVoiceTrigger is provided,
* appends a 🎤 push-to-talk button at the end of the bar.
*/
export function mountKeybar(onSend: (data: string) => void, opts?: KeybarOpts): void {
const keybarEl = document.getElementById('keybar')
if (!keybarEl) return
@@ -115,4 +140,50 @@ export const mountKeybar: MountKeybar = (onSend) => {
keybarEl.appendChild(btn)
}
// A2: voice mic button — only when speech is supported and caller provides a trigger.
if (isSpeechSupported() && opts?.onVoiceTrigger) {
keybarEl.appendChild(buildMicButton(opts.onVoiceTrigger))
}
}
/**
* Build the 🎤 push-to-talk button element.
* SEC-L2: title discloses that Chrome sends audio to Google.
*/
function buildMicButton(onVoiceTrigger: (action: 'start' | 'stop') => void): HTMLButtonElement {
const btn = document.createElement('button')
btn.classList.add('keybar-btn')
btn.dataset.key = 'voice'
btn.title = 'Hold to dictate — audio processed by browser speech engine (Chrome: Google)'
btn.setAttribute('aria-label', 'Push to talk')
const keyEl = document.createElement('span')
keyEl.className = 'kb-key'
keyEl.textContent = '🎤'
const capEl = document.createElement('span')
capEl.className = 'kb-cap'
capEl.textContent = '语音'
btn.append(keyEl, capEl)
// push-to-talk: start on press, stop on release — preventDefault keeps soft keyboard hidden.
btn.addEventListener('touchstart', (e) => {
e.preventDefault()
onVoiceTrigger('start')
})
btn.addEventListener('touchend', (e) => {
e.preventDefault()
onVoiceTrigger('stop')
})
// Desktop fallback: mousedown/mouseup for push-to-talk semantics.
btn.addEventListener('mousedown', (e) => {
e.preventDefault()
onVoiceTrigger('start')
})
btn.addEventListener('mouseup', (e) => {
e.preventDefault()
onVoiceTrigger('stop')
})
return btn
}

View File

@@ -9,7 +9,7 @@
*/
import { Terminal } from '@xterm/xterm'
import type { LiveSessionInfo } from '../src/types.js'
import type { LiveSessionInfo, StatusTelemetry, ClaudeStatus } from '../src/types.js'
/** Shape of GET /live-sessions/:id/preview. */
export interface SessionPreview {
@@ -44,9 +44,13 @@ export function relTime(ms: number): string {
return `${Math.floor(s / 86400)}d`
}
/** Human label for a Claude activity status. */
/** Human label for a Claude activity status. Supports all ClaudeStatus values including 'stuck'. */
export function statusText(s: LiveSessionInfo['status']): string {
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·'
if (s === 'working') return '⚙ working'
if (s === 'waiting') return '⏳ waiting'
if (s === 'idle') return '✓ idle'
if (s === 'stuck') return '⚠ stuck'
return '·'
}
/** Display name for a session: last cwd segment, else the short id. */
@@ -178,3 +182,86 @@ export async function fetchLiveSessions(): Promise<LiveSessionInfo[]> {
return []
}
}
/**
* Render a telemetry gauge into `container` (clears first).
*
* Shows: context-usage bar (>80% = warning colour), $cost chip, model chip,
* and a PR badge. When `telemetry.at` is older than `staleTtlMs` the container
* receives the class `tg-stale` so CSS can grey it out.
*
* Security: all telemetry strings are set via `textContent` (SEC-H5); the PR
* link href is only set when `url.protocol === 'https:'` (SEC-L5).
* Zero `innerHTML` anywhere.
*/
export function renderTelemetryGauge(
container: HTMLElement,
telemetry: StatusTelemetry | null,
staleTtlMs: number,
): void {
// Clear existing children
while (container.firstChild) container.removeChild(container.firstChild)
if (!telemetry) {
container.classList.remove('tg-stale')
return
}
const isStale = Date.now() - telemetry.at > staleTtlMs
if (isStale) container.classList.add('tg-stale')
else container.classList.remove('tg-stale')
// Context-usage bar
if (telemetry.contextUsedPct !== undefined) {
const bar = el('div', 'tg-ctx-bar')
const fill = el('div', 'tg-ctx-fill')
fill.style.width = `${Math.min(100, telemetry.contextUsedPct)}%`
if (telemetry.contextUsedPct > 80) fill.classList.add('tg-ctx-warn')
bar.append(fill, el('span', 'tg-ctx-label', `ctx ${Math.round(telemetry.contextUsedPct)}%`))
container.append(bar)
}
// Cost chip
if (telemetry.costUsd !== undefined) {
container.append(el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`))
}
// Model chip
if (telemetry.model !== undefined) {
container.append(el('span', 'tg-model', telemetry.model))
}
// PR badge
if (telemetry.pr !== undefined) {
const badge = el('span', 'tg-pr')
const link = el('a', 'tg-pr-link')
link.textContent = `PR #${telemetry.pr.number}`
// SEC-L5: only set href for https URLs
try {
const parsed = new URL(telemetry.pr.url)
if (parsed.protocol === 'https:') {
link.href = telemetry.pr.url
link.target = '_blank'
link.rel = 'noopener noreferrer'
}
} catch {
// Invalid URL — leave href unset
}
badge.append(link)
if (telemetry.pr.reviewState !== undefined) {
badge.append(el('span', 'tg-pr-state', telemetry.pr.reviewState))
}
container.append(badge)
}
}
/**
* Render a Claude status badge into `container` (clears first).
* 'stuck' shows the ⚠ warning symbol (A5). Uses only textContent (SEC-H5).
*/
export function renderStatusBadge(container: HTMLElement, status: ClaudeStatus): void {
while (container.firstChild) container.removeChild(container.firstChild)
container.append(el('span', `sb-badge sb-${status}`, statusText(status)))
}

View File

@@ -19,9 +19,12 @@ import type {
ClaudeStatus,
ProjectDetail,
WorktreeInfo,
CreateWorktreeResult,
} from '../src/types.js'
import { el, relTime, statusText } from './preview-grid.js'
import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js'
import { mountDiffViewer, type DiffViewerHandle } from './diff.js'
import { mountTimeline, type TimelineHandle } from './timeline.js'
/* ── Constants ───────────────────────────────────────────────────────────── */
@@ -405,21 +408,166 @@ function makeDetailSessionRow(
return row
}
/** Returns null if `branch` is a valid git branch name, or an error message (B3). Never throws. */
export function validateBranchNameClient(branch: string): string | null {
if (!branch) return 'Branch name cannot be empty'
if (branch.length > 250) return 'Branch name is too long (max 250 characters)'
if (/[\x00-\x20\x7f]/.test(branch)) return 'Branch name cannot contain spaces or control characters'
if (branch.startsWith('-')) return 'Branch name cannot start with a hyphen'
if (branch.includes('..')) return 'Branch name cannot contain ".."'
if (branch.endsWith('.lock')) return 'Branch name cannot end with ".lock"'
if (/[~^:?*\[\\]/.test(branch)) return 'Branch name contains invalid characters'
if (branch.includes('@{')) return 'Branch name cannot contain "@{"'
if (branch.startsWith('/') || branch.endsWith('/') || branch.includes('//')) {
return 'Branch name has invalid slash usage'
}
return null
}
function isWorktreeResult(v: unknown): v is CreateWorktreeResult {
return v !== null && typeof v === 'object' && typeof (v as Record<string, unknown>)['ok'] === 'boolean'
}
/** Render a "New Worktree" form (B3). Errors via textContent only (SEC-L3/H6). */
export function renderNewWorktreeForm(detail: ProjectDetail, hooks: ProjectsHooks): HTMLElement {
const form = el('div', 'proj-wt-form')
const input = document.createElement('input')
input.type = 'text'
input.className = 'proj-wt-branch-input'
input.placeholder = 'New branch name'
input.setAttribute('aria-label', 'New branch name')
input.maxLength = 250
const errorEl = el('div', 'proj-wt-error')
errorEl.style.display = 'none'
const submitBtn = el('button', 'proj-wt-submit', 'Create Worktree')
form.append(input, errorEl, submitBtn)
function showError(msg: string): void {
errorEl.textContent = msg // SEC-L3/H6: textContent, never innerHTML
errorEl.style.display = ''
submitBtn.disabled = false
}
function hideError(): void { errorEl.textContent = ''; errorEl.style.display = 'none' }
let busy = false
async function doCreate(): Promise<void> {
if (busy) return
const branch = input.value.trim()
const err = validateBranchNameClient(branch)
if (err !== null) { showError(err); return }
hideError()
busy = true
submitBtn.disabled = true
try {
const res = await fetch('/projects/worktree', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ repoPath: detail.path, branch }),
})
let data: unknown
try { data = await res.json() } catch { data = null }
if (!res.ok || !isWorktreeResult(data) || !data.ok) {
showError(isWorktreeResult(data) && typeof data.error === 'string'
? data.error : 'Failed to create worktree')
return
}
hooks.onOpenProject(data.path ?? detail.path, data.branch ?? branch, 'claude\r')
} catch {
showError('Failed to create worktree')
} finally {
busy = false
submitBtn.disabled = false
}
}
submitBtn.addEventListener('click', () => { void doCreate() })
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') void doCreate() })
input.addEventListener('input', () => { if (errorEl.style.display !== 'none') hideError() })
return form
}
/** Build the "View Diff" toggle + inline panel (B1). `diffRef.h` tracks the open handle. */
function buildDiffSection(
repoPath: string,
diffRef: { h: DiffViewerHandle | null },
): HTMLElement {
const section = el('div', 'proj-diff-section')
const toggle = el('button', 'proj-diff-toggle', 'View Diff')
const panel = el('div', 'proj-diff-panel')
panel.style.display = 'none'
toggle.addEventListener('click', () => {
if (panel.style.display === 'none') {
panel.style.display = ''
toggle.textContent = 'Hide Diff'
diffRef.h = mountDiffViewer(panel, repoPath, {
onClose: () => {
panel.style.display = 'none'
toggle.textContent = 'View Diff'
diffRef.h?.destroy()
diffRef.h = null
},
})
} else {
diffRef.h?.destroy()
diffRef.h = null
panel.style.display = 'none'
toggle.textContent = 'View Diff'
}
})
section.append(toggle, panel)
return section
}
/** Build the "Activity" section: one timeline panel per running session (A4). */
function buildActivitySection(
running: ProjectSessionRef[],
localTimelines: TimelineHandle[],
collector: TimelineHandle[] | undefined,
): HTMLElement | null {
if (running.length === 0) return null
const section = el('div', 'proj-activity-section')
for (const sess of running) {
const block = el('div', 'proj-activity-session')
block.append(el('div', 'proj-activity-label', sess.title ?? 'session'))
const tlContainer = el('div', 'proj-tl-container')
block.append(tlContainer)
const handle = mountTimeline(tlContainer, sess.id)
localTimelines.push(handle)
if (collector !== undefined) collector.push(handle)
section.append(block)
}
return section
}
interface DetailCallbacks {
onBack: () => void
onKill: (id: string) => void
}
/** Render the project detail view (exported for unit tests). */
/** Render the project detail view (exported for unit tests). `timelineCollector` receives A4 handles for re-render disposal. */
export function renderProjectDetail(
detail: ProjectDetail | null,
hooks: ProjectsHooks,
cb: DetailCallbacks,
timelineCollector?: TimelineHandle[],
): HTMLElement {
const root = el('div', 'proj-detail-inner')
const localTimelines: TimelineHandle[] = []
const diffRef: { h: DiffViewerHandle | null } = { h: null }
const back = el('button', 'proj-back', '← All projects')
back.addEventListener('click', () => cb.onBack())
back.addEventListener('click', () => {
diffRef.h?.destroy()
diffRef.h = null
for (const handle of localTimelines) handle.dispose()
cb.onBack()
})
root.append(back)
if (detail === null) {
@@ -439,6 +587,9 @@ export function renderProjectDetail(
root.append(head)
root.append(el('div', 'proj-detail-path', detail.path))
// B1: View Diff (git repos only) — SEC-H4 enforced inside mountDiffViewer
if (detail.isGit) root.append(buildDiffSection(detail.path, diffRef))
// Branch / worktrees
root.append(el('div', 'proj-section-title', detail.worktrees.length > 1 ? 'Worktrees' : 'Branch'))
if (!detail.isGit) {
@@ -453,6 +604,12 @@ export function renderProjectDetail(
root.append(list)
}
// B3: New Worktree form (git repos only)
if (detail.isGit) {
root.append(el('div', 'proj-section-title', 'New Worktree'))
root.append(renderNewWorktreeForm(detail, hooks))
}
// Active sessions
const running = detail.sessions.filter((s) => !s.exited)
root.append(el('div', 'proj-section-title', `Active sessions (${running.length})`))
@@ -464,15 +621,20 @@ export function renderProjectDetail(
root.append(list)
}
// CLAUDE.md — view + a smart Generate/Update button that runs /init interactively.
// A4: Activity section — one timeline per running session, disposed on back/re-render
const activityEl = buildActivitySection(running, localTimelines, timelineCollector)
if (activityEl !== null) {
root.append(el('div', 'proj-section-title', 'Activity'))
root.append(activityEl)
}
// CLAUDE.md
const cmHead = el('div', 'proj-section-row')
cmHead.append(el('div', 'proj-section-title', 'CLAUDE.md'))
const cmBtn = el('button', 'proj-claudemd-btn', detail.hasClaudeMd ? '↻ Update' : '✨ Generate')
cmBtn.title = detail.hasClaudeMd
? 'Open a Claude session running /init to refresh CLAUDE.md'
: 'Open a Claude session running /init to create CLAUDE.md'
// Launch claude with /init as the initial prompt, in this repo (interactive,
// so you review what it writes). Reuses the project launcher.
cmBtn.addEventListener('click', () => hooks.onOpenProject(detail.path, detail.name, 'claude "/init"\r'))
cmHead.append(cmBtn)
root.append(cmHead)
@@ -483,11 +645,7 @@ export function renderProjectDetail(
root.append(pre)
} else {
root.append(
el(
'div',
'proj-empty',
'No CLAUDE.md yet — generate one to give Claude project-specific instructions.',
),
el('div', 'proj-empty', 'No CLAUDE.md yet — generate one to give Claude project-specific instructions.'),
)
}
@@ -527,6 +685,8 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects
let timer: ReturnType<typeof setInterval> | null = null
let view: 'grid' | 'detail' = 'grid'
let detailPath: string | null = null
// A4: tracks timeline handles from the current detail render for re-render disposal
let detailTimelineHandles: TimelineHandle[] = []
function applyView(): void {
const isGrid = view === 'grid'
@@ -587,15 +747,23 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects
}
function renderDetail(detail: ProjectDetail | null): void {
// Dispose timelines from the previous render before replacing DOM (A4 re-render)
for (const h of detailTimelineHandles) h.dispose()
detailTimelineHandles = []
detailEl.replaceChildren(
renderProjectDetail(detail, hooks, {
onBack: closeDetail,
onKill: (id) => {
void killSession(id).then(() => {
if (view === 'detail') void refresh()
})
renderProjectDetail(
detail,
hooks,
{
onBack: closeDetail,
onKill: (id) => {
void killSession(id).then(() => {
if (view === 'detail') void refresh()
})
},
},
}),
detailTimelineHandles,
),
)
}

354
public/push.ts Normal file
View File

@@ -0,0 +1,354 @@
/**
* public/push.ts — Push subscribe/permission UI (N-push-ui, A1)
*
* Exports:
* PushSupportStatus — union of all push readiness states
* fetchVapidKey() — GET /push/vapid-key (null on 503/error)
* checkPushSupport(vapidKey) — async, checks all preconditions (SEC-H8)
* subscribePush(vapidKey) — request permission + SW subscribe + POST server
* unsubscribePush(sub) — DELETE server + browser unsubscribe (best-effort)
* mountPushToggle(container, opts?) — render 🔔 toggle widget
* isPushMuted() / setPushMuted(muted) — in-app mute (A1-FR9, localStorage only)
*
* SEC-H8: isSecureContext is the first check in checkPushSupport; nothing push-
* related executes in an insecure context.
* Review #12: The localStorage mute is in-app-only. Global DND is server-side
* (NOTIFY_DND env). They are independent.
*/
/* ── Types ─────────────────────────────────────────────────────────────────── */
/** All possible push-readiness states. Drives UI rendering in mountPushToggle. */
export type PushSupportStatus =
| 'unsupported' // SW / PushManager / Notification API unavailable in this browser
| 'insecure-context' // page is not a secure context; needs HTTPS or Tailscale (SEC-H8)
| 'vapid-missing' // server has no VAPID keys; GET /push/vapid-key returned 503
| 'permission-denied' // user blocked notifications in browser settings
| 'available' // all checks pass, ready to subscribe (no active sub yet)
| 'subscribed' // active push subscription already exists
export interface MountPushToggleOpts {
/** Called once after the status is resolved and the widget is rendered. */
onChange?: (status: PushSupportStatus) => void
}
/* ── In-app mute (A1-FR9) ──────────────────────────────────────────────────── */
/** localStorage key for the in-app mute preference. */
const PUSH_MUTE_KEY = 'web-terminal:push-muted'
/** Read the in-app mute preference. Returns false on any storage error. */
export function isPushMuted(): boolean {
try {
return localStorage.getItem(PUSH_MUTE_KEY) === '1'
} catch {
return false
}
}
/** Write the in-app mute preference. Silently ignores storage errors. */
export function setPushMuted(muted: boolean): void {
try {
if (muted) {
localStorage.setItem(PUSH_MUTE_KEY, '1')
} else {
localStorage.removeItem(PUSH_MUTE_KEY)
}
} catch {
// Ignore — private browsing or storage quota; best-effort
}
}
/* ── fetchVapidKey ─────────────────────────────────────────────────────────── */
/**
* Fetch the VAPID public key from the server.
* Returns null when push is disabled (503) or on any error.
*/
export async function fetchVapidKey(): Promise<string | null> {
try {
const res = await fetch('/push/vapid-key', { credentials: 'same-origin' })
if (!res.ok) return null
const data: unknown = await res.json()
if (
typeof data === 'object' &&
data !== null &&
'publicKey' in data &&
typeof (data as Record<string, unknown>)['publicKey'] === 'string'
) {
return (data as { publicKey: string }).publicKey
}
return null
} catch {
return null
}
}
/* ── checkPushSupport ──────────────────────────────────────────────────────── */
/**
* Check the comprehensive push support status. Checks in order:
* insecure-context → unsupported → vapid-missing → permission-denied
* → subscribed → available
*
* SEC-H8: isSecureContext is the first check.
*/
export async function checkPushSupport(vapidKey: string | null): Promise<PushSupportStatus> {
// SEC-H8: secure context is required for SW and Push API
if (!window.isSecureContext) return 'insecure-context'
// Require all three browser APIs — check values (not just property existence),
// because jsdom sets properties to undefined rather than deleting them.
const hasSW = Boolean(navigator.serviceWorker)
// Cast via `unknown` first to avoid TS2352 (Window lacks an index signature).
const winMap = window as unknown as Record<string, unknown>
const hasPushMgr = Boolean(winMap['PushManager'])
// Capture Notification locally so we can access .permission safely below.
const Notif = winMap['Notification'] as typeof Notification | undefined
if (!hasSW || !hasPushMgr || !Notif) return 'unsupported'
// Server must have VAPID keys configured
if (vapidKey === null) return 'vapid-missing'
// Check browser permission
if (Notif.permission === 'denied') return 'permission-denied'
// Check for an existing active subscription
try {
const registration = await navigator.serviceWorker.getRegistration()
if (registration) {
const subscription = await registration.pushManager.getSubscription()
if (subscription) return 'subscribed'
}
} catch {
// Cannot determine subscription state; fall through to 'available'
}
return 'available'
}
/* ── subscribePush ─────────────────────────────────────────────────────────── */
/** Convert a URL-safe base64 string to Uint8Array for applicationServerKey. */
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const output = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
output[i] = rawData.charCodeAt(i) ?? 0
}
return output
}
/**
* Subscribe to push notifications.
* 1. Requests Notification permission (if not already granted).
* 2. Gets the SW registration and calls pushManager.subscribe().
* 3. POSTs the PushSubscription to /push/subscribe.
* Returns the PushSubscription on success, null on any failure.
*/
export async function subscribePush(vapidKey: string): Promise<PushSubscription | null> {
try {
const perm = await Notification.requestPermission()
if (perm !== 'granted') return null
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) return null
const applicationServerKey = urlBase64ToUint8Array(vapidKey)
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
// Cast to `ArrayBuffer` — Uint8Array.buffer is ArrayBufferLike which includes
// SharedArrayBuffer, but DOM types require ArrayBuffer here.
applicationServerKey: applicationServerKey.buffer as ArrayBuffer,
})
const res = await fetch('/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(subscription.toJSON()),
})
if (!res.ok) {
// Roll back: unsubscribe from browser so we stay in sync
await subscription.unsubscribe()
return null
}
return subscription
} catch {
return null
}
}
/* ── unsubscribePush ───────────────────────────────────────────────────────── */
/**
* Unsubscribe from push notifications.
* Both operations are best-effort; individual errors are swallowed so the
* other operation still runs.
*/
export async function unsubscribePush(subscription: PushSubscription): Promise<void> {
try {
await fetch('/push/subscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ endpoint: subscription.endpoint }),
})
} catch {
// Ignore server errors — still attempt browser unsubscribe
}
try {
await subscription.unsubscribe()
} catch {
// Ignore browser errors
}
}
/* ── DOM helpers ───────────────────────────────────────────────────────────── */
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text
return node
}
/* ── mountPushToggle ───────────────────────────────────────────────────────── */
/**
* Mount a 🔔 push subscribe/unsubscribe toggle into container.
*
* Behavior per status:
* 'vapid-missing' → hides container (server has no VAPID keys)
* 'insecure-context' → grayed 🔔 + "needs HTTPS/Tailscale" hint
* 'unsupported' → grayed 🔔 + "not supported in this browser" hint
* 'permission-denied' → grayed 🔔 + "blocked in browser settings" hint
* 'available' → active button (Off state) to initiate subscribe
* 'subscribed' → active button (On state) to initiate unsubscribe
*/
export function mountPushToggle(container: HTMLElement, opts?: MountPushToggleOpts): void {
void initPushToggle(container, opts)
}
async function initPushToggle(container: HTMLElement, opts?: MountPushToggleOpts): Promise<void> {
const vapidKey = await fetchVapidKey()
const status = await checkPushSupport(vapidKey)
renderPushToggle(container, status, vapidKey, opts)
}
function renderPushToggle(
container: HTMLElement,
status: PushSupportStatus,
vapidKey: string | null,
opts?: MountPushToggleOpts,
): void {
// Clear existing children
while (container.firstChild) container.removeChild(container.firstChild)
opts?.onChange?.(status)
if (status === 'vapid-missing') {
// Server push is disabled — hide the widget entirely
container.style.display = 'none'
return
}
container.style.display = ''
if (status === 'insecure-context') {
renderDisabled(
container,
'Push notifications unavailable',
'Enable push: access via HTTPS or Tailscale',
)
return
}
if (status === 'unsupported') {
renderDisabled(
container,
'Push notifications not supported',
'Push notifications not supported in this browser',
)
return
}
if (status === 'permission-denied') {
renderDisabled(
container,
'Push notifications blocked',
'Notifications blocked — allow in browser settings',
)
return
}
// 'available' or 'subscribed' — render a functional toggle button
renderToggleButton(container, status === 'subscribed', vapidKey, opts)
}
function renderDisabled(container: HTMLElement, ariaLabel: string, hint: string): void {
const wrap = el('span', 'push-toggle-disabled')
const bell = el('span', 'push-bell push-bell-off', '🔔')
bell.setAttribute('aria-label', ariaLabel)
const hintEl = el('span', 'push-hint', hint)
wrap.append(bell, hintEl)
container.append(wrap)
}
function renderToggleButton(
container: HTMLElement,
isSubscribed: boolean,
vapidKey: string | null,
opts?: MountPushToggleOpts,
): void {
const btn = el(
'button',
isSubscribed ? 'push-toggle-btn push-toggle-on' : 'push-toggle-btn push-toggle-off',
isSubscribed ? '🔔 On' : '🔔 Off',
)
btn.title = isSubscribed
? 'Click to disable push notifications'
: 'Click to enable push notifications'
btn.setAttribute('aria-pressed', isSubscribed ? 'true' : 'false')
btn.addEventListener('click', () => {
btn.disabled = true
void handleToggleClick(container, vapidKey, isSubscribed, opts)
})
container.append(btn)
}
async function handleToggleClick(
container: HTMLElement,
vapidKey: string | null,
wasSubscribed: boolean,
opts?: MountPushToggleOpts,
): Promise<void> {
if (wasSubscribed) {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (registration) {
const sub = await registration.pushManager.getSubscription()
if (sub) await unsubscribePush(sub)
}
} catch {
// ignore
}
} else if (vapidKey !== null) {
await subscribePush(vapidKey)
}
// Re-check status and re-render
const newStatus = await checkPushSupport(vapidKey)
renderPushToggle(container, newStatus, vapidKey, opts)
}

279
public/quick-reply.ts Normal file
View File

@@ -0,0 +1,279 @@
/**
* public/quick-reply.ts — N-quickreply: quick-reply chips + saved-prompt palette.
*
* Feature: A3 (Walk-away Workbench — "Quick-Reply Chips + Saved Prompt Palette")
*
* Provides:
* - BUILT_IN_CHIPS — pre-set chips for Claude Code's most common responses.
* - Immutable CRUD — addChip / removeChip / reorderChip / updateChip.
* - Palette I/O — loadPalette / savePalette (localStorage, never throw).
* - mountQuickReply — DOM mount: chips + inline editor.
*
* Security: SEC-L3 — all chip labels are set via textContent, never innerHTML,
* so a snippet label such as `<script>alert(1)</script>` appears as inert text.
*/
/** A single sendable snippet. */
export interface Chip {
/** Stable identifier (built-in chips use a double-underscore prefix). */
id: string
/** Raw bytes to send (without the Enter suffix). */
text: string
/** Display label on the chip button. Set via textContent — never innerHTML. */
label: string
/** If true, \r is appended to `text` when sending. */
appendEnter: boolean
}
// ── constants ─────────────────────────────────────────────────────────────────
/** localStorage key used to persist the user palette. */
export const PALETTE_KEY = 'web-terminal:quick-reply-palette'
/** The six built-in chips for Claude Code interaction. Immutable. */
export const BUILT_IN_CHIPS: readonly Chip[] = Object.freeze([
{ id: '__yes', text: 'yes', label: 'yes', appendEnter: true },
{ id: '__continue', text: 'continue', label: 'continue', appendEnter: true },
{ id: '__1', text: '1', label: '1', appendEnter: true },
{ id: '__2', text: '2', label: '2', appendEnter: true },
{ id: '__3', text: '3', label: '3', appendEnter: true },
{ id: '__esc', text: '\x1b', label: 'Esc', appendEnter: false },
])
// ── immutable CRUD ────────────────────────────────────────────────────────────
/** Return a new array with `chip` appended. Does not mutate `chips`. */
export function addChip(chips: readonly Chip[], chip: Chip): Chip[] {
return [...chips, chip]
}
/** Return a new array with the chip matching `id` removed. Does not mutate. */
export function removeChip(chips: readonly Chip[], id: string): Chip[] {
return chips.filter((c) => c.id !== id)
}
/**
* Return a new array with the chip at `fromIndex` moved to `toIndex`.
* Returns a shallow copy unchanged if either index is out of bounds.
* Does not mutate `chips`.
*/
export function reorderChip(chips: readonly Chip[], fromIndex: number, toIndex: number): Chip[] {
if (fromIndex < 0 || fromIndex >= chips.length) return [...chips]
if (toIndex < 0 || toIndex >= chips.length) return [...chips]
const result = [...chips]
const [moved] = result.splice(fromIndex, 1)
// moved is defined because fromIndex is valid (checked above)
result.splice(toIndex, 0, moved as Chip)
return result
}
/**
* Return a new array where the chip with `id` is shallow-merged with `patch`.
* The id field cannot be changed. Does not mutate `chips` or the chip objects.
*/
export function updateChip(
chips: readonly Chip[],
id: string,
patch: Partial<Omit<Chip, 'id'>>,
): Chip[] {
return chips.map((c) => (c.id === id ? { ...c, ...patch } : c))
}
// ── localStorage persistence ──────────────────────────────────────────────────
function isValidChip(item: unknown): item is Chip {
if (item === null || typeof item !== 'object') return false
const o = item as Record<string, unknown>
return (
typeof o['id'] === 'string' &&
typeof o['text'] === 'string' &&
typeof o['label'] === 'string' &&
typeof o['appendEnter'] === 'boolean'
)
}
/**
* Load the user palette from localStorage.
* Returns [] on missing key, bad JSON, or unexpected shape. Never throws.
*/
export function loadPalette(): Chip[] {
try {
const raw = localStorage.getItem(PALETTE_KEY)
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(isValidChip)
} catch {
return []
}
}
/**
* Persist the user palette to localStorage.
* Silently ignores storage errors (quota exceeded, private mode). Never throws.
*/
export function savePalette(chips: readonly Chip[]): void {
try {
localStorage.setItem(PALETTE_KEY, JSON.stringify(chips))
} catch {
// Quota exceeded or storage unavailable — best-effort, ignore.
}
}
// ── DOM helpers ───────────────────────────────────────────────────────────────
/** Create a typed element with an optional CSS class and text content. */
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text // SEC-L3: textContent only
return node
}
/** Remove all children of `parent`. */
function clearChildren(parent: HTMLElement): void {
while (parent.firstChild) {
parent.removeChild(parent.firstChild)
}
}
// ── mountQuickReply ───────────────────────────────────────────────────────────
/** Options for mountQuickReply. */
export interface QuickReplyOpts {
/** Called with the full byte string to inject into the terminal. */
onSend: (data: string) => void
}
/** Returned by mountQuickReply to allow cleanup. */
export interface QuickReplyHandle {
/** Remove all DOM content and event listeners created by mountQuickReply. */
dispose: () => void
}
/**
* Mount the quick-reply chip row + palette into `container`.
*
* Renders built-in chips, then user palette chips, then a `+` button that
* opens an inline editor to add new snippets. Clicking a chip calls
* `opts.onSend(text [+ '\r'])`.
*
* Labels are set via textContent — never innerHTML (SEC-L3).
*/
export function mountQuickReply(container: HTMLElement, opts: QuickReplyOpts): QuickReplyHandle {
const { onSend } = opts
// Track disposers for all event listeners so dispose() is clean.
const disposers: Array<() => void> = []
function makeChipButton(chip: Chip): HTMLButtonElement {
const btn = el('button', 'qr-chip')
btn.textContent = chip.label // SEC-L3: textContent, never innerHTML
btn.title = chip.appendEnter ? `${chip.text}` : chip.text
function handler() {
onSend(chip.text + (chip.appendEnter ? '\r' : ''))
}
btn.addEventListener('click', handler)
disposers.push(() => btn.removeEventListener('click', handler))
return btn
}
function openEditor(): void {
const editor = el('div', 'qr-editor')
const textInput = document.createElement('input')
textInput.type = 'text'
textInput.className = 'qr-editor-text'
textInput.placeholder = 'Text to send'
const labelInput = document.createElement('input')
labelInput.type = 'text'
labelInput.className = 'qr-editor-label'
labelInput.placeholder = 'Label (optional, defaults to text)'
const enterWrap = el('label', 'qr-editor-enter-label')
const enterCheck = document.createElement('input')
enterCheck.type = 'checkbox'
enterCheck.className = 'qr-editor-enter'
enterCheck.checked = true
enterWrap.appendChild(enterCheck)
enterWrap.appendChild(document.createTextNode(' Append Enter'))
const saveBtn = el('button', 'qr-editor-save', 'Save')
const cancelBtn = el('button', 'qr-editor-cancel', 'Cancel')
function onSaveClick() {
const text = textInput.value.trim()
if (!text) return // empty text — no-op, editor stays open
const rawLabel = labelInput.value.trim()
const label = rawLabel !== '' ? rawLabel : text
const newChip: Chip = {
id: `user_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`,
text,
label,
appendEnter: enterCheck.checked,
}
savePalette(addChip(loadPalette(), newChip))
editor.remove()
render()
}
function onCancelClick() {
editor.remove()
}
saveBtn.addEventListener('click', onSaveClick)
cancelBtn.addEventListener('click', onCancelClick)
editor.appendChild(textInput)
editor.appendChild(labelInput)
editor.appendChild(enterWrap)
editor.appendChild(saveBtn)
editor.appendChild(cancelBtn)
container.appendChild(editor)
}
function render(): void {
clearChildren(container)
const row = el('div', 'qr-row')
// Built-in chips first
for (const chip of BUILT_IN_CHIPS) {
row.appendChild(makeChipButton(chip))
}
// User palette chips
for (const chip of loadPalette()) {
row.appendChild(makeChipButton(chip))
}
// Add-snippet button
const addBtn = el('button', 'qr-add-btn', '+')
addBtn.title = 'Add custom snippet'
addBtn.setAttribute('aria-label', 'Add custom snippet')
function onAddClick() {
openEditor()
}
addBtn.addEventListener('click', onAddClick)
disposers.push(() => addBtn.removeEventListener('click', onAddClick))
row.appendChild(addBtn)
container.appendChild(row)
}
render()
return {
dispose() {
for (const cleanup of disposers) cleanup()
clearChildren(container)
},
}
}

82
public/sw-push.js Normal file
View File

@@ -0,0 +1,82 @@
/**
* public/sw-push.js — Pure push notification helpers (no SW globals).
*
* This file is imported by public/sw.js (module service worker) and tested
* directly by test/sw-push.test.ts. It must never reference `self`, `clients`,
* or any other SW-specific global — pure functions only.
*
* Implements the §3.3 PushPayload contract (one shape; FE reads `cls`).
* SEC-M6: tag = sessionId so the browser deduplicates (no notification stacking).
* SEC-C1: capability token is forwarded in the decision body; Origin + token
* validation happens server-side at POST /hook/decision.
*/
/** @type {Record<string, string>} */
const TITLES = {
'needs-input': 'Approval Needed',
done: 'Task Complete',
stuck: 'Task Stuck',
}
/**
* Build a Notification title + options from an incoming push payload.
*
* @param {{ sessionId: string, toolName?: string, detail?: string, token?: string, cls: string }} payload
* @returns {{ title: string, options: Record<string, unknown> }}
*/
export function buildPushNotification(payload) {
const { sessionId, toolName, detail, token, cls } = payload
const title = TITLES[cls] ?? 'Web Terminal'
// Body: prefer explicit detail, fall back to toolName hint, else omit.
const body = detail ?? (toolName ? `Tool: ${toolName}` : undefined)
/** @type {Record<string, unknown>} */
const options = {
tag: sessionId, // SEC-M6: replaces any previous notification for this session
body,
data: { sessionId, token, cls },
requireInteraction: cls === 'needs-input',
// Allow / Deny actions only for needs-input (lock-screen approval, AC-A1.2/A1.3)
actions:
cls === 'needs-input'
? [
{ action: 'allow', title: 'Allow' },
{ action: 'deny', title: 'Deny' },
]
: [],
}
return { title, options }
}
/**
* Resolve a notificationclick event into an opaque result.
* The caller (sw.js) executes the result:
* kind='decision' → POST /hook/decision with result.body (SEC-C1)
* kind='focus' → openWindow / focus existing tab at result.url
*
* @param {{ data: { sessionId?: string, token?: string, cls?: string } | null }} notification
* @param {string | null | undefined} action — event.action from notificationclick
* @returns {{ kind: 'decision', body: string } | { kind: 'focus', url: string }}
*/
export function resolveNotificationClick(notification, action) {
const data = notification?.data ?? {}
const { sessionId = '', token } = /** @type {{ sessionId?: string, token?: string }} */ (data)
if (action === 'allow' || action === 'deny') {
// SEC-C1: include capability token so server can authenticate this decision.
// Origin header is added automatically by fetch() with credentials:'same-origin'.
return {
kind: 'decision',
body: JSON.stringify({ sessionId, decision: action, token }),
}
}
// Default (body click, empty string, undefined, null) → focus/open terminal session
return {
kind: 'focus',
url: `/?session=${encodeURIComponent(sessionId)}`,
}
}

View File

@@ -1,10 +1,16 @@
/**
* public/sw.js — minimal service worker (M4: PWA installability + offline shell).
* public/sw.js — service worker (ES module; must be registered with {type:'module'}).
*
* Network-first so we never serve a stale build while online; falls back to the
* cache when offline. Never intercepts the WebSocket (/term) or hook endpoints.
* Network-first caching + A1 push notifications + notificationclick routing.
* Pure push/notification helpers live in sw-push.js (no SW globals → testable).
*
* IMPORTANT: This file uses ES module `import` syntax. The SW registration in
* public/main.ts must pass { type: 'module' }:
* navigator.serviceWorker.register('./sw.js', { type: 'module' })
*/
import { buildPushNotification, resolveNotificationClick } from './sw-push.js'
const CACHE = 'webterm-v1'
self.addEventListener('install', () => self.skipWaiting())
@@ -14,7 +20,13 @@ self.addEventListener('fetch', (e) => {
const req = e.request
if (req.method !== 'GET') return
const url = new URL(req.url)
if (url.pathname === '/term' || url.pathname.startsWith('/hook')) return // WS / hooks
// Bypass: WS upgrade, hook endpoints, and push subscription routes (A1)
if (
url.pathname === '/term' ||
url.pathname.startsWith('/hook') ||
url.pathname.startsWith('/push')
)
return
e.respondWith(
fetch(req)
@@ -26,3 +38,54 @@ self.addEventListener('fetch', (e) => {
.catch(() => caches.match(req)),
)
})
/** A1: show a notification when a push arrives. */
self.addEventListener('push', (e) => {
let payload
try {
payload = e.data?.json()
} catch {
return // malformed push — ignore
}
if (!payload || typeof payload !== 'object') return
const { title, options } = buildPushNotification(payload)
e.waitUntil(self.registration.showNotification(title, options))
})
/**
* A1: handle notification action clicks.
* Allow/Deny → POST /hook/decision with capability token (SEC-C1).
* Body click → focus / open the terminal session tab.
*/
self.addEventListener('notificationclick', (e) => {
e.notification.close()
const result = resolveNotificationClick(e.notification, e.action)
if (result.kind === 'decision') {
// credentials:'same-origin' ensures Origin header is sent so the server
// can validate it alongside the capability token (SEC-C1).
e.waitUntil(
fetch('/hook/decision', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: result.body,
}).catch((err) => {
console.error('[sw] /hook/decision fetch failed', err)
}),
)
} else {
// Focus an existing window if available, otherwise open a new one.
e.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
if ('focus' in client) return client.focus()
}
return self.clients.openWindow(result.url)
}),
)
}
})

View File

@@ -25,10 +25,25 @@ import { TerminalSession } from './terminal-session.js'
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
import { mountLauncher, type Launcher } from './launcher.js'
import { mountProjects, type ProjectsPanel } from './projects.js'
import type { ClaudeStatus, PermissionMode, UiConfig } from '../src/types.js'
import { renderTelemetryGauge } from './preview-grid.js'
import { mountPushToggle } from './push.js'
import { mountQuickReply } from './quick-reply.js'
import { mountTimeline, type TimelineHandle } from './timeline.js'
import { createVoiceInput, type VoiceInput } from './voice.js'
const TABS_KEY = 'web-terminal:tabs'
const ACTIVE_KEY = 'web-terminal:active'
const HOME_VIEW_KEY = 'web-terminal:home-view'
const PERMISSION_MODE_KEY = 'web-terminal:permission-mode'
/** Mirrors the server STATUSLINE_TTL_MS default — the per-tab gauge greys out
* (class `tg-stale`) once telemetry is older than this (B2). */
const STATUSLINE_TTL_MS = 30_000
/** All `--permission-mode` values (B4). 'auto' is high-risk and only offered
* when the server's ALLOW_AUTO_MODE allows it (SEC-M5, gated via /config/ui). */
const ALL_PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto']
type HomeView = 'sessions' | 'projects'
@@ -38,6 +53,10 @@ interface TabEntry {
autoTitle: string | null // current folder from the terminal title
hasActivity: boolean // inactive tab got output since last viewed
el: HTMLDivElement | null // the .tab element (updated in place)
// A4: live timeline handle while this tab's timeline is open (disposed on
// switch/close). Telemetry is NOT cached here — refreshTab reads the single
// source of truth session.telemetry (review #15).
timelineHandle: TimelineHandle | null
}
interface StoredTab {
@@ -45,6 +64,15 @@ interface StoredTab {
title: string | null
}
/** Compact glyph for a tab's Claude activity, incl. 'stuck' ⚠ (A5). */
function claudeIcon(cs: ClaudeStatus): string {
if (cs === 'working') return '⚙'
if (cs === 'waiting') return '⏳'
if (cs === 'idle') return '✓'
if (cs === 'stuck') return '⚠'
return ''
}
export class TabApp {
private readonly tabs: TabEntry[] = []
private activeIndex = -1
@@ -64,6 +92,15 @@ export class TabApp {
// tab is activated. Irrelevant while no tab is open (home shows regardless).
private homeForced = false
private homeBtn: HTMLButtonElement | null = null
// B4: mirrors the server ALLOW_AUTO_MODE gate (from /config/ui); when false the
// high-risk 'auto' permission mode is hidden/refused (SEC-M5).
private allowAutoMode = false
private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild
private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time)
private timelineOpen = false
private timelineBtn: HTMLButtonElement | null = null
private voice: VoiceInput | null = null // A2: push-to-talk recognizer
private voiceOverlay: HTMLElement | null = null // A2: interim-transcript overlay
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
this.paneHost = paneHost
@@ -94,6 +131,13 @@ export class TabApp {
this.segControl = this.buildSegControl()
this.paneHost.appendChild(this.segControl)
// v0.7 Walk-away Workbench panels (mounted once; survive tab rebuilds):
this.setupPushToggle() // A1 🔔
this.setupQuickReply() // A3 chips above the key bar
this.setupTimelinePanel() // A4 activity timeline
this.setupVoiceOverlay() // A2 interim-transcript overlay
void this.loadUiConfig() // B4 allowAutoMode gate (best-effort)
// v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen;
// the user picks which session to open. Sessions persist server-side, so
// opening one replays its full scrollback.
@@ -101,6 +145,61 @@ export class TabApp {
this.updateHomeView()
}
/* ── v0.7 panel setup (A1/A2/A3/A4/B4) ──────────────────────────── */
/** A1: mount the 🔔 push toggle once into a host re-parented on every rebuild. */
private setupPushToggle(): void {
this.pushHost = document.createElement('div')
this.pushHost.className = 'push-host'
mountPushToggle(this.pushHost)
}
/** A3: quick-reply chips row, inserted directly above the #keybar. */
private setupQuickReply(): void {
const host = document.createElement('div')
host.id = 'quickreply'
const keybar = document.getElementById('keybar')
if (keybar?.parentElement) keybar.parentElement.insertBefore(host, keybar)
else document.body.appendChild(host)
mountQuickReply(host, { onSend: (data) => this.sendToActive(data) })
}
/** A4: hidden activity-timeline panel; toggled per active session. */
private setupTimelinePanel(): void {
this.timelinePanel = document.createElement('div')
this.timelinePanel.id = 'timeline-panel'
this.timelinePanel.style.display = 'none'
document.body.appendChild(this.timelinePanel)
}
/** A2: overlay that shows the live interim transcript while dictating. */
private setupVoiceOverlay(): void {
const overlay = document.createElement('div')
overlay.id = 'voice-interim'
overlay.style.display = 'none'
document.body.appendChild(overlay)
this.voiceOverlay = overlay
}
/** B4: fetch the server UI config (allowAutoMode). Best-effort, never throws. */
private async loadUiConfig(): Promise<void> {
try {
if (typeof fetch === 'undefined') return
const res = await fetch('/config/ui', { credentials: 'same-origin' })
if (!res.ok) return
const data: unknown = await res.json()
if (
data !== null &&
typeof data === 'object' &&
typeof (data as Record<string, unknown>)['allowAutoMode'] === 'boolean'
) {
this.allowAutoMode = (data as UiConfig).allowAutoMode
}
} catch {
// best-effort — leave allowAutoMode false (auto hidden) on any failure
}
}
/* ── Home-view visibility ────────────────────────────────────────── */
/**
@@ -189,7 +288,8 @@ export class TabApp {
/* ── Approval bar ───────────────────────────────────────────────── */
/** Show/hide the approve/reject banner for the active tab's held request (H3). */
/** Show/hide the approve/reject banner (H3). B4: a 'plan' gate renders THREE
* choices; an ordinary 'tool' gate keeps the two buttons (no regression). */
private updateApprovalBar(): void {
const session = this.tabs[this.activeIndex]?.session
if (!session || !session.pendingApproval) {
@@ -199,25 +299,150 @@ export class TabApp {
this.approvalBar.replaceChildren()
const label = document.createElement('span')
label.className = 'approval-label'
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
const approve = document.createElement('button')
approve.className = 'approval-yes'
approve.textContent = '✓ Approve'
approve.addEventListener('click', () => {
session.approve()
this.updateApprovalBar()
})
const reject = document.createElement('button')
reject.className = 'approval-no'
reject.textContent = '✗ Reject'
reject.addEventListener('click', () => {
session.reject()
this.updateApprovalBar()
})
this.approvalBar.append(label, approve, reject)
if (session.pendingGate === 'plan') {
label.textContent = 'Claude finished planning — how should it proceed?'
this.approvalBar.append(label, ...this.planGateButtons(session))
} else {
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
this.approvalBar.append(label, ...this.toolGateButtons(session))
}
this.approvalBar.style.display = 'flex'
}
/** Ordinary tool gate: Approve / Reject (two buttons, unchanged). */
private toolGateButtons(session: TerminalSession): HTMLButtonElement[] {
return [
this.approvalButton('approval-yes', '✓ Approve', () => session.approve()),
this.approvalButton('approval-no', '✗ Reject', () => session.reject()),
]
}
/** B4 plan gate: Approve+Auto (acceptEdits) / Approve+Review (default) / Keep
* Planning (reject — stays in plan mode). Three buttons. */
private planGateButtons(session: TerminalSession): HTMLButtonElement[] {
return [
this.approvalButton('approval-yes', '✓ Approve + Auto', () => session.approve('acceptEdits')),
this.approvalButton('approval-review', '✓ Approve + Review', () => session.approve('default')),
this.approvalButton('approval-no', '✎ Keep Planning', () => session.reject()),
]
}
/** Build one approval-bar button that runs `onClick` then re-renders the bar. */
private approvalButton(cls: string, text: string, onClick: () => void): HTMLButtonElement {
const btn = document.createElement('button')
btn.className = cls
btn.textContent = text
btn.addEventListener('click', () => {
onClick()
this.updateApprovalBar()
})
return btn
}
/* ── A2 voice · A4 timeline · B4 permission mode ─────────────────── */
/** Push-to-talk handler for the key-bar 🎤 (A2). Wire into mountKeybar via
* `{ onVoiceTrigger: (a) => app.handleVoiceTrigger(a) }`. */
handleVoiceTrigger(action: 'start' | 'stop'): void {
if (action === 'start') this.startVoice()
else this.stopVoice()
}
private startVoice(): void {
if (!this.voice) {
this.voice = createVoiceInput((text) => this.sendToActive(text), {
onInterim: (t) => this.showInterim(t),
})
}
if (!this.voice) return // SpeechRecognition unsupported — no-op (AC-A2.3)
this.showInterim('')
this.voice.start()
}
private stopVoice(): void {
this.voice?.stop()
this.hideInterim()
}
private showInterim(text: string): void {
if (!this.voiceOverlay) return
this.voiceOverlay.textContent = text // textContent — transcript is untrusted
this.voiceOverlay.style.display = 'block'
}
private hideInterim(): void {
if (this.voiceOverlay) this.voiceOverlay.style.display = 'none'
}
/** Toggle the activity-timeline panel for the active session (A4). */
private toggleTimeline(): void {
this.timelineOpen = !this.timelineOpen
if (this.timelineOpen) this.openTimelineForActive()
else this.closeTimeline()
this.timelineBtn?.classList.toggle('active', this.timelineOpen)
}
/** Mount the timeline for the active session (one panel at a time, A4). */
private openTimelineForActive(): void {
for (const t of this.tabs) {
t.timelineHandle?.dispose()
t.timelineHandle = null
}
this.timelinePanel.style.display = 'block'
this.timelinePanel.replaceChildren()
const entry = this.tabs[this.activeIndex]
const id = entry?.session.id
if (entry && id) entry.timelineHandle = mountTimeline(this.timelinePanel, id)
}
private closeTimeline(): void {
this.timelinePanel.style.display = 'none'
for (const t of this.tabs) {
t.timelineHandle?.dispose()
t.timelineHandle = null
}
}
/** Permission modes the user may pick (B4). 'auto' is filtered out unless the
* server allows it (SEC-M5). */
availablePermissionModes(): PermissionMode[] {
return ALL_PERMISSION_MODES.filter((m) => m !== 'auto' || this.allowAutoMode)
}
/** B4: persisted default permission mode; 'auto' downgrades to 'default' when
* the server forbids it (SEC-M5). Invalid stored values fall back to 'default'. */
loadDefaultMode(): PermissionMode {
let mode: PermissionMode = 'default'
try {
const stored = localStorage.getItem(PERMISSION_MODE_KEY)
if (stored !== null && (ALL_PERMISSION_MODES as readonly string[]).includes(stored)) {
mode = stored as PermissionMode
}
} catch {
// localStorage unavailable — use 'default'
}
return this.resolveMode(mode)
}
/** Persist the chosen default permission mode (B4-FR7). */
saveDefaultMode(mode: PermissionMode): void {
try {
localStorage.setItem(PERMISSION_MODE_KEY, mode)
} catch {
// localStorage unavailable — run without persistence
}
}
/** SEC-M5: never honor 'auto' when the server forbids it. */
private resolveMode(mode: PermissionMode): PermissionMode {
return mode === 'auto' && !this.allowAutoMode ? 'default' : mode
}
/** B4: `claude` launch command for a permission mode (default → plain claude). */
private buildClaudeCmd(mode: PermissionMode): string {
return mode === 'default' ? 'claude\r' : `claude --permission-mode ${mode}\r`
}
private displayTitle(entry: TabEntry, idx: number): string {
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
}
@@ -287,8 +512,17 @@ export class TabApp {
this.notify(entry)
}
},
// B2: telemetry is the single source of truth on the session; just re-render.
onTelemetry: () => this.refreshTab(entry),
})
entry = { session, customTitle, autoTitle: null, hasActivity: false, el: null }
entry = {
session,
customTitle,
autoTitle: null,
hasActivity: false,
el: null,
timelineHandle: null,
}
this.paneHost.appendChild(session.el)
this.tabs.push(entry)
session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
@@ -319,10 +553,13 @@ export class TabApp {
* distinguish them (e.g. "web-terminal #2"). Mirrors newTabForResume in
* structure: addEntry → persist → rebuild → activate.
*/
openProject(repoPath: string, repoName: string, cmd = 'claude\r'): void {
openProject(repoPath: string, repoName: string, cmd = 'claude\r', mode?: PermissionMode): void {
const n = this.countOpenWithTitlePrefix(repoName)
const label = n === 0 ? repoName : `${repoName} #${n + 1}`
this.addEntry(null, label, repoPath || undefined, cmd)
// B4-FR2: when a permission mode is chosen, upgrade the launch command to
// `claude --permission-mode <mode>` (non-default only); 'auto' is gated (M5).
const effectiveCmd = mode !== undefined ? this.buildClaudeCmd(this.resolveMode(mode)) : cmd
this.addEntry(null, label, repoPath || undefined, effectiveCmd)
this.persist()
this.rebuild()
this.activate(this.tabs.length - 1)
@@ -346,12 +583,14 @@ export class TabApp {
this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild
this.updateHomeView() // hide the seg control + home panels now a tab is active
this.updateApprovalBar()
if (this.timelineOpen) this.openTimelineForActive() // A4: follow the active session
this.persist()
}
closeTab(i: number): void {
if (i < 0 || i >= this.tabs.length) return
const [entry] = this.tabs.splice(i, 1)
entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab
entry?.session.dispose()
if (this.editingIndex === i) this.editingIndex = -1
if (this.tabs.length === 0) {
@@ -464,7 +703,9 @@ export class TabApp {
/* ── rendering ───────────────────────────────────────────────────── */
/** In-place update of one tab's classes/label/dotnever destroys the DOM. */
/** In-place update of one tab's classes/label/dot/gauge (never destroys DOM).
* Single `refreshTab` owner for status dot, stuck badge (A5) and telemetry
* gauge (B2), per SP10/M4. */
private refreshTab(entry: TabEntry): void {
const el = entry.el
if (!el) return
@@ -473,6 +714,7 @@ export class TabApp {
el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex)
const cs = entry.session.claudeStatus
el.classList.toggle('claude-waiting', cs === 'waiting' && idx !== this.activeIndex)
el.classList.toggle('claude-stuck', cs === 'stuck' && idx !== this.activeIndex) // A5
const title = this.displayTitle(entry, idx)
el.title = title
const dot = el.querySelector('.tab-dot')
@@ -480,10 +722,9 @@ export class TabApp {
const label = el.querySelector('.tab-label')
if (label) label.textContent = title
const claude = el.querySelector('.tab-claude')
if (claude) {
claude.textContent =
cs === 'working' ? '⚙' : cs === 'waiting' ? '⏳' : cs === 'idle' ? '✓' : ''
}
if (claude) claude.textContent = claudeIcon(cs)
const gauge = el.querySelector<HTMLElement>('.tab-gauge')
if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS) // B2
}
/** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
@@ -577,6 +818,11 @@ export class TabApp {
claude.className = 'tab-claude'
tabEl.appendChild(claude)
// B2: per-tab telemetry gauge container (filled in by refreshTab).
const gauge = document.createElement('span')
gauge.className = 'tab-gauge'
tabEl.appendChild(gauge)
const close = document.createElement('button')
close.className = 'tab-close'
close.textContent = '×'
@@ -603,6 +849,19 @@ export class TabApp {
add.addEventListener('click', () => this.newTab())
this.tabBar.appendChild(add)
// A4 timeline toggle + A1 push bell — re-appended each rebuild so they
// survive tabBar.replaceChildren() (the bell's subtree is mounted once).
const tl = document.createElement('button')
tl.className = 'tab-timeline'
tl.textContent = '📜'
tl.title = 'Activity timeline'
tl.setAttribute('aria-label', 'Activity timeline')
tl.classList.toggle('active', this.timelineOpen)
tl.addEventListener('click', () => this.toggleTimeline())
this.timelineBtn = tl
this.tabBar.appendChild(tl)
this.tabBar.appendChild(this.pushHost)
// Show/hide the home screen based on whether any tab is open.
this.updateHomeView()
}

View File

@@ -13,7 +13,14 @@ import type { ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import { WebLinksAddon } from '@xterm/addon-web-links'
import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js'
import type {
ClaudeStatus,
ClientMessage,
PermissionGate,
PermissionMode,
ServerMessage,
StatusTelemetry,
} from '../src/types.js'
import { folderFromTitle, cwdFromOsc7 } from './title-util.js'
// Delay after the shell is ready before typing a session's initial command
@@ -56,6 +63,9 @@ export interface TerminalSessionOpts {
onStatus?: (status: SessionStatus) => void
/** Optional: fired when Claude Code activity changes (from a hook, H2). */
onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void
/** Optional: fired when new statusLine telemetry arrives (B2). Single source of
* truth — T-tabs reads session.telemetry via the getter, not its own copy. */
onTelemetry?: (telemetry: StatusTelemetry) => void
/** Optional: spawn a NEW session in this directory ("new tab here", M6). */
cwd?: string
/** Optional: type this once the new session's shell is ready (O2 resume). */
@@ -74,6 +84,7 @@ export class TerminalSession {
private readonly onTitle: ((title: string) => void) | undefined
private readonly onStatus: ((status: SessionStatus) => void) | undefined
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
private readonly onTelemetry: ((telemetry: StatusTelemetry) => void) | undefined
private readonly spawnCwd: string | undefined
private readonly initialInput: string | undefined
private initialSent = false
@@ -82,6 +93,8 @@ export class TerminalSession {
private cwdValue: string | null = null
private pendingApprovalValue = false
private pendingToolValue: string | undefined = undefined
private telemetryValue: StatusTelemetry | null = null
private pendingGateValue: PermissionGate | null = null
private ws: WebSocket | null = null
private sessionId: string | null
@@ -102,6 +115,7 @@ export class TerminalSession {
this.onTitle = opts.onTitle
this.onStatus = opts.onStatus
this.onClaudeStatus = opts.onClaudeStatus
this.onTelemetry = opts.onTelemetry
this.spawnCwd = opts.cwd
this.initialInput = opts.initialInput
@@ -163,6 +177,18 @@ export class TerminalSession {
return this.pendingToolValue
}
/** Latest statusLine telemetry (B2). Single source of truth (review #15):
* T-tabs reads this getter; TabEntry does NOT cache its own copy. */
get telemetry(): StatusTelemetry | null {
return this.telemetryValue
}
/** The gate kind from the last pending status frame (B4): 'plan' or 'tool'.
* Null when no approval is held or the last status had no gate. */
get pendingGate(): PermissionGate | null {
return this.pendingGateValue
}
private setStatus(s: SessionStatus): void {
this.statusValue = s
this.onStatus?.(s)
@@ -270,9 +296,15 @@ export class TerminalSession {
this.claudeStatusValue = msg.status
this.pendingApprovalValue = msg.pending === true
this.pendingToolValue = msg.pending === true ? msg.detail : undefined
this.pendingGateValue = msg.gate ?? null
this.onClaudeStatus?.(msg.status, msg.detail)
break
}
case 'telemetry': {
this.telemetryValue = msg.telemetry
this.onTelemetry?.(msg.telemetry)
break
}
case 'exit': {
this.setStatus('exited')
const reason = msg.reason ? ` (${msg.reason})` : ''
@@ -294,6 +326,14 @@ export class TerminalSession {
})
break
}
default: {
// Compile-time exhaustiveness: TypeScript narrows msg to 'never' here
// once all ServerMessage variants are handled. Silently ignored at runtime
// (future server additions won't crash older clients).
const _exhaustive: never = msg
void _exhaustive
break
}
}
}
@@ -318,10 +358,12 @@ export class TerminalSession {
this.sendMsg({ type: 'input', data })
}
/** Resolve a held PermissionRequest (H3). */
approve(): void {
/** Resolve a held PermissionRequest (H3). Optionally relay a permission-mode
* change (B4): mode is included only when explicitly provided so the server
* can distinguish "approve with default" from "approve, keep existing mode". */
approve(mode?: PermissionMode): void {
this.pendingApprovalValue = false
this.sendMsg({ type: 'approve' })
this.sendMsg({ type: 'approve', ...(mode !== undefined ? { mode } : {}) })
}
reject(): void {
this.pendingApprovalValue = false

226
public/timeline.ts Normal file
View File

@@ -0,0 +1,226 @@
/**
* public/timeline.ts — Activity timeline panel (N-timeline-ui, A4).
*
* Renders a live-polling panel of server-derived timeline events for a session.
* Fetches GET /live-sessions/:id/events and shows rows: "HH:MM · icon · label".
*
* All event strings are set via textContent only (SEC-H6 — labels may contain
* tool-derived content such as file paths). Zero innerHTML with external data.
*
* Owned by: N-timeline-ui (W1, public/timeline.ts)
* Depends on: T-types (TimelineEvent, TimelineClass)
*/
import type { TimelineEvent, TimelineClass } from '../src/types.js'
import { el } from './preview-grid.js'
/* ─── Constants ─────────────────────────────────────────────────────────── */
const DEFAULT_REFRESH_MS = 5_000
const DEFAULT_MAX_EVENTS = 50
const LABEL_MAX_LEN = 500
const TOOL_NAME_MAX_LEN = 200
const VALID_CLASSES: ReadonlySet<string> = new Set<TimelineClass>([
'tool',
'waiting',
'done',
'stuck',
'user',
])
/* ─── Public types ───────────────────────────────────────────────────────── */
/** Handle returned by mountTimeline; call dispose() to stop polling. */
export interface TimelineHandle {
dispose(): void
}
/** Options for mountTimeline. */
export interface TimelineOpts {
/** Polling interval in ms (default 5 000). */
refreshMs?: number
/** Maximum number of events to display (default 50). */
maxEvents?: number
}
/* ─── normalizeTimelineEvent ─────────────────────────────────────────────── */
/**
* Safely narrow an unknown value to TimelineEvent, returning null on failure.
* Never throws. Validates all fields defensively (SEC-H6).
*/
export function normalizeTimelineEvent(raw: unknown): TimelineEvent | null {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
const r = raw as Record<string, unknown>
// Validate `at` — must be a finite number
if (typeof r['at'] !== 'number' || !Number.isFinite(r['at'])) return null
// Validate `class` — must be a known TimelineClass string
if (!isValidClass(r['class'])) return null
// Validate `label` — must be a string
if (typeof r['label'] !== 'string') return null
const event: TimelineEvent = {
at: r['at'],
class: r['class'],
label: r['label'].slice(0, LABEL_MAX_LEN),
}
// Optional toolName
if (typeof r['toolName'] === 'string') {
event.toolName = r['toolName'].slice(0, TOOL_NAME_MAX_LEN)
}
return event
}
function isValidClass(v: unknown): v is TimelineClass {
return typeof v === 'string' && VALID_CLASSES.has(v)
}
/* ─── timelineIcon ───────────────────────────────────────────────────────── */
/** Map a TimelineClass to a display icon string. */
export function timelineIcon(cls: TimelineClass): string {
switch (cls) {
case 'tool': return '🔧'
case 'waiting': return '⏳'
case 'done': return '✓'
case 'stuck': return '⚠'
case 'user': return '💬'
}
}
/* ─── Time formatting ────────────────────────────────────────────────────── */
/** Format a unix-ms timestamp as HH:MM (24-hour wall-clock). */
function formatHHMM(ms: number): string {
const d = new Date(ms)
const h = d.getHours().toString().padStart(2, '0')
const m = d.getMinutes().toString().padStart(2, '0')
return `${h}:${m}`
}
/* ─── renderTimelineEvent ────────────────────────────────────────────────── */
/**
* Render one timeline event as a DOM row: "HH:MM · icon · label".
*
* All strings set via textContent (SEC-H6). Returns a <div class="tl-row">
* with three child spans: tl-time, tl-icon-<class>, tl-label.
*/
export function renderTimelineEvent(ev: TimelineEvent): HTMLElement {
const row = el('div', 'tl-row')
row.append(
el('span', 'tl-time', formatHHMM(ev.at)),
el('span', `tl-icon tl-icon-${ev.class}`, timelineIcon(ev.class)),
el('span', 'tl-label', ev.label), // textContent only — SEC-H6
)
return row
}
/* ─── fetchTimeline ──────────────────────────────────────────────────────── */
/**
* Fetch the activity timeline for `id` from GET /live-sessions/:id/events.
* Returns [] on any error (network failure, non-ok response, bad shape).
*/
export async function fetchTimeline(id: string): Promise<TimelineEvent[]> {
try {
const res = await fetch(`/live-sessions/${encodeURIComponent(id)}/events`)
if (!res.ok) return []
const data: unknown = await res.json()
if (!Array.isArray(data)) return []
return data
.map((item: unknown) => normalizeTimelineEvent(item))
.filter((ev): ev is TimelineEvent => ev !== null)
} catch {
return []
}
}
/* ─── mountTimeline ──────────────────────────────────────────────────────── */
/**
* Mount a live-polling activity timeline panel into `container`.
*
* - Fetches GET /live-sessions/:id/events immediately on mount.
* - Re-polls every `refreshMs` ms (default 5 s) while the container is
* in the viewport (IntersectionObserver when available, always-on fallback).
* - Renders events newest-first, capped at `maxEvents`.
* - Shows an empty-state message when there are no events.
* - Call dispose() to stop polling and disconnect the observer.
*
* Security: event labels rendered via textContent only (SEC-H6).
*/
export function mountTimeline(
container: HTMLElement,
id: string,
opts?: TimelineOpts,
): TimelineHandle {
const refreshMs = opts?.refreshMs ?? DEFAULT_REFRESH_MS
const maxEvents = opts?.maxEvents ?? DEFAULT_MAX_EVENTS
let visible = true
let intervalId: ReturnType<typeof setInterval> | null = null
let observer: IntersectionObserver | null = null
let disposed = false
/** Clear the container and re-render events newest-first. */
function render(events: TimelineEvent[]): void {
while (container.firstChild) container.removeChild(container.firstChild)
const capped = events.slice(0, maxEvents)
// Reverse so newest is first (server returns oldest-first)
const newestFirst = [...capped].reverse()
if (newestFirst.length === 0) {
container.append(el('div', 'tl-empty', 'No activity yet'))
return
}
for (const ev of newestFirst) {
container.append(renderTimelineEvent(ev))
}
}
async function poll(): Promise<void> {
if (!visible || disposed) return
const events = await fetchTimeline(id)
if (!disposed) render(events)
}
// Initial fetch
void poll()
// Start interval polling
intervalId = setInterval(() => { void poll() }, refreshMs)
// Pause/resume based on viewport visibility when IntersectionObserver is available
if (typeof IntersectionObserver !== 'undefined') {
observer = new IntersectionObserver((entries) => {
const entry = entries[0]
visible = entry?.isIntersecting ?? true
})
observer.observe(container)
}
return {
dispose(): void {
if (disposed) return
disposed = true
if (intervalId !== null) {
clearInterval(intervalId)
intervalId = null
}
if (observer !== null) {
observer.disconnect()
observer = null
}
},
}
}

179
public/voice.ts Normal file
View File

@@ -0,0 +1,179 @@
/**
* public/voice.ts — Web Speech API wrapper for push-to-talk voice input (A2).
*
* Pure frontend module. Audio NEVER reaches this server — the browser's speech
* engine handles it (Chrome sends audio to Google; see SEC-L2 / AC-A2.5).
* The final transcript is delivered to the caller, who sends it as raw terminal
* input via the existing WS input path (byte-shuttle unchanged).
*
* Usage:
* const voice = createVoiceInput((text) => sendToTerminal(text), { autoSend: true })
* if (!voice) { /* hide mic UI *\/ }
* micBtn.addEventListener('touchstart', () => voice.start())
* micBtn.addEventListener('touchend', () => voice.stop())
*/
// ── Local SpeechRecognition interface (not in TypeScript 6.x DOM lib) ─────────
/** Minimal interface for the Web Speech recognition object. */
interface ISpeechRecognition {
continuous: boolean
interimResults: boolean
lang: string
onresult: ((event: ISpeechRecognitionEvent) => void) | null
onend: (() => void) | null
onerror: ((event: { error: string }) => void) | null
start(): void
stop(): void
abort(): void
}
interface ISpeechRecognitionEvent {
resultIndex: number
results: ISpeechRecognitionResultList
}
interface ISpeechRecognitionResultList {
length: number
[index: number]: ISpeechRecognitionResult | undefined
}
interface ISpeechRecognitionResult {
isFinal: boolean
[index: number]: ISpeechRecognitionAlternative | undefined
}
interface ISpeechRecognitionAlternative {
transcript: string
confidence: number
}
type SpeechRecognitionCtor = new () => ISpeechRecognition
type SpeechRecognitionWindow = Window & {
SpeechRecognition?: SpeechRecognitionCtor
webkitSpeechRecognition?: SpeechRecognitionCtor
}
// ── Public API ────────────────────────────────────────────────────────────────
/** Options for createVoiceInput. */
export interface VoiceInputOptions {
/** Called with intermediate (non-final) text while the user is still speaking. */
onInterim?: (text: string) => void
/**
* When true, the final transcript is suffixed with \r (carriage return) so
* Claude receives it immediately without the user pressing Enter (A2-FR3).
* Default: false — insert text only; user presses Enter manually.
*/
autoSend?: boolean
/**
* BCP-47 language tag, e.g. 'en-US', 'ja-JP'.
* Default: navigator.language (the browser's UI language).
*/
lang?: string
}
/**
* A live voice recognition session. Obtain via createVoiceInput().
* Call dispose() when the owning component unmounts.
*/
export interface VoiceInput {
/** Begin listening (no-op if already active or disposed). */
start(): void
/** Stop listening and emit the final result (no-op if not active). */
stop(): void
/** Abort and clean up; no callbacks fire after this call. */
dispose(): void
/** True while recognition is in progress. */
isActive(): boolean
}
/** True if this browser supports the Web Speech API (standard or webkit prefix). */
export function isSpeechSupported(): boolean {
if (typeof window === 'undefined') return false
return 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window
}
/** Retrieve the SpeechRecognition constructor, handling the webkit prefix. */
function getSRConstructor(): SpeechRecognitionCtor | undefined {
if (!isSpeechSupported()) return undefined
const w = window as SpeechRecognitionWindow
return w.SpeechRecognition ?? w.webkitSpeechRecognition
}
/**
* Create a push-to-talk voice input session.
*
* Returns null when the browser does not support SpeechRecognition — the
* caller should hide the mic UI in that case (AC-A2.3).
*
* @param onTranscript Receives the final recognised text (+ '\r' if autoSend).
* @param opts See VoiceInputOptions.
*/
export function createVoiceInput(
onTranscript: (text: string) => void,
opts?: VoiceInputOptions,
): VoiceInput | null {
const SRClass = getSRConstructor()
if (!SRClass) return null
const recognition = new SRClass()
recognition.continuous = false
recognition.interimResults = opts?.onInterim !== undefined
recognition.lang = opts?.lang ?? navigator.language
let active = false
let disposed = false
recognition.onresult = (event: ISpeechRecognitionEvent) => {
if (disposed) return
let interimText = ''
let finalText = ''
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
const text = result?.[0]?.transcript ?? ''
if (result?.isFinal) {
finalText += text
} else {
interimText += text
}
}
if (interimText && opts?.onInterim) {
opts.onInterim(interimText)
}
if (finalText) {
onTranscript(opts?.autoSend ? finalText + '\r' : finalText)
}
}
recognition.onend = () => {
active = false
}
recognition.onerror = () => {
active = false
}
return {
start(): void {
if (disposed || active) return
active = true
recognition.start()
},
stop(): void {
if (disposed || !active) return
recognition.stop()
active = false
},
dispose(): void {
if (disposed) return
disposed = true
if (active) recognition.abort()
active = false
},
isActive(): boolean {
return active
},
}
}