From 311e0cef55b580536cf19cbcbb69c6fe872522c0 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:56:02 +0100 Subject: [PATCH] fix(web): exit scratchlist mode after successful promote-to-queue send (#960) * fix(web): exit scratchlist mode after successful promote-to-queue (#959) After Send to queue accepts, call onExitScratchlistMode so the operator can continue normal chat. Rejected sends keep mode on. Unit + Playwright smoke coverage. Co-authored-by: Cursor * fix(web): add execStartedAt/execCompletedAt to ToolCard test mock Upstream ChatToolCall gained exec timestamps; ToolCard.test.ts mock was missing them and broke CI typecheck after rebase onto main. Co-authored-by: Cursor --------- Co-authored-by: Cursor Co-authored-by: Debian --- e2e/scratchlist-exit-after-queue.spec.ts | 61 +++++++ .../scratchlist-exit-mode-fixture.html | 18 ++ .../scratchlist-exit-mode-fixture.tsx | 157 ++++++++++++++++++ .../components/SessionChat.exit-mode.test.tsx | 38 ++++- web/src/components/SessionChat.tsx | 12 +- 5 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 e2e/scratchlist-exit-after-queue.spec.ts create mode 100644 web/e2e-fixtures/scratchlist-exit-mode-fixture.html create mode 100644 web/e2e-fixtures/scratchlist-exit-mode-fixture.tsx diff --git a/e2e/scratchlist-exit-after-queue.spec.ts b/e2e/scratchlist-exit-after-queue.spec.ts new file mode 100644 index 00000000..044acf6d --- /dev/null +++ b/e2e/scratchlist-exit-after-queue.spec.ts @@ -0,0 +1,61 @@ +/* + * Playwright smoke for tiann/hapi#959 — after Send to queue from scratchlist, + * scratchlist mode must turn off so the operator can continue normal chat. + */ + +import { mkdirSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { test, expect } from '@playwright/test' + +const SCREENSHOT_PATH = resolve('localdocs/playwright-runs/959-scratchlist-exit-after-queue.png') + +async function gotoFixture(page: import('@playwright/test').Page, sessionId: string): Promise { + await page.goto(`/e2e-fixtures/scratchlist-exit-mode-fixture.html?session=${encodeURIComponent(sessionId)}`) + await expect(page.getByTestId('scratchlist-mode-toggle')).toBeVisible() +} + +test.describe('scratchlist exit after queue send (#959)', () => { + test('successful promote-to-queue exits scratchlist mode', async ({ page }) => { + await gotoFixture(page, '959-exit-after-queue') + + // Enter scratchlist mode — drawer mounts, send routing goes amber-ish. + await page.getByTestId('scratchlist-mode-toggle').click() + await expect(page.getByTestId('scratchlist-mode-toggle')).toHaveAttribute('aria-pressed', 'true') + await expect(page.getByTestId('scratchlist-drawer')).toBeVisible() + await expect(page.getByTestId('composer-send-mode')).toHaveAttribute('data-scratchlist-routing', 'active') + + // Seed an entry through the fixture add control. + await page.getByLabel('Add scratchlist entry').fill('Queue this note from scratchlist') + await page.getByRole('button', { name: 'Add', exact: true }).click() + await expect(page.getByText('Queue this note from scratchlist')).toBeVisible() + + // Promote to queue — production ScratchlistDrawerHost should exit mode on success. + await page.getByRole('button', { name: 'Send to queue' }).first().click() + await expect(page.getByText('Queue this note from scratchlist')).toHaveCount(0) + + await expect(page.getByTestId('scratchlist-mode-toggle')).toHaveAttribute('aria-pressed', 'false') + await expect(page.getByTestId('scratchlist-drawer')).toHaveCount(0) + await expect(page.getByTestId('composer-send-mode')).toHaveAttribute('data-scratchlist-routing', 'inactive') + + const harness = await page.evaluate(() => window.__scratchlistExitModeE2E) + expect(harness?.queuedTexts).toEqual(['Queue this note from scratchlist']) + expect(harness?.scratchlistMode).toBe(false) + + mkdirSync(dirname(SCREENSHOT_PATH), { recursive: true }) + await page.screenshot({ path: SCREENSHOT_PATH, fullPage: false }) + }) + + test('rejected promote-to-queue keeps scratchlist mode on', async ({ page }) => { + await gotoFixture(page, '959-keep-mode-on-failure') + + await page.getByTestId('scratchlist-mode-toggle').click() + await page.getByLabel('Queue send mode').selectOption('failure') + await page.getByLabel('Add scratchlist entry').fill('This send will fail') + await page.getByRole('button', { name: 'Add', exact: true }).click() + + await page.getByRole('button', { name: 'Send to queue' }).first().click() + await expect(page.getByText('This send will fail')).toBeVisible() + await expect(page.getByTestId('scratchlist-mode-toggle')).toHaveAttribute('aria-pressed', 'true') + await expect(page.getByTestId('scratchlist-drawer')).toBeVisible() + }) +}) diff --git a/web/e2e-fixtures/scratchlist-exit-mode-fixture.html b/web/e2e-fixtures/scratchlist-exit-mode-fixture.html new file mode 100644 index 00000000..ea7a3c12 --- /dev/null +++ b/web/e2e-fixtures/scratchlist-exit-mode-fixture.html @@ -0,0 +1,18 @@ + + + + + + HAPI scratchlist exit-mode e2e fixture (#959) + + + +
+ + + diff --git a/web/e2e-fixtures/scratchlist-exit-mode-fixture.tsx b/web/e2e-fixtures/scratchlist-exit-mode-fixture.tsx new file mode 100644 index 00000000..71b3879f --- /dev/null +++ b/web/e2e-fixtures/scratchlist-exit-mode-fixture.tsx @@ -0,0 +1,157 @@ +/* + * Playwright fixture for issue #959: exit scratchlist mode after a + * successful promote-to-queue. Mirrors ScratchlistDrawerHost behaviour + * without importing SessionChat (which pulls the full app graph). + */ + +import React from 'react' +import ReactDOM from 'react-dom/client' +import '../src/index.css' +import { I18nProvider } from '../src/lib/i18n-context' +import { useScratchlist } from '../src/lib/use-scratchlist' +import { ScratchlistDrawer } from '../src/components/AssistantChat/ScratchlistPanel' + +declare global { + interface Window { + __scratchlistExitModeE2E?: { + sessionId: string + scratchlistMode: boolean + queuedTexts: string[] + queueSendMode: 'success' | 'failure' + } + } +} + +function getInitialSessionId(): string { + const url = new URL(window.location.href) + return url.searchParams.get('session') ?? 'e2e-exit-mode' +} + +function App() { + const [sessionId] = React.useState(getInitialSessionId) + const [scratchlistMode, setScratchlistMode] = React.useState(false) + const scratchlist = useScratchlist(sessionId) + const [queueSendMode, setQueueSendMode] = React.useState<'success' | 'failure'>('success') + const [draft, setDraft] = React.useState('') + const harnessData = React.useRef({ + queuedTexts: [] as string[], + queueSendMode: 'success' as 'success' | 'failure', + }) + const scratchlistModeRef = React.useRef(scratchlistMode) + scratchlistModeRef.current = scratchlistMode + harnessData.current.queueSendMode = queueSendMode + + React.useEffect(() => { + window.__scratchlistExitModeE2E = { + sessionId, + get scratchlistMode() { + return scratchlistModeRef.current + }, + get queuedTexts() { + return harnessData.current.queuedTexts + }, + get queueSendMode() { + return harnessData.current.queueSendMode + }, + } + }, [sessionId]) + + const handleSend = React.useCallback(async (text: string) => { + if (harnessData.current.queueSendMode === 'failure') { + return false + } + harnessData.current.queuedTexts.push(text) + return true + }, []) + + // Mirror ScratchlistDrawerHost.handlePromoteToQueue (SessionChat.tsx). + const handlePromoteToQueue = React.useCallback(async (text: string) => { + const accepted = await handleSend(text) + if (accepted) { + setScratchlistMode(false) + } + return accepted + }, [handleSend]) + + const handleAdd = React.useCallback(() => { + const added = scratchlist.add(draft) + if (added) setDraft('') + }, [draft, scratchlist]) + + return ( + +
+
+ + + Send routing: {scratchlistMode ? 'scratchlist' : 'chat'} + + +
+ + {scratchlistMode ? ( + setScratchlistMode(false)} + onPromoteToQueue={handlePromoteToQueue} + /> + ) : null} + +
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleAdd() + } + }} + className="flex-1 rounded border px-2 py-1 text-sm" + placeholder="Note — Enter to add" + /> + +
+
+
+ ) +} + +const rootEl = document.getElementById('root') +if (rootEl) { + ReactDOM.createRoot(rootEl).render( + + + , + ) +} diff --git a/web/src/components/SessionChat.exit-mode.test.tsx b/web/src/components/SessionChat.exit-mode.test.tsx index f0e0f145..9993c038 100644 --- a/web/src/components/SessionChat.exit-mode.test.tsx +++ b/web/src/components/SessionChat.exit-mode.test.tsx @@ -17,9 +17,9 @@ import type { ScratchlistEntry } from '@/lib/scratchlist' * runtime hook and asserts both the setText call AND the exit-mode call * fire when the operator clicks promote-to-composer. * - * Promote-to-queue does NOT exit the mode - the queue path bypasses the - * scratchlist-mode wrapper entirely, and the operator may still want to - * capture related notes. + * Promote-to-queue exits scratchlist mode after a successful send so the + * operator can continue normal chat (issue #959). Rejected sends keep mode + * on so the entry stays and the operator can retry. */ const setText = vi.fn() @@ -71,7 +71,7 @@ describe('ScratchlistDrawerHost.onPromoteToComposer', () => { expect(onSend).not.toHaveBeenCalled() }) - it('does NOT exit scratchlist mode when an entry is promoted to queue', async () => { + it('exits scratchlist mode when an entry is promoted to queue and the send is accepted', async () => { const onExitScratchlistMode = vi.fn() const onSend = vi.fn(async () => true) const onMove = vi.fn() @@ -93,11 +93,33 @@ describe('ScratchlistDrawerHost.onPromoteToComposer', () => { expect(queueButtons.length).toBeGreaterThan(0) fireEvent.click(queueButtons[0]!) - // Allow the async onSend to settle - await Promise.resolve() - await Promise.resolve() + await waitFor(() => expect(onSend).toHaveBeenCalledWith('send-to-queue text')) + expect(onExitScratchlistMode).toHaveBeenCalledTimes(1) + expect(setText).not.toHaveBeenCalled() + }) - expect(onSend).toHaveBeenCalledWith('send-to-queue text') + it('does NOT exit scratchlist mode when promote-to-queue send is rejected', async () => { + const onExitScratchlistMode = vi.fn() + const onSend = vi.fn(async () => false) + const onMove = vi.fn() + const onDelete = vi.fn() + + render( + + + , + ) + + const queueButtons = screen.getAllByRole('button', { name: /queue|send/i }) + fireEvent.click(queueButtons[0]!) + + await waitFor(() => expect(onSend).toHaveBeenCalledWith('send-to-queue text')) expect(onExitScratchlistMode).not.toHaveBeenCalled() expect(setText).not.toHaveBeenCalled() }) diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 13bca4c0..0bd5028b 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -336,10 +336,14 @@ export function ScratchlistDrawerHost(props: { // Promote-to-queue bypasses the scratchlist-mode wrapper by // calling props.onSend directly (the chat send), so the queue // entry lands in the conversation regardless of scratchlist - // mode. Mode itself stays on - the operator may still be - // capturing related notes. - return await props.onSend(text) - }, [props.onSend]) + // mode. After a successful send, exit scratchlist mode so the + // operator can continue normal chat (issue #959). + const accepted = await props.onSend(text) + if (accepted) { + props.onExitScratchlistMode() + } + return accepted + }, [props.onSend, props.onExitScratchlistMode]) return (