Skip to content

feat(web): scratchlist v1.1 — composer-toggle drawer + reusable FUE primitive#798

Open
heavygee wants to merge 10 commits into
tiann:mainfrom
heavygee:feat/scratchlist-v1.1-composer-drawer
Open

feat(web): scratchlist v1.1 — composer-toggle drawer + reusable FUE primitive#798
heavygee wants to merge 10 commits into
tiann:mainfrom
heavygee:feat/scratchlist-v1.1-composer-drawer

Conversation

@heavygee
Copy link
Copy Markdown
Collaborator

@heavygee heavygee commented Jun 4, 2026

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

  • New notepad icon in the composer toolbar (next to schedule-send) toggles "scratchlist mode".
  • Drawer renders only while mode is on (otherwise: chat-only, no vertical real estate consumed).
  • While in mode, the composer's send button repaints amber and reads "Send to scratchlist" — 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).
  • Promote-to-composer / promote-to-queue / delete / reorder are unchanged.

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 machine unseen → engaging → acknowledged. Persisted to localStorage under hapi.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.md adds 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 new 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.
  • HappyComposer / ComposerButtons: thread through scratchlistMode, scratchlistCount, onScratchlistToggle props.

Tests

Suite Tests New / Existing
useFue (hook) 9 New
computeFueCalloutPlacement 5 New
scratchlist lib 21 Existing — green
ScratchlistPanel component 14 Existing — green
Full web suite 750 All green

bun typecheck clean across cli/web/hub.

Test plan

  • Hard-refresh on a fresh browser session: pulsing amber dot appears on the notepad icon.
  • Hover the icon: native tooltip reads "Scratchlist — park notes & drafts (click to open)".
  • Click the icon: drawer opens above composer, FUE callout pops above the icon with "Got it" button. Composer send button is amber and reads "Send to scratchlist".
  • Type something + Enter: text added to scratchlist, composer cleared, mode still on.
  • Click "Got it" on the callout: callout dismisses, FUE marker stays as a static (non-pulsing) state — wait, no, dot vanishes, replaced by entry counter (number badge) since count > 0.
  • Hard-refresh: dot is gone permanently (acknowledged in localStorage). Entry counter still shows count.
  • Click the icon again (while in mode): drawer hides, composer back to normal chat send.
  • Reset and re-test: localStorage.removeItem('hapi.fue.v1.scratchlist-toggle') then refresh — pulsing dot is back.
  • Existing v1 behavior (promote-to-composer, promote-to-queue, delete with confirmation, reorder) still works inside the new drawer.

Notes

  • The original <ScratchlistPanel> symbol is intentionally kept exported. The existing scratchlist-fixture.html Playwright 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.
  • localStorage is still the only persistence layer (no hub-side scratchlist sync). v2 territory.

…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>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Prevent session switches from overwriting scratchlists — 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. Evidence web/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

Comment thread web/src/lib/use-scratchlist.ts
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>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [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. Evidence web/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

Comment thread web/src/components/SessionChat.tsx
…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>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Clear accepted scheduled chat sends after scratchlist fallback — 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. Evidence web/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, pendingSchedule is set, and shouldRouteToScratchlist falls through; assert pendingSchedule clears after accepted chat send.

HAPI Bot

Comment thread web/src/components/SessionChat.tsx Outdated
…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>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [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. Evidence web/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: bun is unavailable in this runner (bun: command not found). Suggested coverage: a ComposerButtons/SessionChat case with scratchlistMode=true plus pendingSchedule or attachments asserting the send button uses normal chat labeling/state.

HAPI Bot

Comment thread web/src/components/AssistantChat/ComposerButtons.tsx Outdated
heavygee and others added 2 commits June 4, 2026 14:59
…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>
@heavygee
Copy link
Copy Markdown
Collaborator Author

heavygee commented Jun 4, 2026

Caught on the new pass — fixed in 620df08. ScratchlistDrawerHost now calls onExitScratchlistMode whenever it promotes an entry to the composer (since promoting means "send this for real now"). Promote-to-queue still does NOT exit the mode because the queue path bypasses the wrapper anyway, and the operator may still be capturing related notes.

Exported ScratchlistDrawerHost so its host-level wiring can be unit-tested directly. Added SessionChat.exit-mode.test.tsx with 2 tests: promote-to-composer fires both setText and onExitScratchlistMode; promote-to-queue fires onSend but does NOT touch the mode.

Web suite 768/768 green; typecheck clean.

Side note: this finding only showed up in the workflow log (Review posted to GitHub for PR #798 at b256fe5...) -- the corresponding review object isn't surfaced via the REST API at the moment, so my watcher loop (hapi-pr-watch -> hapi-pr-status) couldn't see it. Reading the workflow run output directly is what surfaced it. I'll harden the loop to read the action's stdout when no fresh review is found.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Global scratchlist hotkey fires while other controls/modals are focused — The new window listener accepts Ctrl/Cmd+Shift+S for every event target and immediately prevents default/toggles scratchlist mode. SessionChat can 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. Evidence web/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

Comment thread web/src/components/SessionChat.tsx
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
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Session switch can show the previous session's scratchlist — scratchlistMode is reset in an effect, and useScratchlist also rehydrates in an effect. When SessionChat is reused for /sessions/A -> /sessions/B, the first B render can still have A's scratchlistMode and A's entries/counter before those effects run, exposing or acting on notes from the wrong session. Evidence web/src/components/SessionChat.tsx:274 and web/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: bun is not installed in this runner (bun: command not found).

HAPI Bot

Comment thread web/src/components/SessionChat.tsx Outdated
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.
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SessionChatInner wrapper 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: bun is not installed in this runner (bun: command not found).
  • Static check run: git diff --check 59c29e842313811a2744c6cc993de33e3bb14657..refs/remotes/origin/pr-798.

HAPI Bot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scratchlist v1.1: dial back visual weight + reusable FUE primitive

1 participant