feat(v0.3): H3 — remote approve/reject (no typing)

- types/protocol: ClientMessage approve/reject; ServerMessage status.pending
- server: POST /hook/permission HELD until the client decides; approve/reject
  ws messages resolve it with {hookSpecificOutput.decision.behavior allow|deny};
  5-min timeout + release-on-disconnect fall back to Claude's own prompt
- manager.handleHookEvent threads pending flag
- setup-hooks: PermissionRequest uses the held curl (writes decision to stdout);
  status events stay fire-and-forget
- FE: terminal-session approve()/reject() + pending state; tabs approval banner
  (Approve/Reject) for the active tab's held request
- Tests: protocol approve/reject; integration ⑧ held→approve→allow. 207 green.
- Browser-verified: banner 'Claude wants to use Bash' → Approve → resolves allow.
This commit is contained in:
Yaojia Wang
2026-06-17 19:13:07 +02:00
parent 04355f05e9
commit 9a6150c355
10 changed files with 279 additions and 23 deletions

View File

@@ -43,13 +43,47 @@ export class TabApp {
private notifyAsked = false
private readonly paneHost: HTMLElement
private readonly tabBar: HTMLElement
private readonly approvalBar: HTMLDivElement
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
this.paneHost = paneHost
this.tabBar = tabBar
this.approvalBar = document.createElement('div')
this.approvalBar.id = 'approvalbar'
this.approvalBar.style.display = 'none'
document.body.appendChild(this.approvalBar)
this.restore()
}
/** Show/hide the approve/reject banner for the active tab's held request (H3). */
private updateApprovalBar(): void {
const session = this.tabs[this.activeIndex]?.session
if (!session || !session.pendingApproval) {
this.approvalBar.style.display = 'none'
return
}
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)
this.approvalBar.style.display = 'flex'
}
private displayTitle(entry: TabEntry, idx: number): string {
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
}
@@ -138,6 +172,7 @@ export class TabApp {
onStatus: () => this.refreshTab(entry),
onClaudeStatus: (status) => {
this.refreshTab(entry)
this.updateApprovalBar()
// Notify when a background tab needs approval (H2/H4).
if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) {
this.notify(entry)
@@ -165,6 +200,7 @@ export class TabApp {
if (entry) entry.hasActivity = false // viewing clears the unread dot
this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide()))
this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild
this.updateApprovalBar()
this.persist()
}