Show persistent timestamps for operation and teacher messages

This commit is contained in:
鲨鱼辣椒
2026-09-29 18:02:54 +08:00
parent 3a175101c9
commit ead3407890
24 changed files with 355 additions and 9 deletions

View File

@@ -0,0 +1,27 @@
import { createRoot } from 'react-dom/client';
import { CodingConversationTimeline } from '../../../src/pages/Chat/CodingConversationTimeline';
import { TeacherChatPanel } from '../../../src/pages/Chat/TeacherChatPanel';
import { codingConversationStore } from '../../../src/stores/coding-conversations';
import { createProductSnapshot } from '../../fixtures/coding-conversation-product-fixtures';
import type { TeacherDefinition, TeacherTopic } from '../../../shared/coding-teacher';
import '../../../src/styles/globals.css';
import '../../../src/pages/Chat/classroom-workspace.css';
const sent = '2026-09-28T08:01:02.000Z', finished = '2026-09-28T08:01:42.000Z';
const snapshot = createProductSnapshot('timestamp-fixture', 1);
snapshot.run = { status: 'idle' };
snapshot.nodes = [
{ kind: 'message', id: 'u', role: 'user', status: 'complete', timestamp: Date.parse(sent), blocks: [{ kind: 'text', id: 'ut', status: 'complete', text: '我想做一个会长大的小宠物。' }] },
{ kind: 'message', id: 'a', role: 'assistant', status: 'complete', timestamp: Date.parse(finished), blocks: [{ kind: 'text', id: 'at', status: 'complete', text: '小宠物的家搭好啦。你可以试着点一下喂食按钮,看看它的变化。' }] },
];
codingConversationStore.getState().primeConversation(snapshot);
const definition: TeacherDefinition = { schema_version: 1, config_id: 'teacher-fixture', teacher_id: 'teacher-fixture', name: '设计老师', description: '', avatar_id: 'avatar-06', welcome_message: '', suggested_questions: [], system_prompt: '', skills: [], model: { model_id: 'fixture', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1000 } };
const topic: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'teacher-fixture', accountId: 'fixture', projectId: 'fixture', sourceConversationId: 'timestamp-fixture', definition, version: 1, conversation: { agentId: 'teacher-fixture', segmentTurns: 1, discussions: {} }, createdAt: sent, updatedAt: finished, requests: [{ id: 'r', createdAt: sent, finishedAt: finished, text: '我想让它长大以后变得很特别。', response: '先想一个你最希望它学会的小本领吧。你希望它会帮你做什么?', suggestedQuestions: ['我想让它陪我讲故事。', '我想让它记住我的习惯。'], references: [], sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: sent, includedSourceMessageIds: [], omittedMessages: 0, status: 'completed' }] };
(window as typeof window & { __timestampTopic: TeacherTopic }).__timestampTopic = topic;
const agent = { teacher_id: definition.config_id!, version: 1, is_default: true, definition };
createRoot(document.getElementById('root')!).render(
<div className="classroom-workspace min-h-screen bg-white" style={{ display: 'grid', gridTemplateColumns: innerWidth >= 768 ? 'minmax(0, 1fr) 420px' : 'minmax(0, 1fr)' }}>
<main className="min-w-0 p-6" aria-label="操作对话"><h1 className="mb-8 text-lg">操作对话</h1><CodingConversationTimeline conversationId="timestamp-fixture" /></main>
<div className="consultation-pane h-[850px] border-l"><TeacherChatPanel projectId="fixture" sourceId="timestamp-fixture" selectedAgent={agent} catalogState={{ config: { enabled: true, definition }, catalog: { items: [agent] } }} /></div>
</div>,
);

View File

