mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
co-authored by
Cursor
Debian
parent
2e54d9fdff
commit
311e0cef55
@@ -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
|
||||
* 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(
|
||||
<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(setText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<ScratchlistDrawer
|
||||
entries={props.entries}
|
||||
|
||||
Reference in New Issue
Block a user