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:
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user