mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): per-session scratchlist (workbench) panel (#772)
* feat(web): per-session scratchlist (workbench) panel Adds a per-session "scratchlist" panel above the composer for parking notes / drafts / parking-lot ideas that are explicitly held — never auto-sent. This is distinct from the existing queue (QueuedMessagesBar): - Queue = conveyor belt: messages auto-fire once the agent is idle. - Scratchlist = workbench: held until the operator promotes them. The amber accent and "held — not sent" pill make the visual distinction obvious so operators don't mistake one for the other. Features: - Collapsible per-session panel (collapsed by default, persisted in localStorage). - Add (Enter) / delete / reorder (up/down) entries. - Promote-to-composer copies into the composer for editing (entry stays — copy semantics). - Promote-to-queue routes through the existing onSend path so the entry shows up in QueuedMessagesBar; entry is removed only on accepted send. - Entries persist per session under hapi.scratchlist.v1.<sessionId>. - Confirm-on-delete only for entries longer than 100 chars. - Ctrl/Cmd+Shift+S focuses the add-input. - en + zh-CN strings. v1 scope: localStorage-only. Hub-sync deferred to v2 to keep the diff small and reviewable. Test coverage: - web/src/lib/scratchlist.test.ts — 21 tests (storage round-trip, add/delete/reorder/cap, malformed-JSON resilience, confirm threshold). - web/src/components/AssistantChat/ScratchlistPanel.test.tsx — 13 tests (collapse persistence, hydration, add/delete/reorder UI, promote-to-composer copy semantics, promote-to-queue accepted / rejected paths, per-session isolation). Closes #11 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): block focus into collapsed panel via inert Upstream review (tiann/hapi#772, codex bot) flagged that the collapsed scratchlist body was visually hidden via CSS only - the textarea and action buttons stayed mounted, focusable, and clickable while their ancestor was aria-hidden. Tab into invisible controls + a hidden subtree with focusable descendants is an a11y violation. Apply `inert` to the inner content, gated on the collapsed state. This removes the subtree from the focus, pointer, and accessibility trees while keeping the grid-template-rows expand animation intact (no conditional remount, so the open/close transition still runs). Add a regression test that asserts `inert` is present while collapsed and removed (or empty) while expanded, so a future revert of the fix trips immediately. Co-authored-by: Cursor <cursoragent@cursor.com> * test(scratchlist): add Playwright e2e + isolated fixture page The unit suite under jsdom can't verify the parts of the scratchlist that actually live in the browser: - `inert` blocks focus (jsdom ignores `inert`) - the grid-template-rows collapse animation - localStorage surviving a full page reload - per-session keying surviving cross-route navigation - Ctrl/Cmd+Shift+S firing the global expand+focus shortcut Add a Playwright config + spec that drives a real Chromium against a new Vite-served fixture (`web/e2e-fixtures/scratchlist-fixture.html`). The fixture mounts the production `ScratchlistPanel` in isolation inside an `I18nProvider` and exposes the promote callbacks on `window.__scratchlistE2E` so the spec can assert that promote-to- composer and promote-to-queue receive the right text without having to spin up the hub, auth, or socket layer. Nine specs cover: 1. starts collapsed, toggles 2. collapsed inner is `inert` and refuses focus / pointer 3. add: entry appears, draft clears, count updates 4. persistence across full page reload 5. promote-to-composer fires callback (entry stays - copy semantics) 6. promote-to-queue success path (entry removed) 7. promote-to-queue failure path (entry retained for retry) 8. Ctrl+Shift+S expands + focuses input 9. per-session isolation across navigation Wires `bun run test:e2e` and `test:e2e:ui` at the repo root and documents the harness in `web/README.md`. Bumps `playwright` 1.49.1 -> 1.60.0 alongside the new `@playwright/test` dep so the bundled chromium-headless-shell-1223 (Chrome 148) is used; the older 131 binary SIGTRAPs on this kernel during launch. Adds `test-results/` and `playwright-report/` to `.gitignore`. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): key host by session.id to prevent cross-session leak Upstream review (tiann/hapi#772, codex bot follow-up) flagged a state leak across same-route session switches. ScratchlistPanel reads `sessionId` once via `useState(() => readScratchlist(sessionId))` and rehydrates in a `useEffect`. SessionChat stays mounted when the operator switches sessions on the same `/sessions/$sessionId` route, so the panel sees a new `sessionId` prop without unmounting. Effect order during the prop change: 1. render with sessionId=B but stale entries=[A's items] 2. rehydrate effect: setEntries(read(B)) -> queues correction 3. persist effect (deps [sessionId, entries] both changed): persistScratchlist(B, [A's items]) -> writes A into B 4. re-render with sessionId=B, entries=B's items 5. persist effect: persistScratchlist(B, B's items) -> overwrites the bug write The bug is transient (step 3's write is corrected by step 5) but real: any read between steps 3 and 5 (another tab, a SW prefetch, manual inspection) sees A's data under B's key. Fix is one line: `key={props.session.id}` on `<ScratchlistHost>`. React unmounts and remounts the host when the key changes, so the new mount's useState initializer reads B's storage from scratch and never touches B's key with A's data. This is the React-canonical "reset state on prop change" pattern; cleaner than chasing the race inside the panel. Add an e2e regression test that: - installs a `localStorage.setItem` spy in `addInitScript` - mounts the fixture under session A and adds an entry - clears the spy, then switches to session B in-place via `window.__scratchlistE2E.setSessionId('leak-B')` (no page reload) - asserts no recorded write to `hapi.scratchlist.v1.leak-B` contained A's text (catches the transient corrupting write deterministically, before the correction overwrites it) - round-trips back to A to confirm A's storage is intact The fixture grows a `?key=0` mode that drops the host's `key=` prop. Verified red/green: with `key=0` the regression test fails on the spy-detected corrupting write; with the fix in place (default), all 10 e2e specs pass. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,3 +42,8 @@ execplan/
|
||||
# Generated npm bundle output (local)
|
||||
cli/npm/main/
|
||||
.ace-tool/
|
||||
|
||||
# Playwright e2e artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
e2e-output/
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
"": {
|
||||
"name": "hapi",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"playwright": "1.49.1",
|
||||
"playwright": "1.60.0",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
},
|
||||
@@ -714,6 +715,8 @@
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="],
|
||||
|
||||
"@primer/octicons": ["@primer/octicons@19.23.1", "", { "dependencies": { "object-assign": "^4.1.1" } }, "sha512-CzjGmxkmNhyst6EekrS3SJPdtzgIkUMP/LSJch65y99/kmiFXbO1a+q7zoYe3hnI9NaOM0IN+ydDIbOmd8YqcA=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
@@ -1066,6 +1069,8 @@
|
||||
|
||||
"@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.19.0", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-Xz2UD5fMZPSV0OPhzDo5FE7Tbs4wcHmjK3+NEu4kWTQkslMeQ+j96syR32MfbO1CzIz2kbDckVsnGj+ptfki9A=="],
|
||||
|
||||
"@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.19.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-tGUAG2CKhoLWaCROEjB0sEPFojYp4mU2wyJ4p/V7Q6WO1ayXQHT3dxYo0MvXOFN/u4EXzVJYOTKwa6IKDks0vA=="],
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
@@ -2430,9 +2435,9 @@
|
||||
|
||||
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
||||
"playwright": ["playwright@1.49.1", "", { "dependencies": { "playwright-core": "1.49.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA=="],
|
||||
"playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.49.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg=="],
|
||||
"playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
|
||||
|
||||
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* End-to-end coverage for the per-session scratchlist (issue #11 / PR
|
||||
* #772). The unit tests in `web/src/components/AssistantChat/
|
||||
* ScratchlistPanel.test.tsx` exercise the component under jsdom, which
|
||||
* does not honor `inert` for focus blocking, does not run the CSS
|
||||
* `grid-template-rows` collapse animation, and does not exercise real
|
||||
* `localStorage` round-tripping across full page loads.
|
||||
*
|
||||
* These specs drive a real Chromium against the
|
||||
* `web/e2e-fixtures/scratchlist-fixture.html` page (vite dev), which
|
||||
* mounts the production ScratchlistPanel + I18nProvider with stub
|
||||
* promote callbacks exposed on `window.__scratchlistE2E`.
|
||||
*
|
||||
* Each test uses a unique `?session=...` query param so the keyed
|
||||
* localStorage state is naturally isolated.
|
||||
*/
|
||||
|
||||
import { test, expect, Page } from '@playwright/test'
|
||||
|
||||
type Harness = {
|
||||
sessionId: string
|
||||
promotedToComposer: string[]
|
||||
promotedToQueue: string[]
|
||||
queueSendMode: 'success' | 'failure'
|
||||
}
|
||||
|
||||
async function gotoFixture(page: Page, sessionId: string): Promise<void> {
|
||||
// We use a unique session id per test (the localStorage keys are
|
||||
// namespaced by sessionId), so isolation is naturally per-test
|
||||
// without needing to clear storage. Clearing on every page load
|
||||
// would defeat the persistence + cross-navigation tests below.
|
||||
await page.goto(`/e2e-fixtures/scratchlist-fixture.html?session=${encodeURIComponent(sessionId)}`)
|
||||
await expect(page.getByTestId('scratchlist-panel')).toBeVisible()
|
||||
}
|
||||
|
||||
async function readHarness(page: Page): Promise<Harness> {
|
||||
return await page.evaluate(() => {
|
||||
const h = window.__scratchlistE2E!
|
||||
return {
|
||||
sessionId: h.sessionId,
|
||||
promotedToComposer: [...h.promotedToComposer],
|
||||
promotedToQueue: [...h.promotedToQueue],
|
||||
queueSendMode: h.queueSendMode,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function setQueueMode(page: Page, mode: 'success' | 'failure'): Promise<void> {
|
||||
await page.evaluate((m) => {
|
||||
if (window.__scratchlistE2E) {
|
||||
window.__scratchlistE2E.queueSendMode = m
|
||||
}
|
||||
}, mode)
|
||||
}
|
||||
|
||||
async function expandPanel(page: Page): Promise<void> {
|
||||
const toggle = page.getByRole('button', { name: 'Scratchlist' })
|
||||
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||||
await toggle.click()
|
||||
}
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
}
|
||||
|
||||
async function collapsePanel(page: Page): Promise<void> {
|
||||
const toggle = page.getByRole('button', { name: 'Scratchlist' })
|
||||
if ((await toggle.getAttribute('aria-expanded')) !== 'false') {
|
||||
await toggle.click()
|
||||
}
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
}
|
||||
|
||||
async function addEntry(page: Page, text: string): Promise<void> {
|
||||
const textarea = page.getByLabel('Add scratchlist entry')
|
||||
await textarea.fill(text)
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(textarea).toHaveValue('')
|
||||
}
|
||||
|
||||
test.describe('scratchlist e2e', () => {
|
||||
test('starts collapsed, expands on click, collapses on second click', async ({ page }) => {
|
||||
await gotoFixture(page, 'expand')
|
||||
|
||||
const toggle = page.getByRole('button', { name: 'Scratchlist' })
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
test('collapsed inner is `inert`: textarea cannot be focused or clicked', async ({ page }) => {
|
||||
// This is the regression that the upstream PR review flagged.
|
||||
// jsdom can't verify it; only a real browser can.
|
||||
await gotoFixture(page, 'inert')
|
||||
|
||||
const inner = page.locator('.collapsible-inner').first()
|
||||
await expect(inner).toHaveAttribute('inert', '')
|
||||
|
||||
// Try to focus the (collapsed) textarea. Per the HTML spec, an
|
||||
// inert subtree refuses focus.
|
||||
const textarea = page.getByLabel('Add scratchlist entry')
|
||||
await textarea.focus({ timeout: 1_000 }).catch(() => {})
|
||||
const focusedTagCollapsed = await page.evaluate(
|
||||
() => document.activeElement?.tagName ?? 'NONE'
|
||||
)
|
||||
expect(focusedTagCollapsed).not.toBe('TEXTAREA')
|
||||
|
||||
// Clicks on inert children also have no effect: the panel must
|
||||
// not collapse / submit / fill, the click is swallowed.
|
||||
await textarea.click({ force: true, timeout: 1_000 }).catch(() => {})
|
||||
const stillCollapsed = await page
|
||||
.getByRole('button', { name: 'Scratchlist' })
|
||||
.getAttribute('aria-expanded')
|
||||
expect(stillCollapsed).toBe('false')
|
||||
|
||||
// Expand and confirm focus works again.
|
||||
await expandPanel(page)
|
||||
const innerAttrAfter = await inner.getAttribute('inert')
|
||||
expect(innerAttrAfter === null || innerAttrAfter === '' || innerAttrAfter === 'false').toBeTruthy()
|
||||
await textarea.focus()
|
||||
const focusedAfter = await page.evaluate(
|
||||
() => document.activeElement?.tagName ?? 'NONE'
|
||||
)
|
||||
expect(focusedAfter).toBe('TEXTAREA')
|
||||
})
|
||||
|
||||
test('add: entry appears, draft clears, count updates', async ({ page }) => {
|
||||
await gotoFixture(page, 'add')
|
||||
await expandPanel(page)
|
||||
|
||||
// Initial summary is "empty".
|
||||
await expect(page.getByText('empty', { exact: true })).toBeVisible()
|
||||
|
||||
await addEntry(page, 'Investigate the runner cold-start delay')
|
||||
|
||||
await expect(page.getByText('Investigate the runner cold-start delay')).toBeVisible()
|
||||
await expect(page.getByText('1 item', { exact: true })).toBeVisible()
|
||||
|
||||
await addEntry(page, 'Second draft')
|
||||
await expect(page.getByText('2 items', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('persistence: entries survive a full page reload', async ({ page }) => {
|
||||
await gotoFixture(page, 'persist')
|
||||
await expandPanel(page)
|
||||
await addEntry(page, 'First note')
|
||||
await addEntry(page, 'Second note')
|
||||
|
||||
await page.reload()
|
||||
await expect(page.getByTestId('scratchlist-panel')).toBeVisible()
|
||||
|
||||
// Collapsed-state preference is also remembered, so we expand
|
||||
// again before asserting both entries reappear. The storage
|
||||
// layer renders newest-first, so the most recent add is at
|
||||
// index 0.
|
||||
await expandPanel(page)
|
||||
const items = page.locator('[data-testid="scratchlist-entry"]')
|
||||
await expect(items).toHaveCount(2)
|
||||
await expect(items.nth(0)).toContainText('Second note')
|
||||
await expect(items.nth(1)).toContainText('First note')
|
||||
})
|
||||
|
||||
test('promote-to-composer fires callback with entry text', async ({ page }) => {
|
||||
await gotoFixture(page, 'promote-composer')
|
||||
await expandPanel(page)
|
||||
await addEntry(page, 'Draft a status update')
|
||||
|
||||
await page.getByRole('button', { name: 'Copy into composer' }).first().click()
|
||||
|
||||
const harness = await readHarness(page)
|
||||
expect(harness.promotedToComposer).toEqual(['Draft a status update'])
|
||||
// Promote-to-composer is a copy: the entry stays.
|
||||
await expect(page.getByText('Draft a status update')).toBeVisible()
|
||||
})
|
||||
|
||||
test('promote-to-queue (success) fires callback and removes entry', async ({ page }) => {
|
||||
await gotoFixture(page, 'promote-queue')
|
||||
await expandPanel(page)
|
||||
await addEntry(page, 'Ship the patch release')
|
||||
|
||||
await setQueueMode(page, 'success')
|
||||
await page.getByRole('button', { name: 'Send to queue' }).first().click()
|
||||
|
||||
await expect(page.getByText('Ship the patch release')).toHaveCount(0)
|
||||
const harness = await readHarness(page)
|
||||
expect(harness.promotedToQueue).toEqual(['Ship the patch release'])
|
||||
})
|
||||
|
||||
test('promote-to-queue (failure) keeps the entry on the scratchlist', async ({ page }) => {
|
||||
await gotoFixture(page, 'promote-queue-fail')
|
||||
await expandPanel(page)
|
||||
await addEntry(page, 'This send will fail')
|
||||
|
||||
await setQueueMode(page, 'failure')
|
||||
await page.getByRole('button', { name: 'Send to queue' }).first().click()
|
||||
|
||||
// Failure path: the queue callback returned false, so the entry
|
||||
// must remain on the scratchlist (operator can retry).
|
||||
await expect(page.getByText('This send will fail')).toBeVisible()
|
||||
const harness = await readHarness(page)
|
||||
expect(harness.promotedToQueue).toEqual([])
|
||||
})
|
||||
|
||||
test('Ctrl+Shift+S expands the panel and focuses the input', async ({ page }) => {
|
||||
await gotoFixture(page, 'shortcut')
|
||||
await collapsePanel(page)
|
||||
|
||||
await page.keyboard.press('Control+Shift+S')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Scratchlist' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
await expect.poll(
|
||||
async () => page.evaluate(() => document.activeElement?.tagName ?? 'NONE'),
|
||||
{ timeout: 2_000 }
|
||||
).toBe('TEXTAREA')
|
||||
})
|
||||
|
||||
test('regression: in-place sessionId change does not leak entries (host keyed by sessionId)', async ({ page }) => {
|
||||
// Reproduces the bug flagged by the upstream PR review:
|
||||
// ScratchlistPanel reads `sessionId` once via useState and
|
||||
// rehydrates in useEffect. If a parent stays mounted across
|
||||
// session changes (SessionChat does, on same-route nav), the
|
||||
// persist effect for the stale entries fires under the new
|
||||
// sessionId BEFORE the rehydrate effect's setEntries triggers a
|
||||
// correction render. That race writes A's entries into B's
|
||||
// localStorage, even though the second render then overwrites
|
||||
// it with [] - leaving a transient bad write in storage that
|
||||
// a tab/network race could observe.
|
||||
//
|
||||
// Reading localStorage AFTER the dust settles is too late: the
|
||||
// bug write has already been overwritten by the correction
|
||||
// write. We instead install a setItem spy BEFORE mount so every
|
||||
// write during the session switch is recorded, then assert no
|
||||
// write to the new sessionId's key contained the old session's
|
||||
// entry text. This catches the race deterministically.
|
||||
//
|
||||
// The fix is `key={sessionId}` on ScratchlistHost in
|
||||
// SessionChat.tsx (and the equivalent `keyed=true` path in
|
||||
// this fixture). With the key, React unmounts/remounts on
|
||||
// session change, so the new mount reads B's storage from
|
||||
// scratch and never touches B's key with A's data.
|
||||
await page.addInitScript(() => {
|
||||
const writes: { key: string; value: string }[] = []
|
||||
const orig = window.localStorage.setItem.bind(window.localStorage)
|
||||
window.localStorage.setItem = (k: string, v: string) => {
|
||||
writes.push({ key: String(k), value: String(v) })
|
||||
return orig(k, v)
|
||||
}
|
||||
;(window as unknown as { __lsWrites: typeof writes }).__lsWrites = writes
|
||||
})
|
||||
|
||||
await page.goto(`/e2e-fixtures/scratchlist-fixture.html?session=leak-A`)
|
||||
await expect(page.getByTestId('scratchlist-panel')).toBeVisible()
|
||||
await expandPanel(page)
|
||||
await addEntry(page, 'A-only entry')
|
||||
await expect(page.getByText('A-only entry')).toBeVisible()
|
||||
|
||||
// Clear the recorded writes from the setup phase so the
|
||||
// assertion below only inspects writes that happened DURING
|
||||
// the session switch.
|
||||
await page.evaluate(() => {
|
||||
;(window as unknown as { __lsWrites: { key: string; value: string }[] }).__lsWrites.length = 0
|
||||
})
|
||||
|
||||
// Switch sessionId in-place WITHOUT reloading the parent.
|
||||
await page.evaluate(() => window.__scratchlistE2E!.setSessionId('leak-B'))
|
||||
|
||||
// Wait for the dust to settle (effects + re-render).
|
||||
await expect(page.getByRole('button', { name: 'Scratchlist' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
|
||||
// No write to leak-B's storage key should contain A's entry.
|
||||
const writes = await page.evaluate(
|
||||
() => (window as unknown as { __lsWrites: { key: string; value: string }[] }).__lsWrites.slice()
|
||||
)
|
||||
const leakBKey = 'hapi.scratchlist.v1.leak-B'
|
||||
const corruptingWrite = writes.find(
|
||||
(w) => w.key === leakBKey && w.value.includes('A-only entry')
|
||||
)
|
||||
expect(corruptingWrite, `found corrupting write to ${leakBKey}: ${JSON.stringify(corruptingWrite)}`).toBeUndefined()
|
||||
|
||||
// Final state assertions: leak-B is empty, leak-A retains its
|
||||
// entry on round-trip back.
|
||||
await expandPanel(page)
|
||||
await expect(page.getByText('empty', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('A-only entry')).toHaveCount(0)
|
||||
|
||||
await page.evaluate(() => window.__scratchlistE2E!.setSessionId('leak-A'))
|
||||
await expandPanel(page)
|
||||
await expect(page.getByText('A-only entry')).toBeVisible()
|
||||
})
|
||||
|
||||
test('per-session isolation: full reload across sessions does not leak entries', async ({ page }) => {
|
||||
await gotoFixture(page, 'session-a')
|
||||
await expandPanel(page)
|
||||
await addEntry(page, 'Note for session A')
|
||||
|
||||
// Navigate to a different session id; localStorage is keyed by
|
||||
// `hapi.scratchlist.v1.<sessionId>`, so session B must start
|
||||
// empty even though A still has its entry persisted.
|
||||
await page.goto(`/e2e-fixtures/scratchlist-fixture.html?session=session-b`)
|
||||
await expect(page.getByTestId('scratchlist-panel')).toBeVisible()
|
||||
await expandPanel(page)
|
||||
await expect(page.getByText('empty', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Note for session A')).toHaveCount(0)
|
||||
|
||||
// And navigating back to A still shows its note.
|
||||
await page.goto(`/e2e-fixtures/scratchlist-fixture.html?session=session-a`)
|
||||
await expandPanel(page)
|
||||
await expect(page.getByText('Note for session A')).toBeVisible()
|
||||
})
|
||||
})
|
||||
+4
-1
@@ -23,12 +23,15 @@
|
||||
"test:hub": "cd hub && bun run test",
|
||||
"test:web": "cd web && bun run test",
|
||||
"test:shared": "cd shared && bun run test",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"clean-session": "bun run hub/scripts/cleanup-sessions.ts",
|
||||
"release-all": "cd cli && bun run release-all"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"playwright": "1.49.1",
|
||||
"playwright": "1.60.0",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
const PORT = 5179
|
||||
const BASE_URL = `http://localhost:${PORT}`
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
launchOptions: {
|
||||
// The CI runner and most sandboxed dev environments
|
||||
// run as root or under restricted user namespaces;
|
||||
// without --no-sandbox chromium silently exits 0 a
|
||||
// few seconds after launch and the page handshake
|
||||
// times out. Keep the flag scoped to launchOptions
|
||||
// so this is the only place a future maintainer has
|
||||
// to revisit if they harden the runner.
|
||||
args: ['--no-sandbox'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
// The fixture page mounts ScratchlistPanel in isolation; no hub
|
||||
// is required, which is why this dev server doesn't proxy /api.
|
||||
command: `bun run --cwd web dev -- --port ${PORT} --strictPort`,
|
||||
url: `${BASE_URL}/e2e-fixtures/scratchlist-fixture.html`,
|
||||
timeout: 60_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
})
|
||||
@@ -51,6 +51,11 @@ See `src/router.tsx` for route definitions.
|
||||
- Model selection (default/sonnet/sonnet[1m]/opus/opus[1m]).
|
||||
- Session abort and mode switch controls.
|
||||
- Context size display.
|
||||
- Per-session scratchlist (`src/components/AssistantChat/ScratchlistPanel.tsx`)
|
||||
- Workbench panel for held notes/drafts; **distinct from the queue**.
|
||||
- Add/delete/reorder entries; promote to composer (copy) or queue (send).
|
||||
- Persists across reloads via `localStorage` keyed per session.
|
||||
- Keyboard shortcut: Ctrl/Cmd+Shift+S to focus the add-input.
|
||||
|
||||
### File browser (`src/routes/sessions/files.tsx`)
|
||||
|
||||
@@ -135,6 +140,27 @@ If testing in Telegram, set:
|
||||
- `HAPI_PUBLIC_URL` to the public HTTPS URL of the dev server.
|
||||
- `CORS_ORIGINS` to include the dev server origin.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests run under vitest + jsdom:
|
||||
|
||||
```bash
|
||||
bun run test:web
|
||||
```
|
||||
|
||||
End-to-end browser tests for the scratchlist component (real Chromium, real
|
||||
`inert` focus blocking, real localStorage round-trips) live at the repo root
|
||||
under `e2e/`:
|
||||
|
||||
```bash
|
||||
bun run test:e2e # headless
|
||||
bun run test:e2e:ui # Playwright UI mode (debug)
|
||||
```
|
||||
|
||||
The spec drives a Vite-served fixture page (`web/e2e-fixtures/scratchlist-fixture.html`)
|
||||
that mounts the production `ScratchlistPanel` in isolation, so no hub /
|
||||
auth / socket setup is required.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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 e2e fixture</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-fixture.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Standalone Vite-served fixture for the scratchlist Playwright e2e
|
||||
* spec. Mounts only the ScratchlistPanel inside an I18nProvider so the
|
||||
* spec can drive a real browser against the real component without
|
||||
* having to mock the entire HAPI auth + socket stack.
|
||||
*
|
||||
* The session id is read from the `?session=...` query param (default
|
||||
* `e2e`) so individual specs can isolate localStorage state simply by
|
||||
* navigating to a unique URL.
|
||||
*
|
||||
* The fixture also exposes `window.__scratchlistE2E.setSessionId(id)`
|
||||
* so a spec can switch sessions WITHOUT a full page reload — this
|
||||
* reproduces the SessionChat pattern where the parent stays mounted
|
||||
* across same-route navigation. Used by the regression test for the
|
||||
* "stale entries leak from session A into session B" bug fixed in
|
||||
* `SessionChat.tsx` by keying the host by `session.id`.
|
||||
*
|
||||
* Promote callbacks are exposed on `window.__scratchlistE2E` so the
|
||||
* spec can assert that the right text reached `setText` (composer)
|
||||
* and `onSend` (queue) without involving the real composer / queue.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import '../src/index.css'
|
||||
import { I18nProvider } from '../src/lib/i18n-context'
|
||||
import { ScratchlistPanel } from '../src/components/AssistantChat/ScratchlistPanel'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__scratchlistE2E?: {
|
||||
sessionId: string
|
||||
promotedToComposer: string[]
|
||||
promotedToQueue: string[]
|
||||
queueSendMode: 'success' | 'failure'
|
||||
/** Whether the fixture's host wrapper applies `key={sessionId}`.
|
||||
* Mirrors the SessionChat fix; toggle via `?key=0` to repro the
|
||||
* pre-fix bug for red/green tests. Defaults to `true`. */
|
||||
keyByedSessionId: boolean
|
||||
setSessionId(id: string): void
|
||||
reset(): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialSessionId(): string {
|
||||
const url = new URL(window.location.href)
|
||||
return url.searchParams.get('session') ?? 'e2e'
|
||||
}
|
||||
|
||||
function getKeyByedSessionId(): boolean {
|
||||
const url = new URL(window.location.href)
|
||||
const raw = url.searchParams.get('key')
|
||||
if (raw === '0' || raw === 'false') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/*
|
||||
* Mirror of SessionChat's ScratchlistHost: a thin wrapper that owns
|
||||
* the promote callbacks. The spec drives sessionId changes through
|
||||
* the parent (App), while this host either keys by sessionId
|
||||
* (production behaviour) or doesn't (pre-fix repro).
|
||||
*/
|
||||
function ScratchlistHost({ sessionId, keyed }: { sessionId: string; keyed: boolean }) {
|
||||
const handlePromoteToComposer = React.useCallback((text: string) => {
|
||||
window.__scratchlistE2E?.promotedToComposer.push(text)
|
||||
}, [])
|
||||
|
||||
const handlePromoteToQueue = React.useCallback(async (text: string) => {
|
||||
const harness = window.__scratchlistE2E
|
||||
if (!harness) return false
|
||||
if (harness.queueSendMode === 'failure') {
|
||||
return false
|
||||
}
|
||||
harness.promotedToQueue.push(text)
|
||||
return true
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ScratchlistPanel
|
||||
key={keyed ? sessionId : undefined}
|
||||
sessionId={sessionId}
|
||||
onPromoteToComposer={handlePromoteToComposer}
|
||||
onPromoteToQueue={handlePromoteToQueue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [sessionId, setSessionId] = React.useState<string>(() => getInitialSessionId())
|
||||
const keyed = React.useMemo(() => getKeyByedSessionId(), [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const harness: NonNullable<Window['__scratchlistE2E']> = {
|
||||
sessionId,
|
||||
promotedToComposer: [],
|
||||
promotedToQueue: [],
|
||||
queueSendMode: 'success',
|
||||
keyByedSessionId: keyed,
|
||||
setSessionId: (id: string) => setSessionId(id),
|
||||
reset() {
|
||||
this.promotedToComposer = []
|
||||
this.promotedToQueue = []
|
||||
this.queueSendMode = 'success'
|
||||
},
|
||||
}
|
||||
window.__scratchlistE2E = harness
|
||||
}, [keyed])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (window.__scratchlistE2E) {
|
||||
window.__scratchlistE2E.sessionId = sessionId
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
<ScratchlistHost sessionId={sessionId} keyed={keyed} />
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (rootEl) {
|
||||
ReactDOM.createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { I18nProvider } from '@/lib/i18n-context'
|
||||
import {
|
||||
persistScratchlist,
|
||||
readScratchlist,
|
||||
type ScratchlistEntry,
|
||||
} from '@/lib/scratchlist'
|
||||
import { ScratchlistPanel } from './ScratchlistPanel'
|
||||
|
||||
const SID = 'session-test'
|
||||
|
||||
function renderPanel(props?: {
|
||||
onPromoteToComposer?: (text: string) => void
|
||||
onPromoteToQueue?: (text: string) => Promise<boolean>
|
||||
sessionId?: string
|
||||
}) {
|
||||
const onPromoteToComposer = props?.onPromoteToComposer ?? vi.fn()
|
||||
const onPromoteToQueue = props?.onPromoteToQueue ?? vi.fn(async () => true)
|
||||
return {
|
||||
onPromoteToComposer,
|
||||
onPromoteToQueue,
|
||||
...render(
|
||||
<I18nProvider>
|
||||
<ScratchlistPanel
|
||||
sessionId={props?.sessionId ?? SID}
|
||||
onPromoteToComposer={onPromoteToComposer}
|
||||
onPromoteToQueue={onPromoteToQueue}
|
||||
/>
|
||||
</I18nProvider>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function makeEntry(overrides: Partial<ScratchlistEntry> & { id: string }): ScratchlistEntry {
|
||||
return {
|
||||
text: 'note',
|
||||
createdAt: 1000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function expandPanel(): void {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Scratchlist/ }))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('ScratchlistPanel', () => {
|
||||
it('renders the held / not-sent label so users distinguish it from the queue', () => {
|
||||
renderPanel()
|
||||
// The held-label is rendered inside the toggle button as visual chrome
|
||||
// (aria-hidden) so use textContent rather than a name match.
|
||||
const toggle = screen.getByRole('button', { name: /Scratchlist/ })
|
||||
expect(toggle.textContent).toContain('held')
|
||||
})
|
||||
|
||||
it('starts collapsed by default; clicking the header expands it', () => {
|
||||
renderPanel()
|
||||
const toggle = screen.getByRole('button', { name: /Scratchlist/ })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expandPanel()
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('marks the inner content `inert` while collapsed so hidden controls are not focusable', () => {
|
||||
// Regression guard: upstream PR review flagged that under the
|
||||
// CSS-only collapse the textarea + action buttons were still in
|
||||
// the focus / a11y tree. The fix is `inert` on the inner; this
|
||||
// test fails if anyone reverts that.
|
||||
const { container } = renderPanel()
|
||||
const inner = container.querySelector('.collapsible-inner')
|
||||
expect(inner).not.toBeNull()
|
||||
expect(inner!.hasAttribute('inert')).toBe(true)
|
||||
|
||||
expandPanel()
|
||||
// jsdom doesn't always reflect the React `inert={false}` prop as
|
||||
// an attribute removal — accept either "absent" or empty string,
|
||||
// which both indicate non-inert per the HTML spec.
|
||||
const value = inner!.getAttribute('inert')
|
||||
expect(value === null || value === 'false' || value === '').toBe(true)
|
||||
})
|
||||
|
||||
it('hydrates entries that were persisted before mount', () => {
|
||||
persistScratchlist(SID, [
|
||||
makeEntry({ id: 'persisted-1', text: 'persisted note' }),
|
||||
])
|
||||
renderPanel()
|
||||
expandPanel()
|
||||
expect(screen.getByText('persisted note')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('adds a new entry via the add button and persists it', () => {
|
||||
renderPanel()
|
||||
expandPanel()
|
||||
const input = screen.getByLabelText('Add scratchlist entry') as HTMLTextAreaElement
|
||||
fireEvent.change(input, { target: { value: 'first thought' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(screen.getByText('first thought')).toBeTruthy()
|
||||
const stored = readScratchlist(SID)
|
||||
expect(stored.map((e) => e.text)).toEqual(['first thought'])
|
||||
})
|
||||
|
||||
it('adds a new entry on Enter; Shift+Enter does not add (preserves newline)', () => {
|
||||
renderPanel()
|
||||
expandPanel()
|
||||
const input = screen.getByLabelText('Add scratchlist entry') as HTMLTextAreaElement
|
||||
fireEvent.change(input, { target: { value: 'enter add' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(screen.getByText('enter add')).toBeTruthy()
|
||||
expect(readScratchlist(SID).map((e) => e.text)).toEqual(['enter add'])
|
||||
|
||||
// Shift+Enter must not promote to a new entry (it falls through to
|
||||
// textarea default newline behavior); the stored list stays unchanged.
|
||||
fireEvent.change(input, { target: { value: 'with newline' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
|
||||
expect(readScratchlist(SID).map((e) => e.text)).toEqual(['enter add'])
|
||||
})
|
||||
|
||||
it('deletes an entry without a confirm prompt for short entries', () => {
|
||||
persistScratchlist(SID, [makeEntry({ id: 'a', text: 'short' })])
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
renderPanel()
|
||||
expandPanel()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete entry' }))
|
||||
expect(confirmSpy).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('short')).toBeNull()
|
||||
expect(readScratchlist(SID)).toEqual([])
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('asks for confirmation before deleting long entries (>100 chars)', () => {
|
||||
const longText = 'x'.repeat(150)
|
||||
persistScratchlist(SID, [makeEntry({ id: 'a', text: longText })])
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
|
||||
renderPanel()
|
||||
expandPanel()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete entry' }))
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled()
|
||||
// Confirm rejected — entry stays.
|
||||
expect(readScratchlist(SID).map((e) => e.id)).toEqual(['a'])
|
||||
|
||||
confirmSpy.mockReturnValue(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete entry' }))
|
||||
expect(readScratchlist(SID)).toEqual([])
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reorders entries via the up / down arrow buttons', () => {
|
||||
persistScratchlist(SID, [
|
||||
makeEntry({ id: 'top', text: 'top entry' }),
|
||||
makeEntry({ id: 'bot', text: 'bot entry' }),
|
||||
])
|
||||
renderPanel()
|
||||
expandPanel()
|
||||
|
||||
// First entry is at index 0 — its up-button should be disabled.
|
||||
const upButtons = screen.getAllByRole('button', { name: 'Move entry up' })
|
||||
const downButtons = screen.getAllByRole('button', { name: 'Move entry down' })
|
||||
expect(upButtons[0]?.hasAttribute('disabled')).toBe(true)
|
||||
expect(downButtons[downButtons.length - 1]?.hasAttribute('disabled')).toBe(true)
|
||||
|
||||
// Move bottom row up -> swaps order.
|
||||
fireEvent.click(upButtons[1] as HTMLButtonElement)
|
||||
const stored = readScratchlist(SID)
|
||||
expect(stored.map((e) => e.id)).toEqual(['bot', 'top'])
|
||||
})
|
||||
|
||||
it('promote-to-composer copies text via the callback and keeps the entry', () => {
|
||||
persistScratchlist(SID, [makeEntry({ id: 'a', text: 'compose me' })])
|
||||
const onPromoteToComposer = vi.fn()
|
||||
renderPanel({ onPromoteToComposer })
|
||||
expandPanel()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy into composer' }))
|
||||
expect(onPromoteToComposer).toHaveBeenCalledWith('compose me')
|
||||
// Entry remains: promote-to-composer is a copy, not a move.
|
||||
expect(readScratchlist(SID).map((e) => e.id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('promote-to-queue calls onSend and removes the entry on accepted send', async () => {
|
||||
persistScratchlist(SID, [makeEntry({ id: 'a', text: 'queue me' })])
|
||||
const onPromoteToQueue = vi.fn(async () => true)
|
||||
renderPanel({ onPromoteToQueue })
|
||||
expandPanel()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send to queue' }))
|
||||
await waitFor(() => expect(onPromoteToQueue).toHaveBeenCalledWith('queue me'))
|
||||
await waitFor(() => expect(screen.queryByText('queue me')).toBeNull())
|
||||
expect(readScratchlist(SID)).toEqual([])
|
||||
})
|
||||
|
||||
it('promote-to-queue keeps the entry when the send is rejected', async () => {
|
||||
persistScratchlist(SID, [makeEntry({ id: 'a', text: 'queue me' })])
|
||||
const onPromoteToQueue = vi.fn(async () => false)
|
||||
renderPanel({ onPromoteToQueue })
|
||||
expandPanel()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send to queue' }))
|
||||
await waitFor(() => expect(onPromoteToQueue).toHaveBeenCalledWith('queue me'))
|
||||
// Entry remains because the queue rejected the promotion.
|
||||
expect(screen.getByText('queue me')).toBeTruthy()
|
||||
expect(readScratchlist(SID).map((e) => e.id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('persists collapse state across mounts for the same session', () => {
|
||||
const { unmount } = renderPanel()
|
||||
expandPanel()
|
||||
unmount()
|
||||
|
||||
// Re-mount with the same session id; should remain expanded.
|
||||
const second = renderPanel()
|
||||
const toggle = second.getByRole('button', { name: /Scratchlist/ })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('isolates entries between sessions', () => {
|
||||
persistScratchlist('session-A', [makeEntry({ id: 'a1', text: 'A note' })])
|
||||
persistScratchlist('session-B', [makeEntry({ id: 'b1', text: 'B note' })])
|
||||
|
||||
const a = renderPanel({ sessionId: 'session-A' })
|
||||
fireEvent.click(a.getByRole('button', { name: /Scratchlist/ }))
|
||||
expect(a.getByText('A note')).toBeTruthy()
|
||||
expect(a.queryByText('B note')).toBeNull()
|
||||
a.unmount()
|
||||
|
||||
const b = renderPanel({ sessionId: 'session-B' })
|
||||
fireEvent.click(b.getByRole('button', { name: /Scratchlist/ }))
|
||||
expect(b.getByText('B note')).toBeTruthy()
|
||||
expect(b.queryByText('A note')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,437 @@
|
||||
import {
|
||||
type FormEvent as ReactFormEvent,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
addScratchlistEntry,
|
||||
deleteScratchlistEntry,
|
||||
moveScratchlistEntry,
|
||||
persistScratchlist,
|
||||
readScratchlist,
|
||||
SCRATCHLIST_MAX_ENTRIES,
|
||||
SCRATCHLIST_MAX_TEXT_LENGTH,
|
||||
shouldConfirmDelete,
|
||||
type ScratchlistEntry,
|
||||
} from '@/lib/scratchlist'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'hapi.scratchlist-collapsed.v1.'
|
||||
|
||||
function readCollapsedPref(sessionId: string): boolean {
|
||||
if (typeof window === 'undefined') return true
|
||||
try {
|
||||
const raw = window.localStorage.getItem(`${STORAGE_KEY_PREFIX}${sessionId}`)
|
||||
return raw === null ? true : raw === '1'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function writeCollapsedPref(sessionId: string, collapsed: boolean): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
`${STORAGE_KEY_PREFIX}${sessionId}`,
|
||||
collapsed ? '1' : '0'
|
||||
)
|
||||
} catch {
|
||||
// Non-fatal.
|
||||
}
|
||||
}
|
||||
|
||||
function NoteIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="h-[14px] w-[14px] shrink-0"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3.5 2.5h6L12.5 5.5v8a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1v-10a1 1 0 0 1 1-1Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.5 2.5v3h3M5 8.5h6M5 11h4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
className={`h-3 w-3 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m4 3 4 3-4 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowUpIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" className="h-3.5 w-3.5" aria-hidden="true">
|
||||
<path d="M8 12V4M8 4l3 3M8 4 5 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowDownIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" className="h-3.5 w-3.5" aria-hidden="true">
|
||||
<path d="M8 4v8M8 12l3-3M8 12 5 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PencilIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" className="h-3.5 w-3.5" aria-hidden="true">
|
||||
<path
|
||||
d="M11.5 2.5a1.414 1.414 0 0 1 2 2L5 13H3v-2L11.5 2.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SendIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" className="h-3.5 w-3.5" aria-hidden="true">
|
||||
<path d="M2.5 8 13.5 3 11 13l-3-4-5.5-1Z" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
|
||||
<path d="m11 13-3-4 5.5-6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" className="h-3.5 w-3.5" aria-hidden="true">
|
||||
<path
|
||||
d="M3.5 4.5h9M6 4.5V3a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v1.5M5 4.5l.5 8a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1l.5-8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session scratchlist (issue #11) -- the operator's "workbench".
|
||||
*
|
||||
* Distinct from the queue (`QueuedMessagesBar`):
|
||||
* - Queue = conveyor belt: messages auto-fire in order once the agent is idle.
|
||||
* - Scratchlist = workbench: notes / drafts / parking-lot ideas held until the
|
||||
* operator explicitly promotes them (to the composer or into the queue).
|
||||
*
|
||||
* The "held -- not sent" pill plus the amber accent is the visual signal
|
||||
* that nothing here is being sent without an explicit action.
|
||||
*/
|
||||
export function ScratchlistPanel({
|
||||
sessionId,
|
||||
onPromoteToComposer,
|
||||
onPromoteToQueue,
|
||||
}: {
|
||||
sessionId: string
|
||||
/**
|
||||
* Copies the entry text into the composer for editing. Called with the
|
||||
* raw entry text. Implementation lives in SessionChat (it owns the
|
||||
* AssistantUI runtime that exposes setText).
|
||||
*/
|
||||
onPromoteToComposer: (text: string) => void
|
||||
/**
|
||||
* Sends the entry into the existing send-queue (same path as a normal
|
||||
* composer send). Resolves true when the send was accepted, false when
|
||||
* pre-mutation guards rejected it -- matches the contract of
|
||||
* useSendMessage.sendMessage so the UI knows whether to remove the
|
||||
* scratchlist entry on success.
|
||||
*/
|
||||
onPromoteToQueue: (text: string) => Promise<boolean>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [entries, setEntries] = useState<ScratchlistEntry[]>(() => readScratchlist(sessionId))
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsedPref(sessionId))
|
||||
const [draft, setDraft] = useState<string>('')
|
||||
const [busyEntryId, setBusyEntryId] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Re-hydrate when the session id changes (route navigation between sessions).
|
||||
useEffect(() => {
|
||||
setEntries(readScratchlist(sessionId))
|
||||
setCollapsed(readCollapsedPref(sessionId))
|
||||
setDraft('')
|
||||
setBusyEntryId(null)
|
||||
}, [sessionId])
|
||||
|
||||
// Persist on every change. The storage layer swallows quota / serialization
|
||||
// errors so this won't throw.
|
||||
useEffect(() => {
|
||||
persistScratchlist(sessionId, entries)
|
||||
}, [sessionId, entries])
|
||||
|
||||
// Global keyboard shortcut: Ctrl/Cmd + Shift + S focuses the add-input
|
||||
// and expands the panel. Suggested by the handoff doc; matches the
|
||||
// convention used by other composer-adjacent shortcuts (Ctrl/Cmd-m for
|
||||
// model cycling) so it shouldn't collide with browser defaults that the
|
||||
// app cares about.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: globalThis.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'S' || e.key === 's')) {
|
||||
e.preventDefault()
|
||||
setCollapsed(false)
|
||||
writeCollapsedPref(sessionId, false)
|
||||
queueMicrotask(() => inputRef.current?.focus())
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [sessionId])
|
||||
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev
|
||||
writeCollapsedPref(sessionId, next)
|
||||
return next
|
||||
})
|
||||
}, [sessionId])
|
||||
|
||||
const handleAdd = useCallback((rawText: string) => {
|
||||
setEntries((prev) => addScratchlistEntry(prev, rawText).entries)
|
||||
setDraft('')
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback((event: ReactFormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
handleAdd(draft)
|
||||
}, [draft, handleAdd])
|
||||
|
||||
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Plain Enter adds; Shift+Enter inserts a newline. Mirrors the
|
||||
// composer's default keyboard-send behavior so muscle memory carries
|
||||
// over and reduces accidental newlines in scratchlist titles.
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
handleAdd(draft)
|
||||
}
|
||||
}, [draft, handleAdd])
|
||||
|
||||
const handleDelete = useCallback((entry: ScratchlistEntry) => {
|
||||
if (shouldConfirmDelete(entry)) {
|
||||
const confirmed = typeof window !== 'undefined'
|
||||
? window.confirm(t('scratchlist.confirmDelete'))
|
||||
: true
|
||||
if (!confirmed) return
|
||||
}
|
||||
setEntries((prev) => deleteScratchlistEntry(prev, entry.id))
|
||||
}, [t])
|
||||
|
||||
const handleMove = useCallback((entry: ScratchlistEntry, direction: 'up' | 'down') => {
|
||||
setEntries((prev) => moveScratchlistEntry(prev, entry.id, direction))
|
||||
}, [])
|
||||
|
||||
const handlePromoteToComposer = useCallback((entry: ScratchlistEntry) => {
|
||||
onPromoteToComposer(entry.text)
|
||||
// Promote-to-composer is a copy, not a move: the entry stays in the
|
||||
// scratchlist so the operator can iterate. Promote-to-queue is the
|
||||
// destructive variant.
|
||||
}, [onPromoteToComposer])
|
||||
|
||||
const handlePromoteToQueue = useCallback(async (entry: ScratchlistEntry) => {
|
||||
if (busyEntryId) return
|
||||
setBusyEntryId(entry.id)
|
||||
try {
|
||||
const accepted = await onPromoteToQueue(entry.text)
|
||||
if (accepted) {
|
||||
setEntries((prev) => deleteScratchlistEntry(prev, entry.id))
|
||||
}
|
||||
} finally {
|
||||
setBusyEntryId(null)
|
||||
}
|
||||
}, [busyEntryId, onPromoteToQueue])
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (entries.length === 0) return t('scratchlist.empty')
|
||||
if (entries.length === 1) return t('scratchlist.count.one')
|
||||
return t('scratchlist.count.other', { n: entries.length })
|
||||
}, [entries.length, t])
|
||||
|
||||
const hasReachedCap = entries.length >= SCRATCHLIST_MAX_ENTRIES
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-content mb-1">
|
||||
<div
|
||||
className="rounded-lg border border-[var(--app-badge-warning-border)] bg-[var(--app-badge-warning-bg)]"
|
||||
data-testid="scratchlist-panel"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapsed}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={`scratchlist-body-${sessionId}`}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-[var(--app-badge-warning-text)] hover:opacity-90"
|
||||
>
|
||||
<ChevronIcon open={!collapsed} />
|
||||
<NoteIcon />
|
||||
<span className="flex-1 truncate">
|
||||
{t('scratchlist.title')}
|
||||
</span>
|
||||
<span
|
||||
className="rounded-full border border-[var(--app-badge-warning-border)] px-1.5 py-0.5 text-[10px] uppercase tracking-wide"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{t('scratchlist.heldLabel')}
|
||||
</span>
|
||||
<span className="text-[var(--app-hint)] text-[11px] tabular-nums">
|
||||
{summary}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={`scratchlist-body-${sessionId}`}
|
||||
className="collapsible-panel"
|
||||
aria-hidden={collapsed}
|
||||
{...(!collapsed ? { 'data-open': '' } : {})}
|
||||
>
|
||||
{/*
|
||||
* `inert` removes the inner controls from the focus and
|
||||
* pointer-events tree (and the accessibility tree) while
|
||||
* collapsed. CSS-only collapse left the textarea + buttons
|
||||
* focusable under aria-hidden, which is the regression
|
||||
* flagged by the upstream PR review (a11y violation:
|
||||
* focusable descendants inside an aria-hidden subtree).
|
||||
* Using inert preserves the grid-template-rows expand
|
||||
* animation while keeping the collapsed body unreachable.
|
||||
*/}
|
||||
<div className="collapsible-inner" inert={collapsed}>
|
||||
<div className="px-3 pb-3">
|
||||
<form onSubmit={handleSubmit} className="flex items-start gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={1}
|
||||
maxLength={SCRATCHLIST_MAX_TEXT_LENGTH}
|
||||
placeholder={t('scratchlist.addPlaceholder')}
|
||||
aria-label={t('scratchlist.addAriaLabel')}
|
||||
disabled={hasReachedCap}
|
||||
className="flex-1 min-w-0 resize-none rounded-md bg-[var(--app-bg)] px-2 py-1.5 text-sm text-[var(--app-fg)] placeholder-[var(--app-hint)] focus:outline-none focus:ring-1 focus:ring-[var(--app-badge-warning-text)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={hasReachedCap || draft.trim().length === 0}
|
||||
className="shrink-0 rounded-md border border-[var(--app-badge-warning-border)] bg-[var(--app-bg)] px-3 py-1.5 text-xs font-medium text-[var(--app-badge-warning-text)] hover:bg-[var(--app-subtle-bg)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t('scratchlist.add')}
|
||||
</button>
|
||||
</form>
|
||||
{hasReachedCap ? (
|
||||
<p className="mt-1 text-[11px] text-[var(--app-hint)]">
|
||||
{t('scratchlist.atCap', { n: SCRATCHLIST_MAX_ENTRIES })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{entries.length > 0 ? (
|
||||
<ul
|
||||
aria-label={t('scratchlist.listAriaLabel')}
|
||||
className="mt-2 flex max-h-64 flex-col gap-1.5 overflow-y-auto"
|
||||
>
|
||||
{entries.map((entry, index) => {
|
||||
const isFirst = index === 0
|
||||
const isLast = index === entries.length - 1
|
||||
const isBusy = busyEntryId === entry.id
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-start gap-2 rounded-md bg-[var(--app-bg)] px-2 py-1.5 shadow-sm"
|
||||
data-testid="scratchlist-entry"
|
||||
>
|
||||
<span className="flex-1 min-w-0 whitespace-pre-wrap break-words text-sm text-[var(--app-fg)] line-clamp-4">
|
||||
{entry.text}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-0.5 text-[var(--app-hint)]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('scratchlist.action.moveUp')}
|
||||
title={t('scratchlist.action.moveUp')}
|
||||
onClick={() => handleMove(entry, 'up')}
|
||||
disabled={isFirst || isBusy}
|
||||
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('scratchlist.action.moveDown')}
|
||||
title={t('scratchlist.action.moveDown')}
|
||||
onClick={() => handleMove(entry, 'down')}
|
||||
disabled={isLast || isBusy}
|
||||
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('scratchlist.action.promoteToComposer')}
|
||||
title={t('scratchlist.action.promoteToComposer')}
|
||||
onClick={() => handlePromoteToComposer(entry)}
|
||||
disabled={isBusy}
|
||||
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<PencilIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('scratchlist.action.promoteToQueue')}
|
||||
title={t('scratchlist.action.promoteToQueue')}
|
||||
onClick={() => { void handlePromoteToQueue(entry) }}
|
||||
disabled={isBusy}
|
||||
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<SendIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('scratchlist.action.delete')}
|
||||
title={t('scratchlist.action.delete')}
|
||||
onClick={() => handleDelete(entry)}
|
||||
disabled={isBusy}
|
||||
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-2 text-[11px] text-[var(--app-hint)]">
|
||||
{t('scratchlist.emptyHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { AssistantRuntimeProvider } from '@assistant-ui/react'
|
||||
import { AssistantRuntimeProvider, useAssistantApi } from '@assistant-ui/react'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
@@ -24,6 +24,7 @@ import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePic
|
||||
import { resolvePendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
|
||||
import { HappyThread } from '@/components/AssistantChat/HappyThread'
|
||||
import { QueuedMessagesBar } from '@/components/AssistantChat/QueuedMessagesBar'
|
||||
import { ScratchlistPanel } from '@/components/AssistantChat/ScratchlistPanel'
|
||||
import { useHappyRuntime } from '@/lib/assistant-runtime'
|
||||
import { createAttachmentAdapter } from '@/lib/attachmentAdapter'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
@@ -55,6 +56,35 @@ function isUninvokedScheduledMessage(message: DecryptedMessage): boolean {
|
||||
return message.invokedAt == null && message.scheduledAt != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the per-session scratchlist (issue #11) inside the AssistantUI
|
||||
* runtime so promote-to-composer can call `composer().setText(...)`.
|
||||
* Promote-to-queue routes to the same `onSend` path as a normal composer
|
||||
* send, so a promoted entry shows up immediately in `QueuedMessagesBar`.
|
||||
*/
|
||||
function ScratchlistHost({
|
||||
sessionId,
|
||||
onSend,
|
||||
}: {
|
||||
sessionId: string
|
||||
onSend: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => Promise<boolean>
|
||||
}) {
|
||||
const assistantApi = useAssistantApi()
|
||||
const handlePromoteToComposer = useCallback((text: string) => {
|
||||
assistantApi.composer().setText(text)
|
||||
}, [assistantApi])
|
||||
const handlePromoteToQueue = useCallback(async (text: string) => {
|
||||
return await onSend(text)
|
||||
}, [onSend])
|
||||
return (
|
||||
<ScratchlistPanel
|
||||
sessionId={sessionId}
|
||||
onPromoteToComposer={handlePromoteToComposer}
|
||||
onPromoteToQueue={handlePromoteToQueue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function buildGoalStateMessages(
|
||||
messages: DecryptedMessage[],
|
||||
pendingMessages: DecryptedMessage[] = []
|
||||
@@ -606,6 +636,24 @@ export function SessionChat(props: {
|
||||
) : null}
|
||||
|
||||
<div className="px-3">
|
||||
{/*
|
||||
* Key by session id so React unmounts/remounts when
|
||||
* the operator switches sessions without remounting
|
||||
* SessionChat (e.g. same-route navigation A -> B).
|
||||
* Without this, ScratchlistPanel's useState
|
||||
* initializer reads sessionId once at mount; the
|
||||
* useEffect rehydrate then races against the persist
|
||||
* effect, briefly rendering A's entries under B and
|
||||
* writing them into B's localStorage before
|
||||
* correcting. Keying makes the first render for B
|
||||
* read B's storage directly. Cleaner than chasing
|
||||
* the race inside the panel.
|
||||
*/}
|
||||
<ScratchlistHost
|
||||
key={props.session.id}
|
||||
sessionId={props.session.id}
|
||||
onSend={props.onSend}
|
||||
/>
|
||||
<QueuedMessagesBar
|
||||
sessionId={props.session.id}
|
||||
api={props.api}
|
||||
|
||||
@@ -371,6 +371,25 @@ export default {
|
||||
'composer.scheduleErrorTooFar': 'Maximum schedule time is 7 days.',
|
||||
'queuedMessages.scheduledFor': 'Scheduled for {time}',
|
||||
'queuedMessages.editAlreadyInvoked': "Message already sent — it can't be edited",
|
||||
|
||||
// Scratchlist (per-session workbench, issue #11)
|
||||
'scratchlist.title': 'Scratchlist',
|
||||
'scratchlist.heldLabel': 'held — not sent',
|
||||
'scratchlist.empty': 'empty',
|
||||
'scratchlist.count.one': '1 item',
|
||||
'scratchlist.count.other': '{n} items',
|
||||
'scratchlist.emptyHint': 'Park notes, drafts, or ideas here. Nothing is sent until you promote it.',
|
||||
'scratchlist.addPlaceholder': 'Note, draft, or idea — Enter to add',
|
||||
'scratchlist.addAriaLabel': 'Add scratchlist entry',
|
||||
'scratchlist.add': 'Add',
|
||||
'scratchlist.atCap': 'Scratchlist is at its {n}-entry cap. Delete an old entry to add more.',
|
||||
'scratchlist.confirmDelete': 'Delete this scratchlist entry? This cannot be undone.',
|
||||
'scratchlist.listAriaLabel': 'Scratchlist entries',
|
||||
'scratchlist.action.moveUp': 'Move entry up',
|
||||
'scratchlist.action.moveDown': 'Move entry down',
|
||||
'scratchlist.action.promoteToComposer': 'Copy into composer',
|
||||
'scratchlist.action.promoteToQueue': 'Send to queue',
|
||||
'scratchlist.action.delete': 'Delete entry',
|
||||
'composer.codexSlashUnsupported.title': 'Codex command unavailable',
|
||||
'composer.codexSlashUnsupported.body': 'HAPI remote mode does not yet run built-in Codex slash commands like {command}. Use natural language instead, or run it in the local Codex TUI.',
|
||||
|
||||
|
||||
@@ -373,6 +373,25 @@ export default {
|
||||
'composer.scheduleErrorTooFar': '最多只能定时 7 天。',
|
||||
'queuedMessages.scheduledFor': '定时发送: {time}',
|
||||
'queuedMessages.editAlreadyInvoked': '消息已发送,无法编辑',
|
||||
|
||||
// Scratchlist (per-session workbench, issue #11)
|
||||
'scratchlist.title': '草稿夹',
|
||||
'scratchlist.heldLabel': '暂存 · 未发送',
|
||||
'scratchlist.empty': '空',
|
||||
'scratchlist.count.one': '1 条',
|
||||
'scratchlist.count.other': '{n} 条',
|
||||
'scratchlist.emptyHint': '在此暂存笔记、草稿或想法。需点击发送或编辑后才会真正发出。',
|
||||
'scratchlist.addPlaceholder': '笔记、草稿或想法 — 回车键添加',
|
||||
'scratchlist.addAriaLabel': '添加草稿夹条目',
|
||||
'scratchlist.add': '添加',
|
||||
'scratchlist.atCap': '草稿夹已满({n} 条)。请先删除旧条目。',
|
||||
'scratchlist.confirmDelete': '删除该草稿条目?此操作不可撤销。',
|
||||
'scratchlist.listAriaLabel': '草稿夹条目',
|
||||
'scratchlist.action.moveUp': '上移',
|
||||
'scratchlist.action.moveDown': '下移',
|
||||
'scratchlist.action.promoteToComposer': '复制到输入框',
|
||||
'scratchlist.action.promoteToQueue': '加入发送队列',
|
||||
'scratchlist.action.delete': '删除条目',
|
||||
'composer.codexSlashUnsupported.title': '无法执行 Codex 命令',
|
||||
'composer.codexSlashUnsupported.body': 'HAPI 远程模式暂不支持 {command} 这类 Codex 内建 slash command,请改用自然语言,或在本地 Codex TUI 中执行。',
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
addScratchlistEntry,
|
||||
clearScratchlist,
|
||||
deleteScratchlistEntry,
|
||||
moveScratchlistEntry,
|
||||
persistScratchlist,
|
||||
readScratchlist,
|
||||
SCRATCHLIST_CONFIRM_DELETE_THRESHOLD,
|
||||
SCRATCHLIST_MAX_ENTRIES,
|
||||
SCRATCHLIST_MAX_TEXT_LENGTH,
|
||||
shouldConfirmDelete,
|
||||
type ScratchlistEntry,
|
||||
} from './scratchlist'
|
||||
|
||||
const SID = 'session-test'
|
||||
|
||||
function makeEntry(overrides: Partial<ScratchlistEntry> & { id: string }): ScratchlistEntry {
|
||||
return {
|
||||
text: 'note',
|
||||
createdAt: 1000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('addScratchlistEntry', () => {
|
||||
it('prepends new entries (newest-first ordering)', () => {
|
||||
const initial: ScratchlistEntry[] = [makeEntry({ id: 'old', text: 'older' })]
|
||||
const { entries, added } = addScratchlistEntry(initial, 'newer', 2000)
|
||||
expect(added?.text).toBe('newer')
|
||||
expect(added?.createdAt).toBe(2000)
|
||||
expect(entries.map((e) => e.text)).toEqual(['newer', 'older'])
|
||||
})
|
||||
|
||||
it('rejects empty / whitespace-only input', () => {
|
||||
const initial: ScratchlistEntry[] = [makeEntry({ id: 'a' })]
|
||||
expect(addScratchlistEntry(initial, '').added).toBeNull()
|
||||
expect(addScratchlistEntry(initial, ' ').added).toBeNull()
|
||||
expect(addScratchlistEntry(initial, '\n\t').added).toBeNull()
|
||||
expect(addScratchlistEntry(initial, ' ').entries).toBe(initial)
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace before storing', () => {
|
||||
const { added } = addScratchlistEntry([], ' hello world \n', 1000)
|
||||
expect(added?.text).toBe('hello world')
|
||||
})
|
||||
|
||||
it('truncates entries longer than the per-entry cap rather than rejecting', () => {
|
||||
const huge = 'x'.repeat(SCRATCHLIST_MAX_TEXT_LENGTH + 50)
|
||||
const { added } = addScratchlistEntry([], huge)
|
||||
expect(added).not.toBeNull()
|
||||
expect(added!.text.length).toBe(SCRATCHLIST_MAX_TEXT_LENGTH)
|
||||
})
|
||||
|
||||
it('caps the list at SCRATCHLIST_MAX_ENTRIES (drops oldest tail)', () => {
|
||||
const initial: ScratchlistEntry[] = []
|
||||
for (let i = 0; i < SCRATCHLIST_MAX_ENTRIES; i++) {
|
||||
initial.push(makeEntry({ id: `e${i}`, text: `entry-${i}` }))
|
||||
}
|
||||
const { entries } = addScratchlistEntry(initial, 'fresh')
|
||||
expect(entries.length).toBe(SCRATCHLIST_MAX_ENTRIES)
|
||||
expect(entries[0]?.text).toBe('fresh')
|
||||
// The previous tail entry (oldest) should be dropped after cap-trim.
|
||||
expect(entries[entries.length - 1]?.text).toBe(
|
||||
initial[SCRATCHLIST_MAX_ENTRIES - 2]?.text
|
||||
)
|
||||
})
|
||||
|
||||
it('assigns unique ids to consecutive entries', () => {
|
||||
const a = addScratchlistEntry([], 'one').added
|
||||
const b = addScratchlistEntry([], 'two').added
|
||||
expect(a?.id).toBeTruthy()
|
||||
expect(b?.id).toBeTruthy()
|
||||
expect(a?.id).not.toBe(b?.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteScratchlistEntry', () => {
|
||||
it('removes the entry with the matching id', () => {
|
||||
const entries: ScratchlistEntry[] = [
|
||||
makeEntry({ id: 'a' }),
|
||||
makeEntry({ id: 'b' }),
|
||||
makeEntry({ id: 'c' }),
|
||||
]
|
||||
expect(deleteScratchlistEntry(entries, 'b').map((e) => e.id)).toEqual(['a', 'c'])
|
||||
})
|
||||
|
||||
it('is a no-op for unknown ids', () => {
|
||||
const entries: ScratchlistEntry[] = [makeEntry({ id: 'a' })]
|
||||
expect(deleteScratchlistEntry(entries, 'missing')).toEqual(entries)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveScratchlistEntry', () => {
|
||||
function ids(entries: ScratchlistEntry[]): string[] {
|
||||
return entries.map((e) => e.id)
|
||||
}
|
||||
|
||||
const sample: ScratchlistEntry[] = [
|
||||
makeEntry({ id: 'a' }),
|
||||
makeEntry({ id: 'b' }),
|
||||
makeEntry({ id: 'c' }),
|
||||
]
|
||||
|
||||
it('moves an entry up by one position', () => {
|
||||
expect(ids(moveScratchlistEntry(sample, 'b', 'up'))).toEqual(['b', 'a', 'c'])
|
||||
})
|
||||
|
||||
it('moves an entry down by one position', () => {
|
||||
expect(ids(moveScratchlistEntry(sample, 'b', 'down'))).toEqual(['a', 'c', 'b'])
|
||||
})
|
||||
|
||||
it('is a no-op when moving the first entry up', () => {
|
||||
expect(moveScratchlistEntry(sample, 'a', 'up')).toBe(sample)
|
||||
})
|
||||
|
||||
it('is a no-op when moving the last entry down', () => {
|
||||
expect(moveScratchlistEntry(sample, 'c', 'down')).toBe(sample)
|
||||
})
|
||||
|
||||
it('is a no-op for unknown ids', () => {
|
||||
expect(moveScratchlistEntry(sample, 'missing', 'up')).toBe(sample)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldConfirmDelete', () => {
|
||||
it('confirms only when entry text exceeds the threshold', () => {
|
||||
const short = makeEntry({ id: 'a', text: 'x'.repeat(SCRATCHLIST_CONFIRM_DELETE_THRESHOLD) })
|
||||
const long = makeEntry({ id: 'b', text: 'x'.repeat(SCRATCHLIST_CONFIRM_DELETE_THRESHOLD + 1) })
|
||||
expect(shouldConfirmDelete(short)).toBe(false)
|
||||
expect(shouldConfirmDelete(long)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for null / undefined entries', () => {
|
||||
expect(shouldConfirmDelete(null)).toBe(false)
|
||||
expect(shouldConfirmDelete(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('localStorage round-trip', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('persists and reads back entries scoped per session', () => {
|
||||
const entriesA: ScratchlistEntry[] = [
|
||||
makeEntry({ id: 'a1', text: 'a-one' }),
|
||||
makeEntry({ id: 'a2', text: 'a-two' }),
|
||||
]
|
||||
const entriesB: ScratchlistEntry[] = [makeEntry({ id: 'b1', text: 'b-one' })]
|
||||
persistScratchlist('session-a', entriesA)
|
||||
persistScratchlist('session-b', entriesB)
|
||||
|
||||
expect(readScratchlist('session-a')).toEqual(entriesA)
|
||||
expect(readScratchlist('session-b')).toEqual(entriesB)
|
||||
})
|
||||
|
||||
it('returns [] for an unknown session', () => {
|
||||
expect(readScratchlist('never-written')).toEqual([])
|
||||
})
|
||||
|
||||
it('clears entries for a session', () => {
|
||||
persistScratchlist(SID, [makeEntry({ id: 'a' })])
|
||||
clearScratchlist(SID)
|
||||
expect(readScratchlist(SID)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] when stored value is malformed JSON', () => {
|
||||
localStorage.setItem(`hapi.scratchlist.v1.${SID}`, '{not-json')
|
||||
expect(readScratchlist(SID)).toEqual([])
|
||||
})
|
||||
|
||||
it('skips invalid entries inside the stored array (forward compatibility)', () => {
|
||||
const valid = makeEntry({ id: 'valid', text: 'ok' })
|
||||
localStorage.setItem(
|
||||
`hapi.scratchlist.v1.${SID}`,
|
||||
JSON.stringify([
|
||||
valid,
|
||||
{ id: '', text: 'no id', createdAt: 1 }, // invalid id
|
||||
{ id: 'x', text: 5, createdAt: 1 }, // wrong text type
|
||||
'string entry', // wrong shape
|
||||
null,
|
||||
])
|
||||
)
|
||||
const got = readScratchlist(SID)
|
||||
expect(got).toEqual([valid])
|
||||
})
|
||||
|
||||
it('survives localStorage write failures', () => {
|
||||
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota exceeded')
|
||||
})
|
||||
expect(() => persistScratchlist(SID, [makeEntry({ id: 'a' })])).not.toThrow()
|
||||
setItem.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Per-session scratchlist storage (issue #11).
|
||||
*
|
||||
* The scratchlist is the operator's *workbench*: notes / drafts / parking lot
|
||||
* entries that are explicitly **not** queued for sending. Compare to the
|
||||
* queue (`QueuedMessagesBar`), which is a conveyor belt that auto-fires
|
||||
* messages in order. Scratchlist entries are held until the operator
|
||||
* promotes them (to the composer or into the queue) or deletes them.
|
||||
*
|
||||
* Storage is per-session in `localStorage` under
|
||||
* `hapi.scratchlist.v1.<sessionId>` so entries survive reloads but stay
|
||||
* scoped to a single conversation. Hub-sync is intentionally deferred
|
||||
* (v2) to keep this PR small.
|
||||
*/
|
||||
const STORAGE_KEY_PREFIX = 'hapi.scratchlist.v1.'
|
||||
|
||||
/** Hard upper bound to keep payloads sane and rule out runaway growth. */
|
||||
export const SCRATCHLIST_MAX_ENTRIES = 200
|
||||
|
||||
/** Per-entry text cap: matches what a long composer paste can produce. */
|
||||
export const SCRATCHLIST_MAX_TEXT_LENGTH = 10_000
|
||||
|
||||
export type ScratchlistEntry = {
|
||||
id: string
|
||||
text: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
function getStorageKey(sessionId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${sessionId}`
|
||||
}
|
||||
|
||||
function getLocalStorage(): Storage | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return window.localStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isEntry(value: unknown): value is ScratchlistEntry {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const entry = value as Record<string, unknown>
|
||||
return (
|
||||
typeof entry.id === 'string'
|
||||
&& entry.id.length > 0
|
||||
&& typeof entry.text === 'string'
|
||||
&& typeof entry.createdAt === 'number'
|
||||
&& Number.isFinite(entry.createdAt)
|
||||
)
|
||||
}
|
||||
|
||||
export function readScratchlist(sessionId: string): ScratchlistEntry[] {
|
||||
if (!sessionId) return []
|
||||
const storage = getLocalStorage()
|
||||
if (!storage) return []
|
||||
|
||||
let raw: string | null
|
||||
try {
|
||||
raw = storage.getItem(getStorageKey(sessionId))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
if (!raw) return []
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
if (!Array.isArray(parsed)) return []
|
||||
|
||||
const entries: ScratchlistEntry[] = []
|
||||
for (const item of parsed) {
|
||||
if (isEntry(item)) entries.push(item)
|
||||
if (entries.length >= SCRATCHLIST_MAX_ENTRIES) break
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function writeScratchlist(sessionId: string, entries: ScratchlistEntry[]): void {
|
||||
if (!sessionId) return
|
||||
const storage = getLocalStorage()
|
||||
if (!storage) return
|
||||
try {
|
||||
const trimmed = entries.slice(0, SCRATCHLIST_MAX_ENTRIES)
|
||||
storage.setItem(getStorageKey(sessionId), JSON.stringify(trimmed))
|
||||
} catch {
|
||||
// Storage quota or serialization failures are non-fatal: the in-memory
|
||||
// copy still works for the rest of the session.
|
||||
}
|
||||
}
|
||||
|
||||
function makeEntryId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `scratch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a new entry to the scratchlist. Returns the new entry list (or
|
||||
* the previous list unchanged when text is empty / would exceed the cap).
|
||||
*
|
||||
* Trimming behavior: leading/trailing whitespace stripped; empty input
|
||||
* is rejected (returns the input list unchanged). Entries longer than
|
||||
* `SCRATCHLIST_MAX_TEXT_LENGTH` are truncated rather than rejected so
|
||||
* pasting a giant blob still ends up captured.
|
||||
*/
|
||||
export function addScratchlistEntry(
|
||||
entries: ScratchlistEntry[],
|
||||
rawText: string,
|
||||
now: number = Date.now()
|
||||
): { entries: ScratchlistEntry[]; added: ScratchlistEntry | null } {
|
||||
const text = rawText.trim()
|
||||
if (text.length === 0) {
|
||||
return { entries, added: null }
|
||||
}
|
||||
const truncated = text.length > SCRATCHLIST_MAX_TEXT_LENGTH
|
||||
? text.slice(0, SCRATCHLIST_MAX_TEXT_LENGTH)
|
||||
: text
|
||||
const entry: ScratchlistEntry = {
|
||||
id: makeEntryId(),
|
||||
text: truncated,
|
||||
createdAt: now,
|
||||
}
|
||||
// Newest-first ordering: matches the way operators read the workbench
|
||||
// (most recent thought at the top, scrolling down for older).
|
||||
const next = [entry, ...entries].slice(0, SCRATCHLIST_MAX_ENTRIES)
|
||||
return { entries: next, added: entry }
|
||||
}
|
||||
|
||||
export function deleteScratchlistEntry(
|
||||
entries: ScratchlistEntry[],
|
||||
id: string
|
||||
): ScratchlistEntry[] {
|
||||
return entries.filter((e) => e.id !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an entry up (toward index 0) or down (toward the end). Out-of-range
|
||||
* moves are no-ops so the UI can call this unconditionally without first
|
||||
* checking position.
|
||||
*/
|
||||
export function moveScratchlistEntry(
|
||||
entries: ScratchlistEntry[],
|
||||
id: string,
|
||||
direction: 'up' | 'down'
|
||||
): ScratchlistEntry[] {
|
||||
const index = entries.findIndex((e) => e.id === id)
|
||||
if (index < 0) return entries
|
||||
const swapWith = direction === 'up' ? index - 1 : index + 1
|
||||
if (swapWith < 0 || swapWith >= entries.length) return entries
|
||||
const next = [...entries]
|
||||
const tmp = next[index]
|
||||
const other = next[swapWith]
|
||||
if (!tmp || !other) return entries
|
||||
next[index] = other
|
||||
next[swapWith] = tmp
|
||||
return next
|
||||
}
|
||||
|
||||
export function persistScratchlist(sessionId: string, entries: ScratchlistEntry[]): void {
|
||||
writeScratchlist(sessionId, entries)
|
||||
}
|
||||
|
||||
export function clearScratchlist(sessionId: string): void {
|
||||
if (!sessionId) return
|
||||
const storage = getLocalStorage()
|
||||
if (!storage) return
|
||||
try {
|
||||
storage.removeItem(getStorageKey(sessionId))
|
||||
} catch {
|
||||
// Non-fatal.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm-on-delete threshold. Trivial entries delete instantly; longer
|
||||
* notes deserve a confirmation prompt so a stray click doesn't lose work.
|
||||
* Threshold tuned to "anything longer than a one-line reminder".
|
||||
*/
|
||||
export const SCRATCHLIST_CONFIRM_DELETE_THRESHOLD = 100
|
||||
|
||||
export function shouldConfirmDelete(entry: ScratchlistEntry | null | undefined): boolean {
|
||||
if (!entry) return false
|
||||
return entry.text.length > SCRATCHLIST_CONFIRM_DELETE_THRESHOLD
|
||||
}
|
||||
Reference in New Issue
Block a user