feat(web): scratchlist v1.1 — composer-toggle drawer + reusable FUE primitive#798
feat(web): scratchlist v1.1 — composer-toggle drawer + reusable FUE primitive#798heavygee wants to merge 10 commits into
Conversation
…rimitive The v1 always-visible amber band proved too heavy for what's a 20% feature in a typical session. v1.1 dials it back to a composer-toggle that opens an on-demand drawer, paired with a reusable FUE (First-User Experience) primitive so existing operators get a subtle pulsing dot + on-click explainer the first time they see the toggle. UX changes: - Notepad icon in the composer toolbar (next to schedule-send) toggles scratchlist mode. Drawer renders only while mode is on. - Composer's send button repaints amber and reads "Send to scratchlist" while the mode is sticky; submit routes adds into the scratchlist instead of the chat. Click the icon again to leave. - Small entry-counter badge appears on the toggle when entries exist; empty-state shows just the icon (no zero-state guilt UI). New reusable FUE primitive: - web/src/lib/use-fue.ts: state machine (unseen → engaging → acknowledged) with localStorage persistence, namespaced under hapi.fue.v1.<featureId> so it can't collide with any future upstream onboarding flow. - web/src/components/Fue.tsx: <FueDot> (small pulsing badge) and <FueCallout> (portal-rendered popover with title/body + "Got it" affirmative-action dismiss). No auto-timeout — reading speed varies and silent disappearance undercuts user trust. - AGENTS.md adds a "Adding new web features — consider an FUE" section so future contributors discover the primitive. Refactors: - ScratchlistPanel.tsx: split rendering into <ScratchlistInventory> (presentational list) and <ScratchlistDrawer> (composer-controlled drawer with hint copy). Original <ScratchlistPanel> kept exported for the existing fixture-based tests. - SessionChat.tsx: scratchlist state lifted into useScratchlist hook so the composer-toolbar counter and the drawer share one source of truth. onSend wrapped to route through scratchlist.add when mode is on. Tests: - 9 useFue hook tests (initial state, engage idempotency, no auto-acknowledge, dismiss, featureId switching, post-acknowledged engage no-op, resetFue helper). - 5 placement helper tests (above/below switching, viewport edge clamping, visualViewport offset support). - All 21 existing scratchlist lib tests + 14 ScratchlistPanel tests continue to pass. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Prevent session switches from overwriting scratchlists —
useScratchlistpersists currententrieswheneversessionIdchanges. On A -> B navigation, React first commits with B's id and A's entries; after paint, this persist effect can write A's entries tohapi.scratchlist.v1.Bbefore the rehydrate effect loads B. The previous keyed panel existed specifically to avoid this race. Evidenceweb/src/lib/use-scratchlist.ts:27.
Suggested fix:const [{ sessionId: loadedSessionId, entries }, setScratchlist] = useState(() => ({ sessionId, entries: readScratchlist(sessionId), })) useEffect(() => { setScratchlist({ sessionId, entries: readScratchlist(sessionId) }) }, [sessionId]) useEffect(() => { persistScratchlist(loadedSessionId, entries) }, [loadedSessionId, entries])
Questions
- None.
Summary
- Review mode: initial
- One data-loss regression found in the new lifted scratchlist hook. Residual risk: the drawer path lacks a regression test for switching sessions with stored scratchlist entries.
Testing
- Not run (automation). Suggested: add a hook/component regression test that seeds sessions A and B in localStorage, rerenders with B, and asserts B storage is not overwritten by A entries.
HAPI Bot
Per upstream review on PR tiann#798 (github-actions[bot] [Major]): > useScratchlist persists current `entries` whenever `sessionId` > changes. On A -> B navigation, React first commits with B's id > and A's entries; after paint, this persist effect can write A's > entries to hapi.scratchlist.v1.B before the rehydrate effect > loads B. The previous keyed panel existed specifically to avoid > this race. Lifting state out of the v1 panel (which sidestepped the race via key={props.session.id} forced remount) re-introduced this same data- loss window. The composer-controlled drawer in v1.1 cannot remount on session change because its parent SessionChat doesn't either. Fix: keep the loaded sessionId in state alongside the entries so they swap atomically, and persist against the LOADED sessionId rather than the prop. After A->B, the loaded sessionId is still A until rehydrate runs, so a spurious persist re-writes A's storage with A's entries - a no-op instead of a corruption. Tests: - New use-scratchlist.test.ts with 6 tests: - hydrates from localStorage on mount - add() persists to current session's storage only - rerender to a new session preserves the new session's existing entries - after switching, add() targets the new session - regression test that spies on Storage.prototype.setItem and asserts the rerender lifecycle never produces a (B-key, A-entries) write - remove()/move() target the loaded sessionId - The setItem-spy test correctly fails against the buggy code (verified by temporarily reverting the fix) and passes with the fix in place. - Full web suite: 88 files, 756 tests, all green. Typecheck clean. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Prevent scratchlist mode from dropping attachments — in scratchlist mode the wrapper returns success after adding only
text, whileHappyComposerstill treats composer attachments as sendable input. A text+attachment submit therefore routes through this branch, stores only the text, and silently discards the attachment instead of sending or preserving it. Evidenceweb/src/components/SessionChat.tsx:205.
Suggested fix:// HappyComposer.tsx const scratchlistMode = props.scratchlistMode ?? false const canSend = scratchlistMode ? hasText && !hasAttachments && attachmentsReady && !controlsDisabled : (hasText || hasAttachments) && attachmentsReady && !controlsDisabled // SessionChat.tsx if (scratchlistMode) { if ((attachments?.length ?? 0) > 0) return false return scratchlist.add(text) }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The previous cross-session scratchlist storage finding is addressed by the loaded-session state pair and regression tests. One remaining attachment-loss regression found in the new scratchlist composer routing.
Testing
- Not run (automation). Suggested: add a composer/scratchlist-mode test covering a text+attachment draft and assert the scratchlist route is rejected or the send button is disabled while attachments are present.
HAPI Bot
…of dropping them Per upstream review on PR tiann#798 (github-actions[bot] [Major]): > Prevent scratchlist mode from dropping attachments — in scratchlist > mode the wrapper returns success after adding only `text`, while > HappyComposer still treats composer attachments as sendable input. > A text+attachment submit therefore routes through this branch, > stores only the text, and silently discards the attachment instead > of sending or preserving it. Same hazard applies to scheduledAt: scratchlist entries are pure-text notes - they can't represent attachments or schedule metadata - so any submit carrying either MUST fall through to props.onSend (chat) even when the scratchlist toggle is on. Otherwise the wrapper short-circuits to scratchlist.add(text), reports success to the composer, and the composer dutifully clears attachments + schedule that the user just queued. Fix: extracted the routing rule into shouldRouteToScratchlist(mode, attachments, scheduledAt) - returns true only when mode is on AND the payload is pure text. onSendForComposer uses it. Tests: - 5 new shouldRouteToScratchlist unit tests (mode off, mode on + text-only, mode on + attachments, mode on + schedule, mode on + both) - All in web/src/components/SessionChat.test.ts (13 tests total now) - Full web suite: 88 files, 761 tests, all green. Typecheck clean. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Clear accepted scheduled chat sends after scratchlist fallback — the follow-up change correctly falls through to
props.onSendwhen scratchlist mode is on butscheduledAtis present, yet the accepted-send cleanup still checks only!scratchlistMode. That means a scheduled chat send made while the amber scratchlist UI is active is accepted, butpendingSchedulestays set, so the next normal send can accidentally reuse the same schedule. Evidenceweb/src/components/SessionChat.tsx:630.
Suggested fix:const routedToScratchlist = shouldRouteToScratchlist(scratchlistMode, attachments, scheduledAt) const accepted = await onSendForComposer(text, attachments, scheduledAt) if (!accepted) return if (!routedToScratchlist) { setPendingSchedule(null) setForceScrollToken((token) => token + 1) }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The previous attachment-loss finding is addressed by falling non-text scratchlist-mode payloads back to normal chat. One remaining cleanup regression exists for scheduled sends that use that fallback path.
Testing
- Not run (automation). Suggested: add a
handleSend/integration test where scratchlist mode is on,pendingScheduleis set, andshouldRouteToScratchlistfalls through; assertpendingScheduleclears after accepted chat send.
HAPI Bot
…ion falls back to chat Per upstream review on PR tiann#798 (github-actions[bot] [Major]): > The follow-up change correctly falls through to props.onSend when > scratchlist mode is on but scheduledAt is present, yet the > accepted-send cleanup still checks only !scratchlistMode. That > means a scheduled chat send made while the amber scratchlist UI is > active is accepted, but pendingSchedule stays set, so the next > normal send can accidentally reuse the same schedule. Fix: handleSend now gates the cleanup branch on the actual route taken (routedToScratchlist) rather than the scratchlist UI state. Reuses the same shouldRouteToScratchlist helper so route + cleanup share a single source of truth. Tests: - 2 new tests in SessionChat.test.ts that pin the decision matrix handleSend depends on: - 'cleanup gate: scheduled chat send while scratchlist toggle is on still clears schedule' - 'cleanup gate: pure-text scratchlist add does NOT clear schedule' - Full web suite: 88 files, 763 tests, all green. Typecheck clean. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Send button advertises scratchlist routing even when the submit will go to chat —
shouldRouteToScratchlistcorrectly falls back to normal chat for attachments orscheduledAt, butUnifiedButtonstill turns amber and labels the action as “Send to scratchlist” wheneverscratchlistModeis true. A scheduled send or attachment send made in that state will be submitted to chat while the UI says it is being stashed, which can send content to the agent unexpectedly. Evidenceweb/src/components/AssistantChat/ComposerButtons.tsx:379.
Suggested fix:const routesToScratchlist = scratchlistMode && !props.hasAttachments && props.pendingSchedule == null // pass routesToScratchlist to UnifiedButton instead of raw scratchlistMode, // or compute it inside UnifiedButton from these props. <UnifiedButton ... scratchlistMode={routesToScratchlist} />
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The previous bot finding about scheduled-send cleanup is addressed at the current head. One remaining correctness issue: the composer button state can still claim scratchlist routing while the actual send path falls back to chat for scheduled/attached payloads.
Testing
- Not run:
bunis unavailable in this runner (bun: command not found). Suggested coverage: a ComposerButtons/SessionChat case withscratchlistMode=truepluspendingScheduleor attachments asserting the send button uses normal chat labeling/state.
HAPI Bot
…scratchlist toggle Per upstream review on PR tiann#798 (github-actions[bot] [Major]): > Send button advertises scratchlist routing even when the submit > will go to chat — shouldRouteToScratchlist correctly falls back > to normal chat for attachments or scheduledAt, but UnifiedButton > still turns amber and labels the action as "Send to scratchlist" > whenever scratchlistMode is true. A scheduled send or attachment > send made in that state will be submitted to chat while the UI > says it is being stashed, which can send content to the agent > unexpectedly. Fix: - UnifiedButton's prop renamed `scratchlistMode` -> `routesToScratchlist` to make the contract explicit: "this submit really will go to the scratchlist", not "the scratchlist toggle is on". - The call site computes `routesToScratchlist` from `scratchlistMode && !hasAttachments && pendingSchedule == null`, mirroring SessionChat's shouldRouteToScratchlist exactly. The button is now amber + "Send to scratchlist" only when the actual send path will hit scratchlist; attachments / pending schedule force a chat- style render that matches the real routing. - UnifiedButton exported so it can be unit-tested directly. Tests: - 3 new render tests in ComposerButtons.test.tsx covering: - routesToScratchlist=true → amber + "Send to scratchlist" - routesToScratchlist=false → black + "Send" (the regression case) - omitted prop → defaults to chat-style render - Full web suite: 89 files, 766 tests, all green. Typecheck clean. Co-authored-by: Cursor <cursoragent@cursor.com>
…e composer Per upstream review on PR tiann#798 (HAPI Bot, follow-up after b256fe5): > Found one major issue: promoting a scratchlist item to the composer > keeps scratchlist mode enabled, so the next send re-adds it to the > scratchlist instead of sending to chat. Promoting an entry to the composer means "I want to send this for real now". With scratchlist mode still on, the next composer submit routes back to scratchlist (per the v1.1 modal-mode contract), so the user's click loop becomes promote -> send -> re-add -> nothing-actually-sent. Fix: ScratchlistDrawerHost now calls onExitScratchlistMode whenever it promotes an entry to the composer. Promote-to-queue does NOT exit the mode (queue path bypasses the wrapper anyway, and the operator may still be capturing related notes). Tests: - Exported ScratchlistDrawerHost so its host-level callbacks can be unit-tested in isolation (previously only ScratchlistDrawer was testable; the wiring was untested). - New SessionChat.exit-mode.test.tsx with 2 tests: - promote-to-composer fires setText AND onExitScratchlistMode - promote-to-queue fires onSend but does NOT exit mode - Full web suite: 90 files, 768 tests, all green. Typecheck clean. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Caught on the new pass — fixed in Exported Web suite 768/768 green; typecheck clean. Side note: this finding only showed up in the workflow log ( |
There was a problem hiding this comment.
Findings
- No findings.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- No issues found in the current diff. Prior bot finding about the send button/scratchlist routing mismatch is addressed at
web/src/components/AssistantChat/ComposerButtons.tsx:632, with targeted coverage added for that contract and for promote-to-composer mode exit. - Residual risk: tests were not executed in this review context.
Testing
- Not run (automation; review-only).
HAPI Bot
…tkey) The v1 always-visible panel had Ctrl/Cmd+Shift+S to expand the panel and focus the input. v1.1 mounts the drawer only when scratchlistMode is on, so the v1 listener (inside the panel) is dead code: it can't fire while the drawer is unmounted, and the user has no way to open the drawer without clicking the toolbar icon. Re-bind the shortcut at SessionChat scope so it's always alive and toggles the mode. Convention matches sibling globals (Ctrl/Cmd-m cycles agent model). Ctrl/Cmd-Shift-S is unreserved by Chrome / Firefox / Safari (browser Save As is Ctrl-S / Cmd-S, no Shift), so the user's save-page muscle memory keeps working. Modifier requirement (Ctrl/Cmd+Shift) means it can't collide with literal-character typing in any input - no focus suppression needed. The matcher is extracted to a pure helper isScratchlistToggleHotkey so it's unit testable without mounting SessionChat. 6 new tests pin the modifier matrix: - Ctrl+Shift+S (Linux/Windows) -> match - Cmd+Shift+S (macOS) -> match - Cmd/Ctrl+S without Shift -> reject (browser Save reservation) - bare S / Shift+S -> reject (literal typing) - Ctrl+Shift+Alt+S -> reject (avoid OS clashes) - other modifier+key combos -> reject Tooltip + FUE body now mention the hotkey so it's discoverable from the same UI surface that introduces the feature (en + zh-CN). Web suite 90 files / 774 tests, all green. Typecheck clean. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Global scratchlist hotkey fires while other controls/modals are focused — The new
windowlistener accepts Ctrl/Cmd+Shift+S for every event target and immediately prevents default/toggles scratchlist mode.SessionChatcan have focused inputs in overlays such as rename or schedule dialogs; pressing that shortcut there can turn scratchlist mode on behind the modal, so the next composer send may be routed to scratchlist instead of chat. Evidenceweb/src/components/SessionChat.tsx:265.
Suggested fix:function isScratchlistHotkeyBlockedTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false if (target.closest('[role="dialog"]')) return true return target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target.isContentEditable } useEffect(() => { const onKeyDown = (e: globalThis.KeyboardEvent) => { if (!isScratchlistToggleHotkey(e)) return if (isScratchlistHotkeyBlockedTarget(e.target)) return e.preventDefault() setScratchlistMode((m) => !m) } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [])
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Found one issue in the latest full diff: the scratchlist global shortcut needs to ignore dialog/form targets before toggling mode.
- Residual risk: I did not execute the web test suite in this review context.
Testing
- Suggested: add an RTL test that dispatches Ctrl/Cmd+Shift+S from an input inside a dialog and asserts scratchlist mode does not toggle; keep the existing composer/global toggle coverage.
- Not run (automation; review-only).
HAPI Bot
Bot finding on PR tiann#798 (PRRT_kwDOQuQOSc6HGtLn): the window-level Ctrl/Cmd+Shift+S listener fires for every focus target, so the shortcut can toggle scratchlist mode "behind" an open modal (rename session, schedule picker, FUE callout, image preview), making the next composer send route to scratchlist instead of chat. UX bug. Add isScratchlistHotkeyBlockedTarget(target) and gate the listener on it. Block targets: - any descendant of an open [role="dialog"] (Radix UI's DialogContent renders role="dialog"; FueCallout, ScheduleTimePicker, ImagePreview also use role="dialog") - HTMLInputElement (single-line inputs) - HTMLSelectElement - any contentEditable host (with attribute-based fallback for jsdom, which doesn't implement isContentEditable) NOT blocked: - HTMLTextAreaElement (the composer textarea is the expected focus target when the operator presses the hotkey - blocking would defeat the shortcut) - the document body / unfocused targets 8 new unit tests pin the matrix. Function is exported / pure so callers can reuse the same blocked-target rule for future global shortcuts. Suggested fix from the bot applied modulo: - Use !== null on closest() result (explicit boolean for return type) - Add attribute-based contentEditable fallback for jsdom test env
There was a problem hiding this comment.
Findings
- No issues found in the current full diff.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The prior HAPI Bot shortcut concern is addressed in the current head with target filtering for dialog/input/contentEditable focus and focused unit coverage.
- Residual risk: no browser/e2e run in this review context; FUE popover placement and composer drawer behavior still rely on existing/manual browser coverage.
Testing
- Not run (automation; review-only).
HAPI Bot
Add a per-entry "Copy to clipboard" button between the send-to-queue and
delete actions. On click, write the entry text via the shared
safeCopyToClipboard helper (which already handles the navigator.clipboard
primary path + the execCommand fallback for Safari / non-secure-context
edges); on success, briefly flip the icon to a check and the
aria-label/title to "Copied!" for 1500ms so the operator gets visual +
screen-reader confirmation. Failures (clipboard denied AND execCommand
fallback unavailable) silently no-op rather than throw at the click
handler.
Mirrored across both surfaces:
- ScratchlistInventory (used by the v1.1 composer-toggle drawer)
- ScratchlistPanel inline list (the v1 always-visible panel)
A small useCopiedFeedback() hook owns the "which entry just got copied"
state + the 1.5s auto-clear timeout. Pure state machine; the caller
wires safeCopyToClipboard separately so the hook itself stays free of
jsdom clipboard quirks. Cleared on unmount via the standard ref-tracked
timeout pattern, so promote-and-navigate-away can't leak.
Locale keys: scratchlist.action.copy / scratchlist.action.copied (en + zh-CN).
Three new tests:
- v1 panel happy path: writeText called with the entry text, button
flips to the "Copied!" label, entry is preserved (copy is non-destructive).
- v1 panel failure path: writeText rejects AND execCommand returns
false; button stays in "Copy to clipboard" state — no false success.
- v1.1 drawer happy path: writeText called, label flips, and crucially
no other entry handlers (onSend, onDelete, setText, onExitScratchlistMode)
fire — copy is independent of all the other actions.
Web suite 90 files / 785 tests, all green. Typecheck clean.
There was a problem hiding this comment.
Findings
- [Major] Session switch can show the previous session's scratchlist —
scratchlistModeis reset in an effect, anduseScratchlistalso rehydrates in an effect. WhenSessionChatis reused for/sessions/A -> /sessions/B, the first B render can still have A'sscratchlistModeand A's entries/counter before those effects run, exposing or acting on notes from the wrong session. Evidenceweb/src/components/SessionChat.tsx:274andweb/src/lib/use-scratchlist.ts:51.
Suggested fix:type SessionChatProps = { // existing props object } export function SessionChat(props: SessionChatProps) { return <SessionChatInner key={props.session.id} {...props} /> } function SessionChatInner(props: SessionChatProps) { const scratchlist = useScratchlist(props.session.id) const [scratchlistMode, setScratchlistMode] = useState(false) // existing implementation; remove the effect-only session reset }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- One issue found: session changes need a synchronous remount/reset for the newly lifted scratchlist state.
Testing
- Not run:
bunis not installed in this runner (bun: command not found).
HAPI Bot
Bot finding on PR tiann#798 (PRRT_kwDOQuQOSc6HHOsa): when the operator navigates between sessions on the same route (/sessions/A -> /sessions/B), React reuses the SessionChat component instance. Effects run AFTER the first paint, so for a single render window the new session is rendered with the previous session's scratchlist entries (useScratchlist's rehydrate-effect) AND drawer-open state (scratchlistMode reset effect). Visual leak; drawer actions targeting stale state. Apply the bot's suggested fix verbatim modulo the type extraction: export function SessionChat(props) { return <SessionChatInner key={props.session.id} {...props} /> } Canonical React idiom for "fully reset state on prop change": the keyed wrapper unmounts and re-mounts the inner component when session.id changes, so every hook (useScratchlist's initial-state factory, useState, useHappyRuntime, ...) starts fresh. This supersedes the now-redundant effect-based reset: - useEffect(() => { setScratchlistMode(false) }, [session.id]) REMOVED useScratchlist's atomic-loaded-sessionId persistence (added on the prior PR round) stays as defense-in-depth for any caller that uses the hook without the keyed-wrapper pattern. Web suite 90 files / 785 tests, all green. Typecheck clean.
There was a problem hiding this comment.
Findings
- No issues found.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Re-reviewed the full current diff and the previous bot finding. The new keyed
SessionChatInnerwrapper addresses the session-switch scratchlist exposure by remounting per-session state synchronously. No additional high-confidence issues found in the scratchlist drawer/FUE changes. Residual risk: I could not execute the web Vitest suite in this runner.
Testing
- Not run:
bunis not installed in this runner (bun: command not found). - Static check run:
git diff --check 59c29e842313811a2744c6cc993de33e3bb14657..refs/remotes/origin/pr-798.
HAPI Bot
Summary
After dogfooding the per-session scratchlist (#777, merged via #772) for ~24h, the always-visible amber band proved too heavy for what's a 20% feature in a typical session. This PR dials it back to a composer-toggle that opens an on-demand drawer, paired with a small reusable FUE (First-User Experience) primitive so existing operators discover it without a giant always-on UI block.
Closes #797.
UX changes
Reusable FUE primitive
This is the structural payoff: a generic First-User-Experience helper so any new feature can advertise itself without a heavyweight tour.
web/src/lib/use-fue.ts:useFue(featureId)returns{ status, engage, dismiss }with state machineunseen → engaging → acknowledged. Persisted tolocalStorageunderhapi.fue.v1.<featureId>(namespaced so it can never collide with any future upstream onboarding system).web/src/components/Fue.tsx:<FueDot>(small pulsing badge for the affordance) and<FueCallout>(portal-rendered popover with title/body + "Got it" button). No auto-timeout — reading speed varies and silent disappearance undercuts user trust.AGENTS.mdadds a new "Adding new web features — consider an FUE" section so future contributors find the primitive.Refactors
ScratchlistPanel.tsx: split rendering into<ScratchlistInventory>(presentational list) and<ScratchlistDrawer>(composer-controlled drawer with hint copy). Original<ScratchlistPanel>is kept exported for the existing fixture-based tests.SessionChat.tsx: scratchlist state lifted into a newuseScratchlisthook so the composer-toolbar counter and the drawer share one source of truth.onSendwrapped to route throughscratchlist.addwhen mode is on.HappyComposer/ComposerButtons: thread throughscratchlistMode,scratchlistCount,onScratchlistToggleprops.Tests
useFue(hook)computeFueCalloutPlacementscratchlistlibScratchlistPanelcomponentbun typecheckclean across cli/web/hub.Test plan
localStorage.removeItem('hapi.fue.v1.scratchlist-toggle')then refresh — pulsing dot is back.Notes
<ScratchlistPanel>symbol is intentionally kept exported. The existingscratchlist-fixture.htmlPlaywright e2e tests mount it directly; deleting it would have meant rewriting that fixture in this PR. A future PR can drop the legacy symbol once we've decided whether to port the e2e tests to the drawer.localStorageis still the only persistence layer (no hub-side scratchlist sync). v2 territory.