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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
This commit is contained in:
HeavyGee
2026-07-27 12:56:02 +08:00
committed by GitHub
co-authored by Cursor Debian
parent 2e54d9fdff
commit 311e0cef55
5 changed files with 274 additions and 12 deletions
+61
View File
@@ -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<void> {
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()
})
})
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HAPI scratchlist exit-mode e2e fixture (#959)</title>
<style>
html { background: #fff }
html[data-theme="dark"] { background: #1c1c1e; color-scheme: dark }
body { margin: 0; padding: 16px; font-family: system-ui, sans-serif }
#root { max-width: 720px; margin: 0 auto }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./scratchlist-exit-mode-fixture.tsx"></script>
</body>
</html>
@@ -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 (
<I18nProvider>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
aria-pressed={scratchlistMode ? true : false}
aria-label="Scratchlist drawer"
data-testid="scratchlist-mode-toggle"
onClick={() => setScratchlistMode((prev) => !prev)}
className="rounded border px-3 py-1.5 text-sm"
>
Scratchlist mode
</button>
<span
data-testid="composer-send-mode"
data-scratchlist-routing={scratchlistMode ? 'active' : 'inactive'}
className="rounded border px-2 py-1 text-xs"
>
Send routing: {scratchlistMode ? 'scratchlist' : 'chat'}
</span>
<label className="flex items-center gap-1 text-xs">
Queue mode
<select
aria-label="Queue send mode"
value={queueSendMode}
onChange={(event) => {
setQueueSendMode(event.target.value as 'success' | 'failure')
}}
>
<option value="success">success</option>
<option value="failure">failure</option>
</select>
</label>
</div>
{scratchlistMode ? (
<ScratchlistDrawer
entries={scratchlist.entries}
onMove={scratchlist.move}
onDelete={scratchlist.remove}
onPromoteToComposer={() => setScratchlistMode(false)}
onPromoteToQueue={handlePromoteToQueue}
/>
) : null}
<div className="flex gap-2">
<input
aria-label="Add scratchlist entry"
value={draft}
onChange={(event) => 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"
/>
<button type="button" onClick={handleAdd} className="rounded border px-3 py-1 text-sm">
Add
</button>
</div>
</div>
</I18nProvider>
)
}
const rootEl = document.getElementById('root')
if (rootEl) {
ReactDOM.createRoot(rootEl).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
}
@@ -17,9 +17,9 @@ import type { ScratchlistEntry } from '@/lib/scratchlist'
* runtime hook and asserts both the setText call AND the exit-mode call * runtime hook and asserts both the setText call AND the exit-mode call
* fire when the operator clicks promote-to-composer. * fire when the operator clicks promote-to-composer.
* *
* Promote-to-queue does NOT exit the mode - the queue path bypasses the * Promote-to-queue exits scratchlist mode after a successful send so the
* scratchlist-mode wrapper entirely, and the operator may still want to * operator can continue normal chat (issue #959). Rejected sends keep mode
* capture related notes. * on so the entry stays and the operator can retry.
*/ */
const setText = vi.fn() const setText = vi.fn()
@@ -71,7 +71,7 @@ describe('ScratchlistDrawerHost.onPromoteToComposer', () => {
expect(onSend).not.toHaveBeenCalled() 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 onExitScratchlistMode = vi.fn()
const onSend = vi.fn(async () => true) const onSend = vi.fn(async () => true)
const onMove = vi.fn() const onMove = vi.fn()
@@ -93,11 +93,33 @@ describe('ScratchlistDrawerHost.onPromoteToComposer', () => {
expect(queueButtons.length).toBeGreaterThan(0) expect(queueButtons.length).toBeGreaterThan(0)
fireEvent.click(queueButtons[0]!) fireEvent.click(queueButtons[0]!)
// Allow the async onSend to settle await waitFor(() => expect(onSend).toHaveBeenCalledWith('send-to-queue text'))
await Promise.resolve() expect(onExitScratchlistMode).toHaveBeenCalledTimes(1)
await Promise.resolve() 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(
<I18nProvider>
<ScratchlistDrawerHost
entries={[makeEntry({ id: 'e1', text: 'send-to-queue text' })]}
onMove={onMove}
onDelete={onDelete}
onSend={onSend}
onExitScratchlistMode={onExitScratchlistMode}
/>
</I18nProvider>,
)
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(onExitScratchlistMode).not.toHaveBeenCalled()
expect(setText).not.toHaveBeenCalled() expect(setText).not.toHaveBeenCalled()
}) })
+8 -4
View File
@@ -336,10 +336,14 @@ export function ScratchlistDrawerHost(props: {
// Promote-to-queue bypasses the scratchlist-mode wrapper by // Promote-to-queue bypasses the scratchlist-mode wrapper by
// calling props.onSend directly (the chat send), so the queue // calling props.onSend directly (the chat send), so the queue
// entry lands in the conversation regardless of scratchlist // entry lands in the conversation regardless of scratchlist
// mode. Mode itself stays on - the operator may still be // mode. After a successful send, exit scratchlist mode so the
// capturing related notes. // operator can continue normal chat (issue #959).
return await props.onSend(text) const accepted = await props.onSend(text)
}, [props.onSend]) if (accepted) {
props.onExitScratchlistMode()
}
return accepted
}, [props.onSend, props.onExitScratchlistMode])
return ( return (
<ScratchlistDrawer <ScratchlistDrawer
entries={props.entries} entries={props.entries}