Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Expand All @@ -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();
Expand All @@ -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();}
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,27 +136,32 @@ function OriginalImage({attachment, zh}: {attachment: ChatImageAttachment; zh: b
: <img src={attachment.data_url} alt={attachment.name} onError={() => 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<ChatVisibleMessage[] | null>(null);
const [error, setError] = useState(false);
const [open, setOpen] = useState(false);
const content = useRef<HTMLDivElement>(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 <details onToggle={event => setOpen(event.currentTarget.open)}><summary>{zh ? "原对话" : "Original conversation"}</summary>
}, [open, active, row.source_session_id, row.source_turn_id, revision]);
return <details onToggle={event => {focusOnRead.current = event.currentTarget.open; setOpen(event.currentTarget.open);}}><summary>{zh ? "原对话" : "Original conversation"}</summary>
<div ref={content} tabIndex={-1}>{error ? <p role="alert">{zh ? "原对话暂时无法读取,请重新打开重试。" : "Original conversation unavailable. Reopen to retry."}</p>
: messages ? messages.map(message => <article key={message.message_id}>
<strong>{message.role === "user" ? (zh ? "你" : "You") : "LoopX"}</strong><MarkdownText text={message.text}/>
Expand All @@ -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<string | null>(null);
const loadedSource = useRef<string | null>(null);
const generation = useRef(0);
const dialog = useRef<HTMLDialogElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
Expand All @@ -199,23 +207,39 @@ 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) {
const listed = await fetchChatSessions({channelId: "manager", signal: abort.signal});
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);}
Expand Down Expand Up @@ -286,7 +310,7 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se
{row.evidence_refs.map(value => <p key={value}><code>{value}</code></p>)}
{row.warnings.length ? <p role="status">{zh ? "部分关联记录需要恢复。" : "Some linked records need recovery."}</p> : null}
</details>
<OriginalRequest row={row} zh={zh}/>
<OriginalRequest key={`${row.source_session_id}:${row.source_turn_id}`} row={row} zh={zh} revision={originalRevision} active={open}/>
</div>
</details>)}
</section>;})}
Expand Down
31 changes: 31 additions & 0 deletions docs/assets/personal-workspace/delegation-media/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading