feat(queue): server-side PTY-inject + idle-drained follow-up queue (W2)
The walk-away primitive: queue follow-up prompts and let a session advance itself
while you're gone — "run the tests" drains, and when Claude next goes idle "open a
PR" drains. Injection reuses writeInput (byte-identical to a keystroke, broadcasts
to all mirrored devices); the byte-shuttle is untouched.
- POST/GET/DELETE /live-sessions/:id/queue — POST/DELETE behind requireAllowedOrigin
+ a per-IP rate limit (QUEUE_RATE_MAX=20/min) + SESSION_ID_RE; text non-empty,
byte-capped (QUEUE_ITEM_MAX_BYTES=4096, 16kb body), count-capped (QUEUE_MAX_ITEMS=10).
- Bounded per-session queue in manager (enqueueFollowup/drainOne/clearQueue);
new optional queueLength on LiveSessionInfo.
- Idle drain: the Stop/SessionEnd /hook branch scheduleDrain()s a debounced timer
(QUEUE_SETTLE_MS=1500). It drains exactly one entry only if the session still
exists, hasn't exited, produced no output during settle, and is idle — three
guards (debounced single timer + pop-one + settle re-check) → one entry per idle.
- New additive ServerMessage {type:'queue',length} broadcasts the count to mirrors
(⧗N badge); FE enqueue via the quick-reply editor + TabApp.enqueueToActive.
Injected bytes go verbatim to the PTY (never built into a shell command). Verified
independently: typecheck + build:web clean, 1737 tests pass (real-PTY integration
covers idle-drain / one-per-idle / settle-guard). Foundation for templated launches,
auto-continue, and issue-intake (docs/ROADMAP.md).
This commit is contained in:
@@ -549,4 +549,45 @@ describe('mountQuickReply — DOM', () => {
|
||||
expect(countAfter).toBe(countBefore)
|
||||
handle.dispose()
|
||||
})
|
||||
|
||||
// ── W2: the editor's "Queue" button (only shown when onQueue is wired) ────────
|
||||
it('shows no Queue button when onQueue is not provided', () => {
|
||||
const container = document.createElement('div')
|
||||
const handle = mountQuickReply(container, { onSend: vi.fn() })
|
||||
;(container.querySelector('.qr-add-btn') as HTMLButtonElement).click()
|
||||
expect(container.querySelector('.qr-editor-queue')).toBeNull()
|
||||
handle.dispose()
|
||||
})
|
||||
|
||||
it('Queue button calls onQueue with the typed text + appendEnter and closes the editor', () => {
|
||||
const container = document.createElement('div')
|
||||
const onQueue = vi.fn()
|
||||
const handle = mountQuickReply(container, { onSend: vi.fn(), onQueue })
|
||||
;(container.querySelector('.qr-add-btn') as HTMLButtonElement).click()
|
||||
|
||||
const textInput = container.querySelector('.qr-editor-text') as HTMLInputElement
|
||||
textInput.value = 'run tests then open a PR'
|
||||
const enterCheck = container.querySelector('.qr-editor-enter') as HTMLInputElement
|
||||
enterCheck.checked = true
|
||||
|
||||
;(container.querySelector('.qr-editor-queue') as HTMLButtonElement).click()
|
||||
|
||||
expect(onQueue).toHaveBeenCalledWith('run tests then open a PR', true)
|
||||
// Enqueue must NOT persist a chip nor send immediately.
|
||||
expect(loadPalette().some((c) => c.text === 'run tests then open a PR')).toBe(false)
|
||||
expect(container.querySelector('.qr-editor')).toBeNull()
|
||||
handle.dispose()
|
||||
})
|
||||
|
||||
it('Queue button with empty text is a no-op (onQueue not called, editor stays)', () => {
|
||||
const container = document.createElement('div')
|
||||
const onQueue = vi.fn()
|
||||
const handle = mountQuickReply(container, { onSend: vi.fn(), onQueue })
|
||||
;(container.querySelector('.qr-add-btn') as HTMLButtonElement).click()
|
||||
;(container.querySelector('.qr-editor-queue') as HTMLButtonElement).click()
|
||||
|
||||
expect(onQueue).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('.qr-editor')).not.toBeNull()
|
||||
handle.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user