Files
hapi/web/src/lib/scratchlist.test.ts
T
18bcb522e1 feat(web): per-session scratchlist (workbench) panel (#772)
* 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>
2026-06-04 17:55:29 +08:00

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()
})
})