@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';
import { createServer, type ViteDevServer } from 'vite';
import { resolve } from 'node:path';
let server: ViteDevServer;
let baseUrl: string;
test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
test.beforeAll(async () => {
server = await createServer({
configFile: false, cacheDir: 'node_modules/.vite-message-timestamps',
resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' },
server: { host: '127.0.0.1', port: 0 },
plugins: [{
name: 'isolated-message-timestamps', enforce: 'pre',
load(id) {
if (id.endsWith('/src/lib/coding-teacher.ts')) return `
export function teacherTopicsPath() { return '/fixture'; }
export function legacyTopicBase(base) { return base; }
export const teacherApi = {
conversation: async () => ({ topic: window.__timestampTopic, before: null }),
seenConversation: async () => window.__timestampTopic,
events: async () => ({ close() {}, addEventListener() {} }),
};
`;
if (id.endsWith('/src/lib/host-events.ts')) return 'export function subscribeHostEvent() { return () => {}; }';
if (id.endsWith('/src/lib/host-api.ts')) return `
export async function createHostEventSource() { return { close() {}, addEventListener() {} }; }
export async function ensureHostApiToken() { return 'fixture'; }
export function getHostApiBase() { return ''; }
export function reportDesktopActivity() {}
export function setDesktopBackgroundLease() {}
export async function hostApiFetch() { return {}; }
export async function hostApiFetchBytes() { return new Uint8Array(); }
`;
},
configureServer(vite) {
vite.middlewares.use((req, res, next) => {
if (req.url !== '/' && !req.url?.startsWith('/?')) return next();
res.setHeader('Content-Type', 'text/html');
res.end('<html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/tests/e2e/fixtures/message-timestamps.tsx"></script></body></html>');
});
},
}],
});
await server.listen();
baseUrl = server.resolvedUrls!.local[0];
});
test.afterAll(async () => { await server?.close(); });
test.beforeEach(async ({ page }) => {
page.on('pageerror', error => { throw error; });
await page.addInitScript(() => {
const scope = window as typeof window & { __communityCalls: unknown[][] };
scope.__communityCalls = [];
Object.defineProperty(window, 'electron', { value: { ipcRenderer: {
invoke: async (...args: unknown[]) => { scope.__communityCalls.push(args); return undefined; },
} } });
});
});
for (const width of [375, 1280]) {
test(`both chat surfaces show stable local times at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto(baseUrl);
await expect(page.locator('time')).toHaveCount(4);
const times = await page.locator('time').evaluateAll(elements => elements.map(element => ({ time: element.getAttribute('datetime'), title: element.getAttribute('title') })));
expect(times.map(item => item.time)).toEqual(['2026-09-28T08:01:02.000Z', '2026-09-28T08:01:42.000Z', '2026-09-28T08:01:02.000Z', '2026-09-28T08:01:42.000Z']);
for (const item of times) expect(item.title).toMatch(/^2026年9月28日 \d{2}:\d{2}:(02|42)$/);
for (const time of await page.locator('time').all()) {
await expect(time).toHaveCSS('font-size', '11px');
expect(await time.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
}
await expect(page.getByRole('button', { name: '我想让它陪我讲故事。' })).toBeEnabled();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await page.screenshot({ path: test.info().outputPath(`message-timestamps-${width}.png`), fullPage: true });
await page.reload();
await expect(page.locator('time')).toHaveCount(4);
expect(await page.locator('time').evaluateAll(elements => elements.map(element => ({ time: element.getAttribute('datetime'), title: element.getAttribute('title') })))).toEqual(times);
});
}

View File

@@ -226,6 +226,7 @@ async function installCodingFirstChatHost(
kind: 'message',
id: 'message-user-e2e',
sourceEntryId: 'entry-user-e2e',
timestamp: Date.parse(now),
role: 'user',
status: 'complete',
blocks: [{ kind: 'text', id: 'message-user-e2e:content:0', text: 'Durable user fork source', status: 'complete' }],
@@ -582,7 +583,7 @@ async function installCodingFirstChatHost(
if (action === 'messages' && method === 'POST') {
const suggestions = body!.intent === 'suggestions';
const response = suggestions ? '我们可以从你最近试过的地方聊起。' : body!.intent === 'guided-help' ? '你最近做的哪一步,让你停下来想了一会儿?' : '先理解状态如何随点击变化,再修改代码。';
consultationTopics[agentId] = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,projectId:body!.projectId,sourceConversationId:body!.sourceConversationId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:new Date().toISOString(),sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response,...(suggestions?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
consultationTopics[agentId] = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,projectId:body!.projectId,sourceConversationId:body!.sourceConversationId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:now,finishedAt:new Date(Date.parse(now)+40000).toISOString(),sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response,...(suggestions?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
const last = (consultationTopics[agentId].requests as Record<string, unknown>[]).at(-1)!;
Object.assign(last, consultationReply);
if (state.nextTeacherReplyIncomplete) {
@@ -1914,6 +1915,11 @@ test('project consultations preserve student drafts and switch between work and
await teacher.getByRole('textbox',{name:'向智能体提问'}).fill('');
await teacher.locator('form .consultation-hint').click();
await expect(teacher.getByText('我们可以从你最近试过的地方聊起。')).toBeVisible();
const sentTime = teacher.locator('time').nth(0), replyTime = teacher.locator('time').nth(1);
await expect(sentTime).toHaveAttribute('aria-label', /^发送时间:/);
await expect(replyTime).toHaveAttribute('aria-label', /^回复完成时间:/);
const originalReplyTime = await replyTime.getAttribute('datetime');
expect(Date.parse(originalReplyTime!) - Date.parse((await sentTime.getAttribute('datetime'))!)).toBe(40000);
await expect(teacher.getByTestId('teacher-discussion')).toHaveCount(0);
await teacher.getByRole('textbox',{name:'向智能体提问'}).fill('还没说完的困惑');
await expect(teacher.locator('form .consultation-hint')).toHaveCount(0);
@@ -1965,6 +1971,7 @@ test('project consultations preserve student drafts and switch between work and
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();await expect(teacher).toHaveCount(0);
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
await expect(page.getByText('先理解状态如何随点击变化,再修改代码。',{exact:true})).toBeVisible();
await expect(teacher.locator('time').nth(1)).toHaveAttribute('datetime', originalReplyTime!);
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
} finally {await releaseSnapshot(electronApp);}
});