mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* feat(web): per-session scratchlist (workbench) panel Adds a per-session "scratchlist" panel above the composer for parking notes / drafts / parking-lot ideas that are explicitly held — never auto-sent. This is distinct from the existing queue (QueuedMessagesBar): - Queue = conveyor belt: messages auto-fire once the agent is idle. - Scratchlist = workbench: held until the operator promotes them. The amber accent and "held — not sent" pill make the visual distinction obvious so operators don't mistake one for the other. Features: - Collapsible per-session panel (collapsed by default, persisted in localStorage). - Add (Enter) / delete / reorder (up/down) entries. - Promote-to-composer copies into the composer for editing (entry stays — copy semantics). - Promote-to-queue routes through the existing onSend path so the entry shows up in QueuedMessagesBar; entry is removed only on accepted send. - Entries persist per session under hapi.scratchlist.v1.<sessionId>. - Confirm-on-delete only for entries longer than 100 chars. - Ctrl/Cmd+Shift+S focuses the add-input. - en + zh-CN strings. v1 scope: localStorage-only. Hub-sync deferred to v2 to keep the diff small and reviewable. Test coverage: - web/src/lib/scratchlist.test.ts — 21 tests (storage round-trip, add/delete/reorder/cap, malformed-JSON resilience, confirm threshold). - web/src/components/AssistantChat/ScratchlistPanel.test.tsx — 13 tests (collapse persistence, hydration, add/delete/reorder UI, promote-to-composer copy semantics, promote-to-queue accepted / rejected paths, per-session isolation). Closes #11 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): block focus into collapsed panel via inert Upstream review (tiann/hapi#772, codex bot) flagged that the collapsed scratchlist body was visually hidden via CSS only - the textarea and action buttons stayed mounted, focusable, and clickable while their ancestor was aria-hidden. Tab into invisible controls + a hidden subtree with focusable descendants is an a11y violation. Apply `inert` to the inner content, gated on the collapsed state. This removes the subtree from the focus, pointer, and accessibility trees while keeping the grid-template-rows expand animation intact (no conditional remount, so the open/close transition still runs). Add a regression test that asserts `inert` is present while collapsed and removed (or empty) while expanded, so a future revert of the fix trips immediately. Co-authored-by: Cursor <cursoragent@cursor.com> * test(scratchlist): add Playwright e2e + isolated fixture page The unit suite under jsdom can't verify the parts of the scratchlist that actually live in the browser: - `inert` blocks focus (jsdom ignores `inert`) - the grid-template-rows collapse animation - localStorage surviving a full page reload - per-session keying surviving cross-route navigation - Ctrl/Cmd+Shift+S firing the global expand+focus shortcut Add a Playwright config + spec that drives a real Chromium against a new Vite-served fixture (`web/e2e-fixtures/scratchlist-fixture.html`). The fixture mounts the production `ScratchlistPanel` in isolation inside an `I18nProvider` and exposes the promote callbacks on `window.__scratchlistE2E` so the spec can assert that promote-to- composer and promote-to-queue receive the right text without having to spin up the hub, auth, or socket layer. Nine specs cover: 1. starts collapsed, toggles 2. collapsed inner is `inert` and refuses focus / pointer 3. add: entry appears, draft clears, count updates 4. persistence across full page reload 5. promote-to-composer fires callback (entry stays - copy semantics) 6. promote-to-queue success path (entry removed) 7. promote-to-queue failure path (entry retained for retry) 8. Ctrl+Shift+S expands + focuses input 9. per-session isolation across navigation Wires `bun run test:e2e` and `test:e2e:ui` at the repo root and documents the harness in `web/README.md`. Bumps `playwright` 1.49.1 -> 1.60.0 alongside the new `@playwright/test` dep so the bundled chromium-headless-shell-1223 (Chrome 148) is used; the older 131 binary SIGTRAPs on this kernel during launch. Adds `test-results/` and `playwright-report/` to `.gitignore`. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): key host by session.id to prevent cross-session leak Upstream review (tiann/hapi#772, codex bot follow-up) flagged a state leak across same-route session switches. ScratchlistPanel reads `sessionId` once via `useState(() => readScratchlist(sessionId))` and rehydrates in a `useEffect`. SessionChat stays mounted when the operator switches sessions on the same `/sessions/$sessionId` route, so the panel sees a new `sessionId` prop without unmounting. Effect order during the prop change: 1. render with sessionId=B but stale entries=[A's items] 2. rehydrate effect: setEntries(read(B)) -> queues correction 3. persist effect (deps [sessionId, entries] both changed): persistScratchlist(B, [A's items]) -> writes A into B 4. re-render with sessionId=B, entries=B's items 5. persist effect: persistScratchlist(B, B's items) -> overwrites the bug write The bug is transient (step 3's write is corrected by step 5) but real: any read between steps 3 and 5 (another tab, a SW prefetch, manual inspection) sees A's data under B's key. Fix is one line: `key={props.session.id}` on `<ScratchlistHost>`. React unmounts and remounts the host when the key changes, so the new mount's useState initializer reads B's storage from scratch and never touches B's key with A's data. This is the React-canonical "reset state on prop change" pattern; cleaner than chasing the race inside the panel. Add an e2e regression test that: - installs a `localStorage.setItem` spy in `addInitScript` - mounts the fixture under session A and adds an entry - clears the spy, then switches to session B in-place via `window.__scratchlistE2E.setSessionId('leak-B')` (no page reload) - asserts no recorded write to `hapi.scratchlist.v1.leak-B` contained A's text (catches the transient corrupting write deterministically, before the correction overwrites it) - round-trips back to A to confirm A's storage is intact The fixture grows a `?key=0` mode that drops the host's `key=` prop. Verified red/green: with `key=0` the regression test fails on the spy-detected corrupting write; with the fix in place (default), all 10 e2e specs pass. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
197 lines
7.0 KiB
TypeScript
197 lines
7.0 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
addScratchlistEntry,
|
|
clearScratchlist,
|
|
deleteScratchlistEntry,
|
|
moveScratchlistEntry,
|
|
persistScratchlist,
|
|
readScratchlist,
|
|
SCRATCHLIST_CONFIRM_DELETE_THRESHOLD,
|
|
SCRATCHLIST_MAX_ENTRIES,
|
|
SCRATCHLIST_MAX_TEXT_LENGTH,
|
|
shouldConfirmDelete,
|
|
type ScratchlistEntry,
|
|
} from './scratchlist'
|
|
|
|
const SID = 'session-test'
|
|
|
|
function makeEntry(overrides: Partial<ScratchlistEntry> & { id: string }): ScratchlistEntry {
|
|
return {
|
|
text: 'note',
|
|
createdAt: 1000,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('addScratchlistEntry', () => {
|
|
it('prepends new entries (newest-first ordering)', () => {
|
|
const initial: ScratchlistEntry[] = [makeEntry({ id: 'old', text: 'older' })]
|
|
const { entries, added } = addScratchlistEntry(initial, 'newer', 2000)
|
|
expect(added?.text).toBe('newer')
|
|
expect(added?.createdAt).toBe(2000)
|
|
expect(entries.map((e) => e.text)).toEqual(['newer', 'older'])
|
|
})
|
|
|
|
it('rejects empty / whitespace-only input', () => {
|
|
const initial: ScratchlistEntry[] = [makeEntry({ id: 'a' })]
|
|
expect(addScratchlistEntry(initial, '').added).toBeNull()
|
|
expect(addScratchlistEntry(initial, ' ').added).toBeNull()
|
|
expect(addScratchlistEntry(initial, '\n\t').added).toBeNull()
|
|
expect(addScratchlistEntry(initial, ' ').entries).toBe(initial)
|
|
})
|
|
|
|
it('trims surrounding whitespace before storing', () => {
|
|
const { added } = addScratchlistEntry([], ' hello world \n', 1000)
|
|
expect(added?.text).toBe('hello world')
|
|
})
|
|
|
|
it('truncates entries longer than the per-entry cap rather than rejecting', () => {
|
|
const huge = 'x'.repeat(SCRATCHLIST_MAX_TEXT_LENGTH + 50)
|
|
const { added } = addScratchlistEntry([], huge)
|
|
expect(added).not.toBeNull()
|
|
expect(added!.text.length).toBe(SCRATCHLIST_MAX_TEXT_LENGTH)
|
|
})
|
|
|
|
it('caps the list at SCRATCHLIST_MAX_ENTRIES (drops oldest tail)', () => {
|
|
const initial: ScratchlistEntry[] = []
|
|
for (let i = 0; i < SCRATCHLIST_MAX_ENTRIES; i++) {
|
|
initial.push(makeEntry({ id: `e${i}`, text: `entry-${i}` }))
|
|
}
|
|
const { entries } = addScratchlistEntry(initial, 'fresh')
|
|
expect(entries.length).toBe(SCRATCHLIST_MAX_ENTRIES)
|
|
expect(entries[0]?.text).toBe('fresh')
|
|
// The previous tail entry (oldest) should be dropped after cap-trim.
|
|
expect(entries[entries.length - 1]?.text).toBe(
|
|
initial[SCRATCHLIST_MAX_ENTRIES - 2]?.text
|
|
)
|
|
})
|
|
|
|
it('assigns unique ids to consecutive entries', () => {
|
|
const a = addScratchlistEntry([], 'one').added
|
|
const b = addScratchlistEntry([], 'two').added
|
|
expect(a?.id).toBeTruthy()
|
|
expect(b?.id).toBeTruthy()
|
|
expect(a?.id).not.toBe(b?.id)
|
|
})
|
|
})
|
|
|
|
describe('deleteScratchlistEntry', () => {
|
|
it('removes the entry with the matching id', () => {
|
|
const entries: ScratchlistEntry[] = [
|
|
makeEntry({ id: 'a' }),
|
|
makeEntry({ id: 'b' }),
|
|
makeEntry({ id: 'c' }),
|
|
]
|
|
expect(deleteScratchlistEntry(entries, 'b').map((e) => e.id)).toEqual(['a', 'c'])
|
|
})
|
|
|
|
it('is a no-op for unknown ids', () => {
|
|
const entries: ScratchlistEntry[] = [makeEntry({ id: 'a' })]
|
|
expect(deleteScratchlistEntry(entries, 'missing')).toEqual(entries)
|
|
})
|
|
})
|
|
|
|
describe('moveScratchlistEntry', () => {
|
|
function ids(entries: ScratchlistEntry[]): string[] {
|
|
return entries.map((e) => e.id)
|
|
}
|
|
|
|
const sample: ScratchlistEntry[] = [
|
|
makeEntry({ id: 'a' }),
|
|
makeEntry({ id: 'b' }),
|
|
makeEntry({ id: 'c' }),
|
|
]
|
|
|
|
it('moves an entry up by one position', () => {
|
|
expect(ids(moveScratchlistEntry(sample, 'b', 'up'))).toEqual(['b', 'a', 'c'])
|
|
})
|
|
|
|
it('moves an entry down by one position', () => {
|
|
expect(ids(moveScratchlistEntry(sample, 'b', 'down'))).toEqual(['a', 'c', 'b'])
|
|
})
|
|
|
|
it('is a no-op when moving the first entry up', () => {
|
|
expect(moveScratchlistEntry(sample, 'a', 'up')).toBe(sample)
|
|
})
|
|
|
|
it('is a no-op when moving the last entry down', () => {
|
|
expect(moveScratchlistEntry(sample, 'c', 'down')).toBe(sample)
|
|
})
|
|
|
|
it('is a no-op for unknown ids', () => {
|
|
expect(moveScratchlistEntry(sample, 'missing', 'up')).toBe(sample)
|
|
})
|
|
})
|
|
|
|
describe('shouldConfirmDelete', () => {
|
|
it('confirms only when entry text exceeds the threshold', () => {
|
|
const short = makeEntry({ id: 'a', text: 'x'.repeat(SCRATCHLIST_CONFIRM_DELETE_THRESHOLD) })
|
|
const long = makeEntry({ id: 'b', text: 'x'.repeat(SCRATCHLIST_CONFIRM_DELETE_THRESHOLD + 1) })
|
|
expect(shouldConfirmDelete(short)).toBe(false)
|
|
expect(shouldConfirmDelete(long)).toBe(true)
|
|
})
|
|
|
|
it('returns false for null / undefined entries', () => {
|
|
expect(shouldConfirmDelete(null)).toBe(false)
|
|
expect(shouldConfirmDelete(undefined)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('localStorage round-trip', () => {
|
|
beforeEach(() => {
|
|
localStorage.clear()
|
|
})
|
|
|
|
it('persists and reads back entries scoped per session', () => {
|
|
const entriesA: ScratchlistEntry[] = [
|
|
makeEntry({ id: 'a1', text: 'a-one' }),
|
|
makeEntry({ id: 'a2', text: 'a-two' }),
|
|
]
|
|
const entriesB: ScratchlistEntry[] = [makeEntry({ id: 'b1', text: 'b-one' })]
|
|
persistScratchlist('session-a', entriesA)
|
|
persistScratchlist('session-b', entriesB)
|
|
|
|
expect(readScratchlist('session-a')).toEqual(entriesA)
|
|
expect(readScratchlist('session-b')).toEqual(entriesB)
|
|
})
|
|
|
|
it('returns [] for an unknown session', () => {
|
|
expect(readScratchlist('never-written')).toEqual([])
|
|
})
|
|
|
|
it('clears entries for a session', () => {
|
|
persistScratchlist(SID, [makeEntry({ id: 'a' })])
|
|
clearScratchlist(SID)
|
|
expect(readScratchlist(SID)).toEqual([])
|
|
})
|
|
|
|
it('returns [] when stored value is malformed JSON', () => {
|
|
localStorage.setItem(`hapi.scratchlist.v1.${SID}`, '{not-json')
|
|
expect(readScratchlist(SID)).toEqual([])
|
|
})
|
|
|
|
it('skips invalid entries inside the stored array (forward compatibility)', () => {
|
|
const valid = makeEntry({ id: 'valid', text: 'ok' })
|
|
localStorage.setItem(
|
|
`hapi.scratchlist.v1.${SID}`,
|
|
JSON.stringify([
|
|
valid,
|
|
{ id: '', text: 'no id', createdAt: 1 }, // invalid id
|
|
{ id: 'x', text: 5, createdAt: 1 }, // wrong text type
|
|
'string entry', // wrong shape
|
|
null,
|
|
])
|
|
)
|
|
const got = readScratchlist(SID)
|
|
expect(got).toEqual([valid])
|
|
})
|
|
|
|
it('survives localStorage write failures', () => {
|
|
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
throw new Error('quota exceeded')
|
|
})
|
|
expect(() => persistScratchlist(SID, [makeEntry({ id: 'a' })])).not.toThrow()
|
|
setItem.mockRestore()
|
|
})
|
|
})
|