diff --git a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs index 8984adf27e..6f7636292a 100644 --- a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs @@ -244,7 +244,9 @@ try { await fixture('report-partial'); await english.getByRole('button', {name: 'Refresh delegations', exact: true}).click(); await english.getByRole('searchbox', {name: 'Search delegations'}).fill('核对账单导出的字段映射'); - await english.getByRole('searchbox', {name: 'Search delegations'}).press('Enter'); + // Wait for the actual submit control: a refresh can start after the click + // settles, so Enter while its submit button is disabled would be ignored. + await english.getByRole('button', {name: 'Search', exact: true}).click(); await rows(1); await english.getByText('Partial result returned', {exact: true}).waitFor(); await english.locator('.conversation-request-row > summary').click(); @@ -254,6 +256,53 @@ try { await english.getByRole('button', {name: 'Refresh delegations', exact: true}).click(); await rows(0); await english.getByRole('button', {name: 'All records', exact: true}).click(); await rows(1); await english.getByText('Returned', {exact: true}).waitFor(); + // A slow filter read must not erase the last readable view or relabel its + // rows/counts. Hold a real backend response, then challenge cancellation. + await english.getByRole('button', {name: 'Clear', exact: true}).click(); await settled(); await rows(12); + await page.setViewportSize({width: 390, height: 844}); + const allRecords = english.getByRole('button', {name: 'All records', exact: true}); + const followUp = english.getByRole('button', {name: 'Awaiting follow-up', exact: true}); + const shownBeforeSwitch = await english.locator('.conversation-request-row > summary > strong').allTextContents(); + const countBeforeSwitch = await english.locator('.conversation-requests-pagination [role=status]').innerText(); + async function holdFollowUp() { + let seen, release, finished; + const observed = new Promise(resolve => seen = resolve), held = new Promise(resolve => release = resolve); + const completed = new Promise(resolve => finished = resolve); + let heldOnce = false; + await page.route('**/api/chat/request-inventory?*', async route => { + if (new URL(route.request().url()).searchParams.get('view') !== 'unfinished' || heldOnce) return route.continue(); + heldOnce = true; + const response = await route.fetch(); seen(); await held; + try {await route.fulfill({response});} catch { /* cancelled read */ } finally {finished();} + }); + return {observed, release, completed}; + } + const delayedSwitch = await holdFollowUp(); + await followUp.click(); await delayedSwitch.observed; + assert.deepEqual(await english.locator('.conversation-request-row > summary > strong').allTextContents(), shownBeforeSwitch); + assert.equal(await english.locator('.conversation-requests-pagination [role=status]').innerText(), countBeforeSwitch); + assert.equal(await allRecords.getAttribute('aria-pressed'), 'true', 'Selected tab describes the published rows'); + assert.equal(await followUp.getAttribute('aria-busy'), 'true', 'The requested view exposes its unfinished read'); + assert.ok(await english.evaluate(node => node.scrollWidth <= node.clientWidth + 1)); + await page.screenshot({path: resolve(output, 'view-switch-narrow.png')}); + await allRecords.click(); await settled(); + delayedSwitch.release(); await delayedSwitch.completed; + await page.unroute('**/api/chat/request-inventory?*'); + await rows(12); assert.equal(await allRecords.getAttribute('aria-pressed'), 'true'); + await followUp.click(); await rows(11); + assert.equal(await followUp.getAttribute('aria-pressed'), 'true'); + await fixture('fault'); await allRecords.click(); await settled(); await rows(0); + await english.getByRole('alert').waitFor(); + await english.getByText('Record count unknown', {exact: true}).waitFor(); + await fixture('recover'); await english.getByRole('button', {name: 'Retry', exact: true}).click(); await rows(12); + const interruptedSwitch = await holdFollowUp(); + await followUp.click(); await interruptedSwitch.observed; + await page.keyboard.press('Escape'); + interruptedSwitch.release(); await interruptedSwitch.completed; + await page.unroute('**/api/chat/request-inventory?*'); + await page.getByRole('button', {name: /Delegations,.*awaiting follow-up/}).click(); + assert.equal(await allRecords.getAttribute('aria-pressed'), 'false', 'Reopening cannot briefly show a historical view as the default'); + await rows(11); assert.equal(writes.some(path => /request-(?:inventory|control)|team-work/.test(path)), false); assert.deepEqual(errors, []); console.log('PASS: packaged search, pre-pagination match, count, complete original across real pages, long body, corrupt journal and later-page failure/recovery, close/reopen, clear/paging, changed-snapshot More, partial/final returned history, stale response, Escape/focus, workspace isolation, narrow layout, English and source refusal/recovery; explicit fixture reports update return state, original-reader POST delta zero and no new Turns.'); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.css b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.css index 9d6d16f6bc..9650d74bb3 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.css @@ -15,6 +15,7 @@ .conversation-requests-dialog button[aria-disabled=true] {cursor:default;opacity:.55;} .conversation-requests-dialog > header > button {display:grid;place-items:center;border:0;} .conversation-requests-dialog > nav {display:flex;gap:8px;padding:0 24px 16px;border-bottom:1px solid var(--pw-line);} +.conversation-requests-dialog > nav > button {display:inline-flex;align-items:center;gap:6px;} .conversation-requests-dialog > nav > button[aria-pressed=true] {background:var(--pw-text);color:var(--pw-bg);} .conversation-requests-dialog > nav > button:last-child {margin-left:auto;} .conversation-requests-changed {align-self:center;color:var(--pw-muted);font-size:var(--text-caption);} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx index afc888bbb6..7bc360d8d1 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx @@ -251,6 +251,13 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se const moreButton = useRef(null); const pageCountStatus = useRef(null); const restorePagingFocus = useRef(false); + const scope = JSON.stringify([sessionId, ownerFallback, history, search]); + const otherViewScope = JSON.stringify([sessionId, ownerFallback, !history, search]); + // Keep a verified view for the unchanged caller while its other filter loads. + // A fallback resolving to another source clears it before reading that source. + const keepPreviousView = loadedSource.current !== null + && (sessionId ? loadedSource.current === sessionId : ownerFallback) + && loadedScope.current === otherViewScope; useEffect(() => { setOpen(false); setHistory(false); setPages([]); setError(false); setSearch(""); setSearchDraft(""); loadedScope.current = null; loadedSource.current = null; @@ -275,7 +282,6 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se requestAbort.current?.abort(); const abort = new AbortController(); requestAbort.current = abort; setBusy(true); setError(false); setNoSession(false); setConversationChanged(false); - const scope = JSON.stringify([sessionId, ownerFallback, history, search]); const sameScope = loadedScope.current === scope; const loadedRows = pages.reduce((count, page) => count + page.rows.length, 0); // A fresh snapshot or parent refresh must retain the page requested by @@ -283,10 +289,9 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se requestedRows.current = sameScope ? Math.max(requestedRows.current, offset ? offset + 12 : Math.ceil(loadedRows / 12) * 12) : 12; const refreshRows = requestedRows.current; - // Keep verified rows during a same-source refresh, but never across scope - // changes or a failed read. Publish refreshed pages together below. - if (!sameScope) setPages([]); - loadedScope.current = scope; + // Keep the published view readable during this source's filter change. + // Searches, source changes and failed reads still discard its contents. + if (!sameScope && !keepPreviousView) setPages([]); try { let source = sessionId && sessionId !== "new-session-pending" ? sessionId : undefined; if (!source && ownerFallback) { @@ -318,6 +323,7 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se if (value.next_offset !== null && value.next_offset <= cursor) throw new Error("Request paging did not advance."); refreshed.push(value); cursor = value.next_offset; } + loadedScope.current = scope; setPages(previous => offset ? [...previous, ...refreshed] : refreshed); if (!offset) setOriginalRevision(value => value + 1); } catch { @@ -326,7 +332,10 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se requestAbort.current = null; setBusy(false); }} } - const page = pages[0], last = pages.at(-1); + // Hide another source/search immediately, before effect cleanup can run. + const visiblePages = loadedScope.current === scope || keepPreviousView ? pages : []; + const page = visiblePages[0], last = visiblePages.at(-1); + const shownHistory = page ? loadedScope.current === JSON.stringify([sessionId, ownerFallback, true, search]) : history; useEffect(() => { if (!busy && restorePagingFocus.current) { restorePagingFocus.current = false; @@ -336,10 +345,15 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se } }, [busy]); const unknown = page && (!page.coverage.scan_complete || page.coverage.unreadable > 0); - const rows = [...new Map(pages.flatMap(value => value.rows).map(row => [row.request_id, row])).values()]; + const rows = [...new Map(visiblePages.flatMap(value => value.rows).map(row => [row.request_id, row])).values()]; const recipients = [...new Set(rows.map(row => `${row.goal_id}\n${row.agent_id}`))]; const count = error || !page ? "—" : unknown ? (page.pending_count ? `${page.pending_count}+` : "—") : page.pending_count; const title = zh ? "委派" : "Delegations"; + const changeHistory = (value: boolean) => { + if (value === history) return; + generation.current++; requestAbort.current?.abort(); + setBusy(true); setError(false); setHistory(value); + }; const applySearch = (value: string) => { // Fence immediately, including the interval before the effect starts. generation.current++; requestAbort.current?.abort(); @@ -349,7 +363,11 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se }; const close = () => {generation.current++; requestAbort.current?.abort(); requestedRows.current = 12; setBusy(false); setOpen(false); trigger.current?.focus();}; return
- @@ -360,8 +378,10 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se

{zh ? "当前管家发出的请求,含同一来源的较早会话。数量是仍需回传、送达或核对的请求,不是任务数或成果验收。时间为最近可核验的记录。结论与取消记录仍可在历史中查看。" : "Requests from this steward, including earlier sessions from the same source. Counts describe returns, delivery or readback still owed, not task counts or accepted outcomes. Times identify the latest verifiable record. Concluded and cancelled requests remain in history."}

@@ -380,7 +400,7 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se {!busy && noSession ?

{zh ? "尚无可读取的管家会话。" : "No readable steward conversation yet."}

: null} {!busy && !noSession && !error && page && !unknown && !rows.length ?

{search ? (zh ? "未找到匹配的委派。" : "No matching delegations.") - : history ? (zh ? "暂无委派记录。" : "No delegation records.") + : shownHistory ? (zh ? "暂无委派记录。" : "No delegation records.") : (zh ? "当前没有待跟进的委派。" : "No delegations awaiting follow-up.")}

: null} {recipients.map(recipient => {const [goal, agent] = recipient.split("\n"); return

{agent}{goal}