Files
hapi/web/src/lib/scratchlist.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

193 lines
6.0 KiB
TypeScript

/**
* Per-session scratchlist storage (issue #11).
*
* The scratchlist is the operator's *workbench*: notes / drafts / parking lot
* entries that are explicitly **not** queued for sending. Compare to the
* queue (`QueuedMessagesBar`), which is a conveyor belt that auto-fires
* messages in order. Scratchlist entries are held until the operator
* promotes them (to the composer or into the queue) or deletes them.
*
* Storage is per-session in `localStorage` under
* `hapi.scratchlist.v1.<sessionId>` so entries survive reloads but stay
* scoped to a single conversation. Hub-sync is intentionally deferred
* (v2) to keep this PR small.
*/
const STORAGE_KEY_PREFIX = 'hapi.scratchlist.v1.'
/** Hard upper bound to keep payloads sane and rule out runaway growth. */
export const SCRATCHLIST_MAX_ENTRIES = 200
/** Per-entry text cap: matches what a long composer paste can produce. */
export const SCRATCHLIST_MAX_TEXT_LENGTH = 10_000
export type ScratchlistEntry = {
id: string
text: string
createdAt: number
}
function getStorageKey(sessionId: string): string {
return `${STORAGE_KEY_PREFIX}${sessionId}`
}
function getLocalStorage(): Storage | null {
if (typeof window === 'undefined') {
return null
}
try {
return window.localStorage
} catch {
return null
}
}
function isEntry(value: unknown): value is ScratchlistEntry {
if (!value || typeof value !== 'object') return false
const entry = value as Record<string, unknown>
return (
typeof entry.id === 'string'
&& entry.id.length > 0
&& typeof entry.text === 'string'
&& typeof entry.createdAt === 'number'
&& Number.isFinite(entry.createdAt)
)
}
export function readScratchlist(sessionId: string): ScratchlistEntry[] {
if (!sessionId) return []
const storage = getLocalStorage()
if (!storage) return []
let raw: string | null
try {
raw = storage.getItem(getStorageKey(sessionId))
} catch {
return []
}
if (!raw) return []
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return []
}
if (!Array.isArray(parsed)) return []
const entries: ScratchlistEntry[] = []
for (const item of parsed) {
if (isEntry(item)) entries.push(item)
if (entries.length >= SCRATCHLIST_MAX_ENTRIES) break
}
return entries
}
function writeScratchlist(sessionId: string, entries: ScratchlistEntry[]): void {
if (!sessionId) return
const storage = getLocalStorage()
if (!storage) return
try {
const trimmed = entries.slice(0, SCRATCHLIST_MAX_ENTRIES)
storage.setItem(getStorageKey(sessionId), JSON.stringify(trimmed))
} catch {
// Storage quota or serialization failures are non-fatal: the in-memory
// copy still works for the rest of the session.
}
}
function makeEntryId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `scratch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
/**
* Append a new entry to the scratchlist. Returns the new entry list (or
* the previous list unchanged when text is empty / would exceed the cap).
*
* Trimming behavior: leading/trailing whitespace stripped; empty input
* is rejected (returns the input list unchanged). Entries longer than
* `SCRATCHLIST_MAX_TEXT_LENGTH` are truncated rather than rejected so
* pasting a giant blob still ends up captured.
*/
export function addScratchlistEntry(
entries: ScratchlistEntry[],
rawText: string,
now: number = Date.now()
): { entries: ScratchlistEntry[]; added: ScratchlistEntry | null } {
const text = rawText.trim()
if (text.length === 0) {
return { entries, added: null }
}
const truncated = text.length > SCRATCHLIST_MAX_TEXT_LENGTH
? text.slice(0, SCRATCHLIST_MAX_TEXT_LENGTH)
: text
const entry: ScratchlistEntry = {
id: makeEntryId(),
text: truncated,
createdAt: now,
}
// Newest-first ordering: matches the way operators read the workbench
// (most recent thought at the top, scrolling down for older).
const next = [entry, ...entries].slice(0, SCRATCHLIST_MAX_ENTRIES)
return { entries: next, added: entry }
}
export function deleteScratchlistEntry(
entries: ScratchlistEntry[],
id: string
): ScratchlistEntry[] {
return entries.filter((e) => e.id !== id)
}
/**
* Move an entry up (toward index 0) or down (toward the end). Out-of-range
* moves are no-ops so the UI can call this unconditionally without first
* checking position.
*/
export function moveScratchlistEntry(
entries: ScratchlistEntry[],
id: string,
direction: 'up' | 'down'
): ScratchlistEntry[] {
const index = entries.findIndex((e) => e.id === id)
if (index < 0) return entries
const swapWith = direction === 'up' ? index - 1 : index + 1
if (swapWith < 0 || swapWith >= entries.length) return entries
const next = [...entries]
const tmp = next[index]
const other = next[swapWith]
if (!tmp || !other) return entries
next[index] = other
next[swapWith] = tmp
return next
}
export function persistScratchlist(sessionId: string, entries: ScratchlistEntry[]): void {
writeScratchlist(sessionId, entries)
}
export function clearScratchlist(sessionId: string): void {
if (!sessionId) return
const storage = getLocalStorage()
if (!storage) return
try {
storage.removeItem(getStorageKey(sessionId))
} catch {
// Non-fatal.
}
}
/**
* Confirm-on-delete threshold. Trivial entries delete instantly; longer
* notes deserve a confirmation prompt so a stray click doesn't lose work.
* Threshold tuned to "anything longer than a one-line reminder".
*/
export const SCRATCHLIST_CONFIRM_DELETE_THRESHOLD = 100
export function shouldConfirmDelete(entry: ScratchlistEntry | null | undefined): boolean {
if (!entry) return false
return entry.text.length > SCRATCHLIST_CONFIRM_DELETE_THRESHOLD
}