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:
347
public/diff.ts
Normal file
347
public/diff.ts
Normal 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 = ''
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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
354
public/push.ts
Normal 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
279
public/quick-reply.ts
Normal 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
82
public/sw-push.js
Normal 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)}`,
|
||||
}
|
||||
}
|
||||
71
public/sw.js
71
public/sw.js
@@ -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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
309
public/tabs.ts
309
public/tabs.ts
@@ -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/dot — never 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()
|
||||
}
|
||||
|
||||
@@ -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
226
public/timeline.ts
Normal 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
179
public/voice.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user