Merge Agent attachment panel and explicit delivered-file filtering

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 00:09:38 +08:00
commit be53e44dcf
14 files changed
+1010 -98

No files matched your search

+96 -17
View File
@@ -24,21 +24,31 @@ test.beforeAll(async () => {
load(id) {
if (id === entry) return `
import { createRoot } from 'react-dom/client';
import { useState } from 'react';
import { CloudChat } from '/src/pages/CloudAgents/CloudChat.tsx';
import { CloudAvatar } from '/src/pages/CloudAgents/CloudAvatar.tsx';
import avatar from '/src/assets/agent-avatars/avatar-05.png';
import '/src/styles/globals.css';
const examples = ['我想一起想一个有趣的故事。', '请帮我把今天的新发现整理一下。'];
createRoot(document.getElementById('root')).render(
<main className="flex h-screen min-h-0 min-w-0 flex-col">
const name = new URLSearchParams(location.search).has('longName') ? '我的故事搭档'.repeat(16) : '我的故事搭档';
function Harness() {
const [header, setHeader] = useState(null);
return <main className="flex h-screen min-h-0 min-w-0 flex-col">
<header className="agent-workspace-header agent-chat-workspace-header">
<CloudAvatar src={avatar} name={name} className="agent-chat-header-avatar" />
<h2 className="agent-chat-header-name" title={name}>{name}</h2>
<div ref={setHeader} className="agent-chat-header-files" />
</header>
<CloudChat slug="ml-chat-layout-fixture" assistantName="我的故事搭档" assistantAvatarUrl={avatar}
examples={examples} />
</main>
);
examples={examples} attachmentHeader={header} />
</main>;
}
createRoot(document.getElementById('root')).render(<Harness />);
`;
},
configureServer(vite) {
vite.middlewares.use((req, res, next) => {
if (req.url !== '/') return next();
if (req.url?.split('?')[0] !== '/') return next();
res.setHeader('Content-Type', 'text/html');
res.end('<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/__cloud-agent-chat-layout-harness.tsx"></script></body></html>');
});
@@ -57,6 +67,7 @@ const longWord = 'an-unbroken-story-notebook-'.repeat(24);
const richReply = [
'## 一起把小发现变成故事',
'你可以先选一个最喜欢的发现,再让角色去试一试。',
'[下载故事笔记](sandbox:/故事笔记.md)',
`这个很长的名字也应该完整保留:${longWord}`,
`[查看完整故事线索](https://example.invalid/story/${longWord})`,
['| 今天的线索 | 可以接着想什么 |', '| --- | --- |',
@@ -92,7 +103,7 @@ type FixtureSeed = { threads: CloudThread[]; histories: Record<string, CloudHist
type FixtureStream = EventTarget & { readyState: number; url: string };
type FixtureState = FixtureSeed & { calls: FixtureCall[]; attachments: CloudAttachment[]; streams: FixtureStream[] };
type FixtureWindow = Window & { __cloudChatFixture: FixtureState };
type FixtureKind = 'empty' | 'history' | 'running' | 'approval' | 'thinking';
type FixtureKind = 'empty' | 'history' | 'running' | 'approval' | 'thinking' | 'delivery';
async function installFixture(page: Page, kind: FixtureKind) {
const isRunning = kind === 'running' || kind === 'thinking';
@@ -103,7 +114,14 @@ async function installFixture(page: Page, kind: FixtureKind) {
const run = isRunning ? makeRun(first, 'running') : kind === 'approval' ? makeRun(first, 'interrupted', interrupt) : null;
const seed: FixtureSeed = kind === 'empty' ? { threads: [], histories: {} } : {
threads: [first, second], histories: {
[first.thread_id]: kind === 'thinking' ? { ...makeHistory(first, false, run), messages: [] } : makeHistory(first, kind !== 'approval', run),
[first.thread_id]: kind === 'thinking' ? { ...makeHistory(first, false, run), messages: [] }
: kind === 'delivery' ? { ...makeHistory(first), messages: [
{ id: 1, role: 'user', content: '[用户提到的文件](sandbox:/user.txt)', request_id: 'delivery-user', run_id: null, created_at: date },
{ id: 2, role: 'assistant', request_id: 'delivery-answer', run_id: null, created_at: date,
content: '[下载完成的小程序](sandbox:/deliverable.py)\n\n准备时用到了 /internal.py、/call.txt、/cache.json 和 /plain.txt。\n\n`[行内示例](sandbox:/inline.py)`\n\n```markdown\n[代码示例](sandbox:/example.py)\n```',
reasoning_content: '[思考过程](sandbox:/thought.txt)',
},
] } : makeHistory(first, kind !== 'approval', run),
[second.thread_id]: makeHistory(second),
},
};
@@ -140,6 +158,7 @@ async function installFixture(page: Page, kind: FixtureKind) {
if (path === '/api/cloud-agents/recovery') return result({ recent: null, pending: [] });
if (path === '/api/cloud-agents/recovery/recent') return result({ saved: true });
if (path === '/api/cloud-agents/attachments/pick') return result({ object_name: 'temporary/狐狸照片.png', file_name: '狐狸照片.png', file_type: 'png', parse_supported: true, parse_methods: ['disable', 'rapid_ocr'] });
if (path === '/api/cloud-agents/files/save') return result({ saved: true });
if (path !== '/api/cloud-agents/actions') throw new Error('Unexpected fixture path: ' + path);
const operation = String(input?.operation);
const args = input?.input as Record<string, unknown> | undefined;
@@ -155,7 +174,8 @@ async function installFixture(page: Page, kind: FixtureKind) {
if (!state.attachments.some(item => item.file_id === attachment.file_id)) state.attachments.push(attachment);
return result({ attachments: [attachment] });
}
case 'files': return result({ files: [{ name: '故事笔记.md', is_dir: false, path: '/故事笔记.md', size: 1024 }] });
case 'files': return result({ files: ['故事笔记.md', 'deliverable.py', 'internal.py', 'call.txt', 'cache.json', 'plain.txt', 'inline.py', 'example.py', 'user.txt', 'thought.txt']
.map(name => ({ name, is_dir: false, path: '/' + name, directory_path: '/', size: 1024 })) });
case 'run': return result(Object.values(state.histories).find(item => item.run?.agent_run_id === args?.run_id)?.run);
case 'cancelRun': return result({ status: 'cancel_requested' });
case 'resume': {
@@ -175,12 +195,12 @@ async function installFixture(page: Page, kind: FixtureKind) {
});
}
async function openChat(page: Page, kind: FixtureKind, width: number, height: number) {
async function openChat(page: Page, kind: FixtureKind, width: number, height: number, longName = false) {
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.setViewportSize({ width, height });
await installFixture(page, kind);
await page.goto(baseUrl);
await page.goto(baseUrl + (longName ? '?longName=1' : ''));
await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible();
await expect(page.getByLabel('消息', { exact: true })).toBeEnabled();
await expect(page.getByText('正在读取对话…', { exact: true })).toHaveCount(0);
@@ -229,6 +249,46 @@ async function emitRunEvent(page: Page, type: 'messages' | 'end', id: string, en
}, { type, id, envelope });
}
test('long Agent identity and attachment entry fit on a short narrow window', async ({ page }) => {
const errors = await openChat(page, 'history', 375, 540, true);
const entry = page.getByRole('button', { name: '附件,共 2 个文件', exact: true });
await expect(entry).toBeVisible();
const header = page.locator('.agent-chat-workspace-header');
expect((await header.boundingBox())!.height).toBeLessThanOrEqual(104);
await expect(header.locator('.agent-chat-header-name')).toHaveAttribute('title', '我的故事搭档'.repeat(16));
await expect(header.locator('.agent-attachment-entry')).toHaveCount(1);
await expectComposerBounds(page);
await entry.click();
await expect(page.getByRole('dialog', { name: '本次对话的附件' })).toBeVisible();
await page.keyboard.press('Escape');
await expect(entry).toBeFocused();
await expectComposerBounds(page);
await screenshot(page, 'agent-chat-long-name-attachment-header');
expect(errors).toEqual([]);
});
test('the file Sheet counts only uploads and files explicitly delivered in the assistant answer', async ({ page }) => {
const errors = await openChat(page, 'delivery', 375, 640);
const entry = page.getByRole('button', { name: '附件,共 2 个文件', exact: true });
await expect(entry).toBeVisible();
await entry.click();
const panel = page.getByRole('dialog', { name: '本次对话的附件' });
const generated = panel.getByRole('region', { name: '助手生成的' });
await expect(generated.getByRole('button', { name: /^保存 / })).toHaveCount(1);
await expect(generated.getByRole('button', { name: '保存 deliverable.py', exact: true })).toBeVisible();
for (const name of ['故事笔记.md', 'internal.py', 'call.txt', 'cache.json', 'plain.txt', 'inline.py', 'example.py', 'user.txt', 'thought.txt']) {
await expect(generated.getByText(name, { exact: true })).toHaveCount(0);
}
await generated.getByRole('button', { name: '保存 deliverable.py', exact: true }).click();
await expect.poll(() => page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.calls
.filter(call => call.path === '/api/cloud-agents/files/save').map(call => call.input))).toEqual([{ thread_id: 'story-first', path: '/deliverable.py' }]);
await page.keyboard.press('Escape');
await expect(entry).toBeFocused();
await expectComposerBounds(page);
expect((await operations(page)).some(item => ['submit', 'preview', 'resume', 'deleteAttachment'].includes(String(item?.operation)))).toBe(false);
expect(errors).toEqual([]);
});
for (const width of [375, 768, 1024, 1440]) {
test(`friendly empty conversation keeps the composer reachable at ${width}px`, async ({ page }) => {
const errors = await openChat(page, 'empty', width, width === 375 ? 640 : 760);
@@ -249,7 +309,7 @@ for (const width of [375, 768, 1024, 1440]) {
});
}
for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900 }]) {
for (const viewport of [{ width: 375, height: 540 }, { width: 768, height: 760 }, { width: 1024, height: 760 }, { width: 1440, height: 900 }]) {
test(`long Markdown history scrolls independently at ${viewport.width}×${viewport.height}`, async ({ page }) => {
const errors = await openChat(page, 'history', viewport.width, viewport.height);
const transcript = page.getByTestId('cloud-chat-messages');
@@ -282,17 +342,34 @@ for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900
await expect(page.getByLabel('历史对话', { exact: true })).toHaveCount(0);
await expect(transcript.getByRole('article')).toHaveCount(16);
await input.fill('');
await page.getByRole('button', { name: '对话文件', exact: true }).click();
await expect(page.getByRole('button', { name: '添加文件或图片', exact: true })).toBeVisible();
const entry = page.getByRole('button', { name: /^附件,共 2 个文件$/ });
await expect(composer.getByRole('button', { name: /附件,共/ })).toHaveCount(0);
await entry.click();
const panel = page.getByRole('dialog', { name: '本次对话的附件' });
await expect(panel).toBeVisible();
const bounds = await panel.boundingBox();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport.width + 1);
expect(Math.abs(bounds!.x + bounds!.width - viewport.width)).toBeLessThanOrEqual(1);
expect(await panel.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await expect(page.getByRole('button', { name: '保存 故事笔记.md', exact: true })).toBeVisible();
await page.getByRole('button', { name: '保存 故事笔记.md', exact: true }).click();
await expect.poll(() => page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.calls
.filter(call => call.path === '/api/cloud-agents/files/save').map(call => call.input))).toEqual([{ thread_id: 'story-first', path: '/故事笔记.md' }]);
const chosen = page.getByRole('checkbox', { name: '选择附件 我的故事笔记与新发现.txt', exact: true });
await chosen.check();
await screenshot(page, `agent-chat-attachment-panel-${viewport.width}`);
await page.getByRole('button', { name: /返回聊天/ }).click();
await expect(panel).toHaveCount(0);
await expect(composer.getByRole('button', { name: '移除附件 我的故事笔记与新发现.txt', exact: true })).toBeVisible();
await composer.getByRole('button', { name: '移除附件 我的故事笔记与新发现.txt', exact: true }).click();
await entry.click();
await expect(chosen).not.toBeChecked();
await expect(composer.locator('.agent-attachment-selected')).toHaveCount(0);
expect((await operations(page)).some(item => item?.operation === 'deleteAttachment')).toBe(false);
await page.getByRole('button', { name: '对话文件', exact: true }).click();
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(entry).toBeFocused();
await expectComposerBounds(page);
expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false);
expect(errors).toEqual([]);
@@ -306,6 +383,7 @@ for (const width of [375, 1440]) {
const send = composer.getByRole('button', { name: '发送', exact: true });
await expect(send).toBeDisabled();
await composer.getByRole('button', { name: '添加文件或图片', exact: true }).click();
await page.getByRole('menuitem', { name: '添加文件或图片', exact: true }).click();
const remove = composer.getByRole('button', { name: '移除附件 狐狸照片.png', exact: true });
await expect(remove).toBeVisible();
await expect(composer.locator('.agent-attachment-card[data-kind="image"]')).toHaveCount(1);
@@ -320,8 +398,9 @@ for (const width of [375, 1440]) {
await remove.click();
await expect(composer.locator('.agent-attachment-selected')).toHaveCount(0);
await expect(send).toBeDisabled();
await composer.getByRole('button', { name: '对话文件', exact: true }).click();
await expect(composer.getByRole('checkbox', { name: '选择附件 狐狸照片.png', exact: true })).not.toBeChecked();
await page.getByRole('button', { name: /^附件,共 3 个文件$/ }).click();
await expect(page.getByRole('checkbox', { name: '选择附件 狐狸照片.png', exact: true })).not.toBeChecked();
await page.getByRole('button', { name: '关闭附件面板' }).click();
expect((await operations(page)).some(item => ['deleteAttachment', 'submit', 'preview'].includes(String(item?.operation)))).toBe(false);
expect(errors).toEqual([]);
});