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
|
* 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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user