diff --git a/apps/presentation/dashboard/smoke/delegation-media-browser-smoke.mjs b/apps/presentation/dashboard/smoke/delegation-media-browser-smoke.mjs index 718764971c..0b76cac467 100644 --- a/apps/presentation/dashboard/smoke/delegation-media-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/delegation-media-browser-smoke.mjs @@ -114,6 +114,24 @@ try { assert.ok(await dialog.evaluate(node => node.scrollWidth <= node.clientWidth + 1)); assert.ok(await images.evaluateAll(nodes => nodes.every(node => Math.abs(node.clientWidth / node.clientHeight - 600 / 180) < .05)), 'Original images keep their full aspect ratio'); await page.screenshot({path: resolve(output, 'images-narrow.png')}); + // Refreshing the same source must keep the evidence the reader opened. + // This oracle also runs against an immutable pre-change packaged bundle. + const refreshed = page.waitForResponse(response => new URL(response.url()).pathname === '/api/chat/request-inventory'); + await dialog.getByRole('button', {name: '刷新委派', exact: true}).click(); + await refreshed; + await page.waitForFunction(() => document.querySelector('.conversation-requests-body')?.getAttribute('aria-busy') === 'false'); + assert.equal(await original.evaluate(node => node.parentElement.open), true, + 'A same-source refresh must keep the original conversation expanded'); + assert.equal(await articles.first().locator('.personal-md').innerText(), body); + assert.equal(await images.count(), 2); + const refreshOriginal = async nextMode => { + mode = nextMode; + const response = page.waitForResponse(value => new URL(value.url()).pathname === `/api/chat/sessions/${ready.session_id}`); + await dialog.getByRole('button', {name: '刷新委派', exact: true}).click(); await response; + }; + await refreshOriginal('unavailable'); await row.getByRole('alert').waitFor(); assert.equal(await images.count(), 0); + await refreshOriginal('wrong-session'); await row.getByRole('alert').waitFor(); assert.equal(await images.count(), 0); + await refreshOriginal('images'); await images.first().waitFor(); const reopen = async nextMode => { await original.click(); mode = nextMode; await original.click(); }; @@ -129,7 +147,9 @@ try { const seen = new Promise(resolve => observed = resolve); await original.click(); await seen; await original.click(); release(); assert.equal(await images.count(), 0, 'A late read cannot reopen a closed original'); - await page.keyboard.press('Escape'); + const modalSeen = new Promise(resolve => observed = resolve); + await original.click(); await modalSeen; await page.keyboard.press('Escape'); release(); + assert.equal(await images.count(), 0, 'Closing the dialog cancels an open original reader'); assert.equal(await trigger.evaluate(node => node === document.activeElement), true); await page.evaluate(() => localStorage.setItem('loopx-pw-locale', 'en')); mode = 'images'; await page.reload(); @@ -142,11 +162,80 @@ try { await english.getByRole('img', {name: '原始图一.png', exact: true}).waitFor(); const inspection = await inspect(); assert.equal(inspection.source_unchanged, true); assert.equal(inspection.posts, 0); + await page.setViewportSize({width: 1440, height: 1000}); + + // The loaded second page and its open reader survive refresh as a unit. + const settled = () => page.waitForFunction(() => document.querySelector('.conversation-requests-body')?.getAttribute('aria-busy') === 'false'); + const rows = count => page.waitForFunction(expected => document.querySelectorAll('.conversation-request-row').length === expected + && document.querySelector('.conversation-requests-body')?.getAttribute('aria-busy') === 'false', count); + const refresh = english.getByRole('button', {name: 'Refresh delegations', exact: true}); + await english.getByRole('button', {name: 'Clear', exact: true}).click(); await rows(12); + await english.getByRole('button', {name: 'All records', exact: true}).click(); await rows(12); + await english.getByRole('button', {name: 'More records', exact: true}).click(); await rows(13); + const secondPage = english.locator('.conversation-request-row').filter({hasText: '核对账单导出的字段映射'}); + await secondPage.locator(':scope > summary').click(); + await secondPage.getByText('Original conversation', {exact: true}).click(); + await secondPage.getByRole('img', {name: '原始图一.png', exact: true}).waitFor(); + const nativeRow = english.locator('.conversation-request-row').filter({hasText: '复核迁移说明中的示例'}); + await nativeRow.locator(':scope > summary').click(); + await nativeRow.getByText('Original conversation', {exact: true}).click(); + await nativeRow.locator('article').first().waitFor(); + let inventoryMode = 'hold', releasePage, sawPage; + await page.route('**/api/chat/request-inventory?*', async route => { + if (new URL(route.request().url()).searchParams.get('offset') !== '12') return route.continue(); + const response = await route.fetch(); + if (inventoryMode === 'hold') {sawPage(); await new Promise(resolve => releasePage = resolve);} + if (inventoryMode === 'drift') { + const value = await response.json(); value.snapshot_digest = '0'.repeat(64); + return route.fulfill({response, json: value}); + } + return route.fulfill({response}).catch(() => {}); + }); + const held = new Promise(resolve => sawPage = resolve); + const scroll = english.locator('.conversation-requests-body'); + const beforeScroll = await scroll.evaluate(node => {node.scrollTop = 150; return node.scrollTop;}); + await refresh.click(); await held; + const focusedInput = english.getByRole('searchbox', {name: 'Search delegations'}); + await focusedInput.focus(); + assert.equal(await english.locator('.conversation-request-row').count(), 13, 'Do not publish a half-refreshed inventory'); + assert.equal(await secondPage.getByText('Original conversation', {exact: true}).evaluate(node => node.parentElement.open), true); + releasePage(); await rows(13); + assert.equal(await focusedInput.evaluate(node => node === document.activeElement), true, 'Refreshing evidence must not steal focus'); + assert.ok(Math.abs(await scroll.evaluate(node => node.scrollTop) - beforeScroll) <= 1, 'Keep same-source reading position'); + inventoryMode = 'normal'; + // This return is appended by the real native Inbox drain, never by the + // browser response enrichment. The same open reader must show its body. + child.stdin.write('deliver\n'); assert.equal((await next()).event, 'deliver'); + const delivered = '已核对本次迁移说明与示例,版本一致,原始检查结果已保留。'; + await refresh.click(); await rows(13); + await nativeRow.getByText(delivered, {exact: true}).waitFor(); + assert.equal(await secondPage.getByText('Original conversation', {exact: true}).evaluate(node => node.parentElement.open), true); + await nativeRow.getByText(delivered, {exact: true}).scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(output, 'refresh-return-desktop.png')}); + await page.setViewportSize({width: 390, height: 844}); + await nativeRow.getByText(delivered, {exact: true}).scrollIntoViewIfNeeded(); + assert.ok(await english.evaluate(node => node.scrollWidth <= node.clientWidth + 1)); + await page.screenshot({path: resolve(output, 'refresh-return-narrow.png')}); + inventoryMode = 'drift'; await refresh.click(); await settled(); + await english.getByRole('alert').waitFor(); await rows(0); + inventoryMode = 'normal'; await english.getByRole('button', {name: 'Retry', exact: true}).click(); await rows(12); + // A source/query change while an original read is in flight cannot carry + // its private evidence into the new result, even if the response arrives. + const sourceRow = english.locator('.conversation-request-row').filter({hasText: '复核迁移说明中的示例'}); + await sourceRow.locator(':scope > summary').click(); mode = 'delayed'; + const late = new Promise(resolve => observed = resolve); + await sourceRow.getByText('Original conversation', {exact: true}).click(); await late; + await english.getByRole('searchbox', {name: 'Search delegations'}).fill('reviewer'); + await english.getByRole('button', {name: 'Search', exact: true}).click(); await rows(4); + release(); mode = 'images'; await page.unroute('**/api/chat/request-inventory?*'); + assert.equal(await english.locator('article').count(), 0); + assert.equal(await english.locator('img').count(), 0); + assert.equal((await inspect()).posts, 0); assert.deepEqual(writes, [], 'Reading originals must not POST or start a Turn'); assert.deepEqual(errors, []); - console.log('PASS: packaged original images/full body, same-Turn isolation, text-only, failed image/read, foreign-session denial, retry, late close, focus return, narrow aspect ratio, English and unchanged SQLite/Inbox; zero writes.'); + console.log('PASS: packaged original images/full body, same-Turn isolation, read failures/retry, late close/query change, focus/scroll, bilingual/narrow, atomic two-page refresh, native return in the open reader and snapshot-drift denial; browser writes zero.'); } } catch (error) { if (page) {await page.screenshot({path: resolve(output, 'failure.png')}).catch(() => {}); await writeFile(resolve(output, 'failure.txt'), await page.locator('body').innerText());} throw error; -} finally {await browser?.close(); child.stdin.write('stop\n'); child.stdin.end();} +} finally {await page?.unrouteAll({behavior: 'ignoreErrors'}); await browser?.close(); child.stdin.write('stop\n'); child.stdin.end();} diff --git a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs index 4b9bd4b2bb..f14d8577fa 100644 --- a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs @@ -34,7 +34,11 @@ try { await page.addInitScript(() => {if (!localStorage.getItem('loopx-pw-locale')) localStorage.setItem('loopx-pw-locale', 'zh-CN');}); const writes = []; page.on('request', request => {if (request.method() !== 'GET') writes.push(new URL(request.url()).pathname);}); const errors = []; page.on('pageerror', error => errors.push(error.message)); + // This disposable source has manager history, but no original Lark binding. + await page.route('**/api/chat/app-steward', route => route.fulfill({json: {ok: true, binding_id: null, session_id: null}})); await page.goto(ready.origin + '/chat/'); + await page.getByRole('navigation', {name: '管家视图'}).getByRole('button', {name: /^(Chat|对话)$/}).click(); + await page.getByText('复核迁移说明中的示例', {exact: true}).filter({visible: true}).first().waitFor(); const trigger = page.getByRole('button', {name: /委派,.*条待跟进/}); await trigger.click(); const dialog = page.getByRole('dialog', {name: '委派', exact: true}); 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 ee2b2bfc47..e5298454cf 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx @@ -136,27 +136,32 @@ function OriginalImage({attachment, zh}: {attachment: ChatImageAttachment; zh: b : {attachment.name} setFailed(true)}/>; } -function OriginalRequest({row, zh}: {row: ConversationRequest; zh: boolean}) { +function OriginalRequest({row, zh, revision, active}: {row: ConversationRequest; zh: boolean; revision: number; active: boolean}) { const [messages, setMessages] = useState(null); const [error, setError] = useState(false); const [open, setOpen] = useState(false); const content = useRef(null); + const focusOnRead = useRef(false); useEffect(() => { - if (open && messages) {content.current?.focus(); content.current?.scrollIntoView({block: "nearest"});} - }, [open, messages]); + if (open && (messages || error) && focusOnRead.current) { + focusOnRead.current = false; + content.current?.focus(); content.current?.scrollIntoView({block: "nearest"}); + } + }, [open, messages, error]); useEffect(() => { - setMessages(null); setError(false); - if (!open) return; + setError(false); + if (!open || !active) {setMessages(null); return;} const abort = new AbortController(); void fetchChatSession(row.source_session_id, abort.signal).then(value => { if (abort.signal.aborted) return; if (value.session.session_id !== row.source_session_id) throw new Error("Original conversation scope changed."); const source = value.messages.filter(message => message.turn_id === row.source_turn_id); - if (!source.length) setError(true); else setMessages(source); - }).catch(() => {if (!abort.signal.aborted) setError(true);}); + if (!source.length) throw new Error("Original turn unavailable."); + setMessages(source); + }).catch(() => {if (!abort.signal.aborted) {setMessages(null); setError(true);}}); return () => abort.abort(); - }, [open, row.source_session_id, row.source_turn_id]); - return
setOpen(event.currentTarget.open)}>{zh ? "原对话" : "Original conversation"} + }, [open, active, row.source_session_id, row.source_turn_id, revision]); + return
{focusOnRead.current = event.currentTarget.open; setOpen(event.currentTarget.open);}}>{zh ? "原对话" : "Original conversation"}
{error ?

{zh ? "原对话暂时无法读取,请重新打开重试。" : "Original conversation unavailable. Reopen to retry."}

: messages ? messages.map(message =>
{message.role === "user" ? (zh ? "你" : "You") : "LoopX"} @@ -180,6 +185,9 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se const [error, setError] = useState(false); const [noSession, setNoSession] = useState(false); const [revision, setRevision] = useState(0); + const [originalRevision, setOriginalRevision] = useState(0); + const loadedScope = useRef(null); + const loadedSource = useRef(null); const generation = useRef(0); const dialog = useRef(null); const trigger = useRef(null); @@ -199,8 +207,13 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se requestAbort.current?.abort(); const abort = new AbortController(); requestAbort.current = abort; setBusy(true); setError(false); setNoSession(false); - // Never retain a previous source/page behind an error or changed scope. - if (!offset) setPages([]); + const scope = JSON.stringify([sessionId, ownerFallback, history, search]); + const sameScope = loadedScope.current === scope; + const pageCount = sameScope ? Math.max(1, pages.length) : 1; + // 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;} + loadedScope.current = scope; try { let source = sessionId && sessionId !== "new-session-pending" ? sessionId : undefined; if (!source && ownerFallback) { @@ -208,14 +221,25 @@ 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) {setNoSession(true); return;} - const value = await fetchConversationRequests(source, history, offset, abort.signal, search); - if (current !== generation.current) return; - // Returns and revised briefs can reorder a live list between pages. - // Restart the read rather than silently skipping work at an old offset. - if (offset && value.snapshot_digest !== pages[0]?.snapshot_digest) {void load(); return;} - if (offset && value.next_offset !== null && value.next_offset <= offset) throw new Error("Request paging did not advance."); - setPages(previous => offset ? [...previous, value] : [value]); + if (!source) {setPages([]); setNoSession(true); return;} + const sameSource = loadedSource.current === source; + if (!sameSource) setPages([]); + 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); + 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 (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([]);} } finally {if (current === generation.current) setBusy(false);} @@ -286,7 +310,7 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se {row.evidence_refs.map(value =>

{value}

)} {row.warnings.length ?

{zh ? "部分关联记录需要恢复。" : "Some linked records need recovery."}

: null}
- +
)} ;})} diff --git a/docs/assets/personal-workspace/delegation-media/README.md b/docs/assets/personal-workspace/delegation-media/README.md new file mode 100644 index 0000000000..310903d96b --- /dev/null +++ b/docs/assets/personal-workspace/delegation-media/README.md @@ -0,0 +1,31 @@ +# Original conversation reading / 原对话阅读 + +These are public synthetic screenshots of the packaged Chat reader backed by the +production SQLite, Inbox and HTTP owners. The refresh examples show the same +open original after a native Inbox return, at desktop and narrow widths. They +do not certify external delivery or model execution. + +这些截图来自公开合成数据与真实 SQLite、Inbox、HTTP 读取链路。刷新示例展示 +原生 Inbox 返回后仍展开的同一原对话,分别核验桌面和窄屏;不代表外部投递或 +模型执行验收。 + +- `refresh-before.png`: the previous reader collapses the original on refresh. +- `refresh-return-desktop.png`, `refresh-return-narrow.png`: the open reader + retains the native return, full body and loaded pages after refresh. +- The other images cover the original full-body/image reading and failure UI. + +Run the targeted author/PR check after building the Chat bundle: + +```sh +npm --prefix apps/presentation/dashboard run build:chat +LOOPX_PLAYWRIGHT_PACKAGE="$PWD/apps/presentation/dashboard/node_modules/playwright" \ + node apps/presentation/dashboard/smoke/delegation-media-browser-smoke.mjs +``` + +The smoke owns image reading, same-source refresh and reader cancellation. It +extends the existing fixture rather than adding a second consumer or test server. +It checks atomic multi-page refresh, focus and scroll, native return visibility, +foreign-session/read failures, snapshot drift, retry and late close/query changes. +Browser requests remain GET-only; only the disposable fixture explicitly drains +its synthetic queued return. `LOOPX_MEDIA_ASSETS` selects a prebuilt bundle for the +same semantic oracle against an earlier version. diff --git a/docs/assets/personal-workspace/delegation-media/refresh-before.png b/docs/assets/personal-workspace/delegation-media/refresh-before.png new file mode 100644 index 0000000000..6b55694b00 Binary files /dev/null and b/docs/assets/personal-workspace/delegation-media/refresh-before.png differ diff --git a/docs/assets/personal-workspace/delegation-media/refresh-return-desktop.png b/docs/assets/personal-workspace/delegation-media/refresh-return-desktop.png new file mode 100644 index 0000000000..ff7ebeb2af Binary files /dev/null and b/docs/assets/personal-workspace/delegation-media/refresh-return-desktop.png differ diff --git a/docs/assets/personal-workspace/delegation-media/refresh-return-narrow.png b/docs/assets/personal-workspace/delegation-media/refresh-return-narrow.png new file mode 100644 index 0000000000..a6ef464125 Binary files /dev/null and b/docs/assets/personal-workspace/delegation-media/refresh-return-narrow.png differ