diff --git a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs index 972a4d76db..3628865771 100644 --- a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs @@ -86,6 +86,22 @@ try { await page.screenshot({path: resolve(output, 'read-failure.png')}); await fixture('recover'); await dialog.getByRole('button', {name: '重试', exact: true}).click(); await settled(); await rows(4); + await fixture('local-source-unavailable'); + const denied = await fetch(`${ready.origin}/api/chat/request-inventory?session_id=${ready.session_id}`); + assert.equal(denied.status, 400); + assert.equal((await denied.json()).error_code, 'request_inventory_unavailable'); + await dialog.getByRole('button', {name: '刷新委派', exact: true}).click(); await settled(); + await dialog.getByRole('alert').waitFor(); await rows(0); + assert.equal(await trigger.getAttribute('aria-label'), '委派,— 条待跟进'); + assert.equal(await dialog.locator('.conversation-requests-pagination').count(), 0); + // Changing the filter must not turn the same missing source into a new + // empty workspace, nor may an unsuccessful retry lose that distinction. + await search('builder'); await settled(); await dialog.getByRole('alert').waitFor(); + await dialog.getByRole('button', {name: '重试', exact: true}).click(); await settled(); + await dialog.getByRole('alert').waitFor(); await rows(0); + await fixture('local-source-recover'); + await dialog.getByRole('button', {name: '重试', exact: true}).click(); await settled(); await rows(9); + await search('reviewer'); await rows(4); assert.equal((await fixture('inspect')).source_unchanged, true); await fixture('deliver'); await search('复核迁移说明中的示例'); await settled(); await dialog.getByText('未找到匹配的委派。', {exact: true}).waitFor(); @@ -126,9 +142,20 @@ try { await fixture('source-recover'); await page.getByRole('button', {name: 'Retry history', exact: true}).click(); await page.getByRole('button', {name: /Delegations,.*awaiting follow-up/}).click(); await page.getByRole('searchbox', {name: 'Search delegations'}).waitFor(); + const english = page.getByRole('dialog', {name: 'Delegations', exact: true}); + await english.getByRole('button', {name: 'All records', exact: true}).click(); await rows(12); + // A real return changes ordering and the snapshot while More is reading. + // Rebuild from the beginning, but fulfill this click's requested extent; + // the parent message refresh must not replace it with the old page count. + await fixture('report-window'); + const nextPage = english.getByRole('button', {name: 'More records', exact: true}); + await nextPage.focus(); await page.keyboard.press('Enter'); await rows(13); + assert.equal(await english.getByRole('button', {name: 'More records', exact: true}).count(), 0); + assert.equal(await page.evaluate(() => document.activeElement?.textContent), '13 / 13 shown'); + await english.getByRole('button', {name: 'Awaiting follow-up', exact: true}).click(); 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, original conversation, clear/paging, failure/retry, returned history, stale response, Escape/focus, workspace isolation, narrow layout, English, source refusal/recovery and read-only receipts.'); + console.log('PASS: packaged search, pre-pagination match, count, original conversation, clear/paging, failure/retry, returned history, stale response, Escape/focus, workspace isolation, narrow layout, English, source refusal/recovery, changed-snapshot More and read-only receipts.'); } } catch (error) { if (page) {await page.screenshot({path: resolve(output, 'failure.png')}).catch(() => {}); await writeFile(resolve(output, 'failure.txt'), await page.locator('body').innerText());} diff --git a/apps/presentation/dashboard/src/data/conversation-requests.ts b/apps/presentation/dashboard/src/data/conversation-requests.ts index 2c58144b16..faac0c7dac 100644 --- a/apps/presentation/dashboard/src/data/conversation-requests.ts +++ b/apps/presentation/dashboard/src/data/conversation-requests.ts @@ -21,8 +21,9 @@ const inventorySchema = z.object({ok: z.literal(true), session_id: z.string(), r export type ConversationRequest = z.infer; export type ConversationRequestInventory = z.infer; -export async function fetchConversationRequests(sessionId: string, history: boolean, offset = 0, signal?: AbortSignal, search = "") { +export async function fetchConversationRequests(sessionId: string, history: boolean, offset = 0, signal?: AbortSignal, search = "", limit = 12) { const query = new URLSearchParams({session_id: sessionId, view: history ? "history" : "unfinished", offset: String(offset), search}); + if (limit !== 12) query.set("limit", String(limit)); const result = inventorySchema.parse(await requestJson(`/api/chat/request-inventory?${query}`, {signal})); if (result.session_id !== sessionId) throw new Error("Conversation request scope changed."); return result; 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 5e15d62ffa..c11a76bc9c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx @@ -207,6 +207,7 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se const [originalRevision, setOriginalRevision] = useState(0); const loadedScope = useRef(null); const loadedSource = useRef(null); + const requestedRows = useRef(12); const generation = useRef(0); const dialog = useRef(null); const trigger = useRef(null); @@ -216,7 +217,9 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se const restorePagingFocus = useRef(false); useEffect(() => { setOpen(false); setHistory(false); setPages([]); setError(false); setSearch(""); setSearchDraft(""); - }, [sessionId]); + loadedScope.current = null; loadedSource.current = null; + requestedRows.current = 12; + }, [sessionId, ownerFallback]); useEffect(() => { if (open) dialog.current?.showModal(); else dialog.current?.close(); }, [open]); @@ -231,10 +234,15 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se setBusy(true); setError(false); setNoSession(false); const scope = JSON.stringify([sessionId, ownerFallback, history, search]); const sameScope = loadedScope.current === scope; - const pageCount = sameScope ? Math.max(1, pages.length) : 1; + const loadedRows = pages.reduce((count, page) => count + page.rows.length, 0); + // A fresh snapshot or parent refresh must retain the page requested by + // More, including while that read has not published its rows yet. + 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([]); loadedSource.current = null;} + if (!sameScope) setPages([]); loadedScope.current = scope; try { let source = sessionId && sessionId !== "new-session-pending" ? sessionId : undefined; @@ -243,27 +251,34 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se source = listed.sessions.find(row => row.channel_id === "manager")?.session_id; } if (current !== generation.current) return; - if (!source) {setPages([]); setNoSession(true); return;} + if (!source) { + // A vanished source is a failed read, not a new empty workspace. Keep + // its lineage across filters so retry continues to report the loss. + if (loadedSource.current) throw new Error("Previous steward source is unavailable."); + setPages([]); setNoSession(true); return; + } const sameSource = loadedSource.current === source; - if (!sameSource) setPages([]); + if (!sameSource) {setPages([]); requestedRows.current = 12;} loadedSource.current = source; const refreshed: ConversationRequestInventory[] = []; let cursor: number | null = offset; - const limit = offset ? 1 : sameSource ? pageCount : 1; - for (let index = 0; index < limit && cursor !== null; index++) { - const value = await fetchConversationRequests(source, history, cursor, abort.signal, search); + const targetRows = offset ? 12 : sameSource ? refreshRows : 12; + // Each HTTP read verifies the whole inventory. Restore the loaded extent + // in bounded windows; opening and More still read one normal page. + for (let remaining = targetRows; remaining > 0 && cursor !== null; remaining -= 48) { + const value = await fetchConversationRequests(source, history, cursor, abort.signal, search, Math.min(48, remaining)); if (current !== generation.current) return; // Returns can reorder a live inventory. A stale More cursor gets one // fresh read; mixed snapshots during that read fail visibly for retry. if (offset && value.snapshot_digest !== pages[0]?.snapshot_digest) {void load(); return;} - if (index && value.snapshot_digest !== refreshed[0].snapshot_digest) throw new Error("Request snapshot changed."); + if (refreshed.length && value.snapshot_digest !== refreshed[0].snapshot_digest) throw new Error("Request snapshot changed."); if (value.next_offset !== null && value.next_offset <= cursor) throw new Error("Request paging did not advance."); refreshed.push(value); cursor = value.next_offset; } setPages(previous => offset ? [...previous, ...refreshed] : refreshed); if (!offset) setOriginalRevision(value => value + 1); } catch { - if (current === generation.current && !abort.signal.aborted) {setError(true); setPages([]);} + if (current === generation.current && !abort.signal.aborted) {requestedRows.current = 12; setError(true); setPages([]);} } finally {if (current === generation.current) setBusy(false);} } const page = pages[0], last = pages.at(-1); @@ -283,10 +298,11 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se const applySearch = (value: string) => { // Fence immediately, including the interval before the effect starts. generation.current++; requestAbort.current?.abort(); + requestedRows.current = 12; setPages([]); setError(false); setBusy(true); setSearch(value.trim()); setRevision(value => value + 1); }; - const close = () => {generation.current++; requestAbort.current?.abort(); setBusy(false); setOpen(false); trigger.current?.focus();}; + const close = () => {generation.current++; requestAbort.current?.abort(); requestedRows.current = 12; setBusy(false); setOpen(false); trigger.current?.focus();}; return