From ead34078906336492c509ebb616c9cbb1ab9870a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 29 Sep 2026 18:02:54 +0800 Subject: [PATCH] Show persistent timestamps for operation and teacher messages --- .../20260929-community-welcome-c724e5a1.md | 13 ++- README.md | 1 + electron/coding-runtime/pi/event-projector.ts | 4 + electron/coding-runtime/pi/runtime.ts | 1 + .../coding-runtime/pi/session-projector.ts | 5 ++ electron/coding-teacher/service.ts | 3 +- shared/coding-conversation-contracts.ts | 2 + shared/coding-teacher.ts | 2 + shared/message-timestamp.ts | 7 ++ src/lib/message-timestamp.ts | 18 +++++ src/pages/Chat/CodingConversationTimeline.tsx | 3 + src/pages/Chat/MessageTimestamp.tsx | 15 ++++ src/pages/Chat/TeacherChatPanel.tsx | 13 +-- src/stores/coding-conversations.ts | 1 + tests/e2e/fixtures/message-timestamps.tsx | 27 +++++++ tests/e2e/message-timestamps.spec.ts | 79 +++++++++++++++++++ tests/e2e/pi-coding-first-chat.spec.ts | 9 ++- .../coding-conversation-timeline.test.tsx | 22 ++++++ tests/unit/coding-teacher.test.ts | 29 +++++++ tests/unit/message-timestamp.test.tsx | 30 +++++++ tests/unit/pi-event-projector.test.ts | 16 ++++ tests/unit/pi-session-projector.test.ts | 19 +++++ .../unit/teacher-conversation-panel.test.tsx | 30 +++++++ tests/unit/teacher-conversation.test.ts | 15 ++++ 24 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 shared/message-timestamp.ts create mode 100644 src/lib/message-timestamp.ts create mode 100644 src/pages/Chat/MessageTimestamp.tsx create mode 100644 tests/e2e/fixtures/message-timestamps.tsx create mode 100644 tests/e2e/message-timestamps.spec.ts create mode 100644 tests/unit/message-timestamp.test.tsx diff --git a/.project-docs/30-worklog/tasks/20260929-community-welcome-c724e5a1.md b/.project-docs/30-worklog/tasks/20260929-community-welcome-c724e5a1.md index 62a865aa..3a5b56c4 100644 --- a/.project-docs/30-worklog/tasks/20260929-community-welcome-c724e5a1.md +++ b/.project-docs/30-worklog/tasks/20260929-community-welcome-c724e5a1.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260929-community-welcome-c724e5a1 - Base commit: 4199cadd1e067ce9741d6496196f4d13fed61d12 - Owner: codex -- Status: Ready for Integration +- Status: In Progress ## Scope @@ -109,3 +109,14 @@ All three steps completed. Earlier public-feed investigation was feasibility wor - Completed: removed shared welcome background/shadow, heading and dismiss-all entry/callback. Each native button owns its entire colored speech-bubble surface (including the tail), while the close button is a separate sibling. Avatar opening, teacher-specific dismissal, persisted introduction receipts and three-operation-round auto-dismiss are retained. Keyboard focus is visible. - Verification: 78 related unit tests, 21 headless welcome/teacher-preview layout cases, typecheck, lint:check (0 errors, 8 existing warnings), scoped changed-test ESLint, build:vite, native E2E --list (20 cases), ownership and whitespace checks passed. Tests cover padding clicks opening the correct teacher, close not opening consultation, Enter opening, many-teacher scroll, no shared chrome, individual dismissal persistence and the previous three-round lifecycle. Reviewed the desktop screenshot. Native headed fixture was not run to avoid focus changes. - Trial refreshed in the background to index-BmLachPE.js on unchanged Main PID 62668. Live DOM confirms transparent outer container, no shadow/header/dismiss-all, preserved independent close button and hit-testing of bubble padding to the open button. Preserved the already-dismissed introduction receipts (one remaining Alice introduction), login identity, route and all 13 saved drafts. Welcome centering error remains 0px and bottom gap 16px. No alert or focus activation. Background capture was unavailable; visual evidence comes from the headless screenshot plus actual live DOM verification. Original background throttling restored and inspector closed after completion. Evidence: trial standalone-teacher-bubbles-20260929/. + + +## Follow-up: Timestamps For Operation And Teacher Messages + +- User approved the timestamp proposal and asks to implement and update the temporary app. Same owned task/worktree resumed at 3a17510; official start/status verified planning ownership. Existing Project Context Loaded and peer scope assessment apply; no new overlap. Planning Gate: Passed. +- Show unobtrusive local timestamps beneath every visible user/assistant message, including operation commentary and teacher check-ins. Today uses HH:mm, older messages include date, hover gives full date and seconds. Pending replies show a pending label. +- Operation messages use Pi user send timestamps and persisted assistant message-entry completion timestamps; live completion is observed at message_end and reconciled to durable history on settlement. Teacher questions retain createdAt; all teacher terminal replies persist a new optional finishedAt. Missing historical times remain explicitly unknown, with no backfill from topic/update/current time and no destructive migration. +- Plan: add shared validation/display, propagate times through Main and Renderer, verify streaming/terminal/reload/legacy paths, then rebuild and restart only the existing trial in the background, preserving account/project/drafts/history. Keep standalone teacher bubbles and other approved UI. No cloud prompts/config edits, model calls, subagents, main merge or push. + +- Implemented the approved timestamp data/display flow with no prompt/configuration changes or history migration. The shared display uses semantic time elements, accessible labels, local dates and full hover precision. Terminal teacher replies persist finishedAt before the saved snapshot is emitted. Operation settlement continues to use the existing canonical Pi hydration. +- Verification before trial restart: 286 focused tests passed; full suite 2,681 passed and 3 skipped across 266 files including the pressure run. After extracting the pure formatter to remove a new lint warning, typecheck and all 41 affected UI/formatter tests passed. lint:check has 0 errors and the same 8 existing warnings; changed E2E files pass scoped ESLint; build:vite passed. All 23 headless Chrome layout/interaction cases passed, including the two new real-component timestamp fixtures (375/1280px, stable times after reload, quick-reply usability). Reviewed both screenshot sizes. Updated native Electron timestamp assertions and all 20 native cases parse via --list; no headed launch or real model calls. diff --git a/README.md b/README.md index c7660c89..03151c9e 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - Conversation 历史按需从 Main-owned Snapshot 读取。Session hydration 沿完整 active branch 投影可见历史;公开 SSE 交付 Snapshot、会话元数据变更通知,以及按 Conversation、worker generation 分组的 `patch-batch`。Renderer 整批校验连续 seq 后在一次状态事务中顺序应用,缺口或畸形批次只恢复目标 Conversation,隐藏 Conversation 的流式更新不会提交选中时间线。Main 发出 `lifecycle:sleep` 时页面关闭旧事件流;视图挂载、项目上下文变化、窗口重新可见或获得焦点时,已选 Conversation 会静默重读权威 Snapshot,从而收敛后台已结束的任务,但绝不重放已接受或状态不确定的 mutation。时间线默认挂载最近 120 个节点,接近顶部时按 100 个节点自动追加更早内容并补偿新增高度,保持当前阅读锚点。 - 核心时间线按每条用户消息组织为“用户消息、连续处理过程、结论回复”。Pi 的流式 thinking、工具、压缩、重试、通知和 `subagent.v1` 单个/并行/串行子任务共享一条轻量过程轨道;运行时显示“处理中”和活动指示,完成后按耗时(历史缺少计时时按项目数)收纳为可展开的“已处理”摘要。文字层级按结构化事件类型划分,不按文案关键词猜测:助手在工具调用前后直接对用户说的过程正文与最终结论都使用黑色;thinking、工具输入输出、压缩、子任务和运行状态使用浅灰辅助文字,仍在运行的灰色缩略文字带有低对比度 Shimmer 擦拭高光,结束后停止动画;错误状态保持红色。折叠的 thinking、助手过程正文和工具输出固定展示第一个可见块的首个非空行,横向偏移始终为零;尾部流式更新不会替换未变化的首行预览,展开后仍可查看完整内容。两类流式文字均在原位更新且不做位移动画,助手过程正文也不使用 Shimmer。工具调用用一行摘要持续展示工具名称、最新输入或输出和运行状态,并可按流式批次轻量整行轮动。思考、助手过程正文和工具节点均可展开查看完整详情,工具的累计输出和浏览器附件留在对应的二级展开区而不形成独立气泡。结论回复按安全 Markdown 排版标题、列表、引用、代码和表格;可点击的 HTTP/HTTPS 与本地 HTML 链接统一使用无底色的橙色文字。左键通过 Main-owned 系统入口交给默认浏览器打开;右键可打开系统菜单,网页链接可复制地址,本地链接还可在访达或系统文件夹中显示。同一回复已经给出完整 HTML 路径时,对应的相对查看路径也会解析到该文件。内部 `turn-start`/`turn-end` 只维持事件顺序、不呈现在界面。 - Composer 使用贴底的复合输入面板,支持文字、登录后的语音转写,以及粘贴或选择 PNG/JPEG/WebP/GIF 图片;每条消息最多 16 张图片、最多并行上传 4 张。语音录制通过现有 Main-owned Works Speech Host API 转成文字,并在项目或账号切换时立即停止;当前对话的模型与思考强度组合入口、语音及发送/中止入口保留在底部控制行,模型入口默认使用透明底色。模型与思考强度通过向上展开的分级菜单切换,只列出当前 Provider 或 Makelore 已验证模型档案实际支持的选项;Qwen3.6 Plus 使用混合思考开关,Qwen3.8 Max 暴露低/中/高三档并把产品“高”映射到服务端 `xhigh`。模型仅报告 `off` 时明确显示“不可调”,不伪造未知模型的推理档位。切换完成后静默刷新当前 Snapshot,只有真正恢复 worker 时才显示本地 Agent 重连提示。重复读取 Snapshot 不发布无变化的 worker 状态;Snapshot 与 SSE 批次竞态时,Renderer 会丢弃已覆盖事件并只应用连续的新尾部,避免形成伪重连循环。上下文接近模型限制或发生溢出时由 Pi 自动整理,Composer 不提供手动整理入口;自动整理的进度和结果仍显示在对话过程轨道中。图片在发送前只保留本地预览,点击发送时才经 Main-owned 有界二进制接口上传一次;Main 在落盘前核对 MIME 与最小图片签名,状态与事件只保存 attachment id,时间线按需读取二进制并创建临时 object URL,不保存重复 base64。发送后立即展示图片附件;Pi 实时消息与历史会话恢复都将图片映射到同一附件存储,重复加载复用已有文件。智能体报告 `changed-file.v1` 后,输入框上方会展示当前轮次的文件数与增删行统计,并可展开查看 Main-owned 变更快照中的逐文件差异。 +- 操作 Agent 和所有老师的消息下方显示本地时间:提问记发送时间,回复记完成时间;当天显示时分,较早消息加日期,跨年加年份,悬停显示完整日期和秒。流式回复显示“回复中…”。操作历史读取 Pi 原始用户时间和回复条目落盘时间;实时回复在结束时记录,再由持久化记录校准。老师每次回复(含主动检查、取消与失败)单独保存完成时间;旧记录缺少时间时显示“时间未记录”,不使用当前时间或话题更新时间回填,也不改写用户历史。 - Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。最终产品中的 Pi 运行时会通过受控 Provider-shaped 回环 smoke 验证同一 Agent Server 内多个 Conversation 逻辑线程的重叠执行、状态隔离、凭证引用隔离、单线程关闭和 Server 崩溃后重启,以及独立子 Agent 进程的隔离与回收;真实外部 Provider 的并发、限流、协议兼容和凭证隔离仍是独立风险,未执行真实 Provider 验证时不得标记为 Pass。 - 首次发送会立即生成稳定的乐观用户消息;HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。 - 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列正常在 `agent_settled` 后释放,用户可中止当前运行。若 Pi 已终结但该握手丢失,Main 会在 30 秒宽限后仅探测目标线程:权威 idle 状态会补齐持久化最终回复并 exactly-once 释放 ownership,矛盾状态则显式失败该目标,且不会重放已接受请求。select/confirm/input/editor 交互在输入区上方回答,select 除预设选项外支持自由输入;交互提交或取消后立即从输入区移除,失效请求会就地提示并允许重试或取消。 diff --git a/electron/coding-runtime/pi/event-projector.ts b/electron/coding-runtime/pi/event-projector.ts index f49e5116..5d6d7c54 100644 --- a/electron/coding-runtime/pi/event-projector.ts +++ b/electron/coding-runtime/pi/event-projector.ts @@ -1,3 +1,4 @@ +import { messageTimestamp } from '../../../shared/message-timestamp'; import type { ConversationContentBlock, ConversationMessageNode, @@ -529,6 +530,7 @@ export class PiEventProjector { ? { clientRequestId: optimistic.clientRequestId } : {}), role: 'user', + timestamp: messageTimestamp(message.timestamp) ?? optimistic?.timestamp ?? this.now(), status: 'complete', blocks: await liveContentBlocks( id, @@ -723,6 +725,8 @@ export class PiEventProjector { kind: 'message', id, role: 'assistant', + // Live observation; settlement hydration replaces it with Pi's durable entry time. + timestamp: this.now(), status: message.stopReason === 'aborted' ? 'aborted' : message.stopReason === 'error' ? 'error' : 'complete', diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index a56cf174..2ed7eed2 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -814,6 +814,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { id: messageId, clientRequestId: input.clientRequestId, role: 'user', + timestamp: this.now(), status: 'optimistic', blocks: [ ...(input.text.length > 0 diff --git a/electron/coding-runtime/pi/session-projector.ts b/electron/coding-runtime/pi/session-projector.ts index 9dbf6c6b..b1fbdb44 100644 --- a/electron/coding-runtime/pi/session-projector.ts +++ b/electron/coding-runtime/pi/session-projector.ts @@ -1,3 +1,4 @@ +import { messageTimestamp } from '../../../shared/message-timestamp'; import type { CodingRuntimeErrorCode, ConversationContentBlock, @@ -235,6 +236,10 @@ async function projectEntries( id: messageId, sourceEntryId: entry.id, role: message.role, + // Pi appends the entry on message_end; assistant.message.timestamp is its start time. + timestamp: message.role === 'user' + ? messageTimestamp(message.timestamp) ?? messageTimestamp(entry.timestamp) + : messageTimestamp(entry.timestamp), status: message.stopReason === 'aborted' ? 'aborted' : message.stopReason === 'error' ? 'error' : 'complete', diff --git a/electron/coding-teacher/service.ts b/electron/coding-teacher/service.ts index 01502db7..b255fe80 100644 --- a/electron/coding-teacher/service.ts +++ b/electron/coding-teacher/service.ts @@ -671,7 +671,8 @@ export class CodingTeacherService { current.response = ''; delete current.suggestedQuestions; } - topic.updatedAt = new Date().toISOString(); + current.finishedAt = new Date().toISOString(); + topic.updatedAt = current.finishedAt; topic.revision++; try { await store.save(topic, current.id); diff --git a/shared/coding-conversation-contracts.ts b/shared/coding-conversation-contracts.ts index 1e52995e..86a5aeee 100644 --- a/shared/coding-conversation-contracts.ts +++ b/shared/coding-conversation-contracts.ts @@ -142,6 +142,8 @@ export type ConversationContentBlock = export interface ConversationMessageNode { kind: 'message'; id: string; + /** Epoch ms: user send time / assistant completion time. Absent for undated history. */ + timestamp?: number; sourceEntryId?: string; clientRequestId?: string; role: 'user' | 'assistant'; diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index 95b0f3e5..00b21150 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -96,6 +96,8 @@ export interface TeacherRequest { text: string; references: TeacherReference[]; createdAt: string; + /** Actual terminal time, absent in older history; never inferred from topic.updatedAt. */ + finishedAt?: string; sourceCursor: TeacherSourceContext['cursor']; sourceCapturedAt: string; includedSourceMessageIds: string[]; diff --git a/shared/message-timestamp.ts b/shared/message-timestamp.ts new file mode 100644 index 00000000..02e8d915 --- /dev/null +++ b/shared/message-timestamp.ts @@ -0,0 +1,7 @@ +/** Read only real, valid epoch milliseconds / ISO dates; never invent a history time. */ +export function messageTimestamp(value: unknown): number | undefined { + const timestamp = typeof value === 'number' ? value + : typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value) ? Date.parse(value) : NaN; + return Number.isFinite(timestamp) && timestamp >= 0 && !Number.isNaN(new Date(timestamp).getTime()) + ? timestamp : undefined; +} diff --git a/src/lib/message-timestamp.ts b/src/lib/message-timestamp.ts new file mode 100644 index 00000000..1e751b03 --- /dev/null +++ b/src/lib/message-timestamp.ts @@ -0,0 +1,18 @@ +import { messageTimestamp } from '../../shared/message-timestamp'; + +export function formatMessageTimestamp(value: unknown, now = new Date()) { + const timestamp = messageTimestamp(value); + if (timestamp === undefined) return undefined; + const date = new Date(timestamp); + const pad = (part: number) => String(part).padStart(2, '0'); + const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`; + const sameYear = date.getFullYear() === now.getFullYear(); + const today = sameYear && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(); + const day = `${date.getMonth() + 1}月${date.getDate()}日`; + return { + iso: date.toISOString(), + short: today ? time : `${sameYear ? '' : `${date.getFullYear()}年`}${day} ${time}`, + full: `${date.getFullYear()}年${day} ${time}:${pad(date.getSeconds())}`, + }; +} + diff --git a/src/pages/Chat/CodingConversationTimeline.tsx b/src/pages/Chat/CodingConversationTimeline.tsx index 78105632..8f37f759 100644 --- a/src/pages/Chat/CodingConversationTimeline.tsx +++ b/src/pages/Chat/CodingConversationTimeline.tsx @@ -1,5 +1,6 @@ import { OPEN_WORK_PROMPT } from '../../../shared/coding-work-preview'; import { CONTINUE_CODING_PROMPT, type CodingRecoveryAction } from '../../../shared/coding-recovery'; +import { MessageTimestamp } from './MessageTimestamp'; import { CodingRecoveryCard } from './CodingRecoveryCard'; import { memo, @@ -539,6 +540,7 @@ const MessageNode = memo(function MessageNode({ )} + {!user && answerText && ( } @@ -537,13 +539,12 @@ export function TeacherChatPanel({ ?

我看看你最近做到了哪里…

: request.status === 'completed' && <> -
- {request.suggestedQuestions?.map((question, index) => )} -
} {['failed', 'cancelled', 'interrupted'].includes(request.status) && } : request.response ? :
{['running', 'preparing'].includes(request.status) ? {label}正在想… : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}
} - {request.intent !== 'suggestions' && request.status === 'completed' && !request.replyIncomplete && Boolean(request.suggestedQuestions?.length) &&
+ +
+ {request.status === 'completed' && !request.replyIncomplete && Boolean(request.suggestedQuestions?.length) &&
{request.suggestedQuestions?.map((question, index) => )}
} {request.status === 'completed' && !request.replyIncomplete && request.replyParseError &&

回答正文已保留,部分附加内容未能显示。

} diff --git a/src/stores/coding-conversations.ts b/src/stores/coding-conversations.ts index 706f2955..5e6d565e 100644 --- a/src/stores/coding-conversations.ts +++ b/src/stores/coding-conversations.ts @@ -227,6 +227,7 @@ function optimisticNode( id: nodeId, clientRequestId, role: 'user', + timestamp: Date.now(), status: 'optimistic', blocks: [ ...(draft.text ? [{ diff --git a/tests/e2e/fixtures/message-timestamps.tsx b/tests/e2e/fixtures/message-timestamps.tsx new file mode 100644 index 00000000..a841c268 --- /dev/null +++ b/tests/e2e/fixtures/message-timestamps.tsx @@ -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( +
= 768 ? 'minmax(0, 1fr) 420px' : 'minmax(0, 1fr)' }}> +

操作对话

+
+
, +); diff --git a/tests/e2e/message-timestamps.spec.ts b/tests/e2e/message-timestamps.spec.ts new file mode 100644 index 00000000..946c45c7 --- /dev/null +++ b/tests/e2e/message-timestamps.spec.ts @@ -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('
'); + }); + }, + }], + }); + 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); + }); +} diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index 75f468bb..99e14a72 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -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[]).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);} }); diff --git a/tests/unit/coding-conversation-timeline.test.tsx b/tests/unit/coding-conversation-timeline.test.tsx index cc228f38..14d794ae 100644 --- a/tests/unit/coding-conversation-timeline.test.tsx +++ b/tests/unit/coding-conversation-timeline.test.tsx @@ -11,6 +11,28 @@ vi.mock('@/lib/coding-attachments', () => ({ })); describe('CodingConversationTimeline', () => { + it('shows stable timestamps beneath both sides without changing message content', async () => { + const { codingConversationStore } = await import('@/stores/coding-conversations'); + const { CodingConversationTimeline } = await import('@/pages/Chat/CodingConversationTimeline'); + const snapshot = createProductSnapshot('message-times', 1); + snapshot.run = { status: 'idle' }; + const sent = Date.parse('2026-09-28T08:01:02Z'), finished = sent + 40000; + snapshot.nodes = [ + { kind: 'message', id: 'u', role: 'user', status: 'complete', timestamp: sent, blocks: [{ kind: 'text', id: 'ut', status: 'complete', text: '时间问题' }] }, + { kind: 'message', id: 'a', role: 'assistant', status: 'complete', timestamp: finished, blocks: [{ kind: 'text', id: 'at', status: 'complete', text: '时间回答' }] }, + ]; + codingConversationStore.getState().primeConversation(snapshot); + const view = render(); + const userTime = view.container.querySelector('[data-node-id="u"] time'); + const agentTime = view.container.querySelector('[data-node-id="a"] time'); + expect(userTime).toHaveAttribute('datetime', new Date(sent).toISOString()); + expect(userTime).toHaveClass('text-right'); + expect(agentTime).toHaveAttribute('datetime', new Date(finished).toISOString()); + act(() => codingConversationStore.getState().primeConversation(structuredClone(snapshot))); + expect(view.container.querySelector('[data-node-id="a"] time')).toHaveAttribute('datetime', new Date(finished).toISOString()); + expect(screen.getByText('时间回答')).toBeVisible(); + }); + it('gives the failed hidden work action one login recovery and removes it when work is ready', async () => { const { codingConversationStore } = await import('@/stores/coding-conversations'); const { CodingConversationTimeline } = await import('@/pages/Chat/CodingConversationTimeline'); diff --git a/tests/unit/coding-teacher.test.ts b/tests/unit/coding-teacher.test.ts index bb290b47..34df5a95 100644 --- a/tests/unit/coding-teacher.test.ts +++ b/tests/unit/coding-teacher.test.ts @@ -164,6 +164,35 @@ async function fixture({ durableSource = false, sourceContext = context, liveMod }; } describe('cloud coding teacher', () => { + it.each([false, true])('persists terminal times for local/cloud=%s replies across restart', async cloudTeacher => { + const f = await fixture({ cloudTeacher, mockCloud: cloudTeacher }); + const topic = await f.service.create(f.scope, undefined, undefined, cloudTeacher ? 9 : undefined); + const id = '22222222-2222-4222-8222-222222222222'; + await f.service.send(f.scope, topic.id, { requestId: id, text: '解释一下' }); + const running = (await f.service.read(f.scope, topic.id)).requests[0]; + expect(running.finishedAt).toBeUndefined(); + const endingAfter = Date.now(); + f.finish(); + await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0].finishedAt).toBeTruthy()); + const saved = (await f.service.read(f.scope, topic.id)).requests[0]; + expect(saved.createdAt).toBe(running.createdAt); + expect(Date.parse(saved.finishedAt!)).toBeGreaterThanOrEqual(endingAfter); + expect(Date.parse(saved.finishedAt!)).toBeLessThanOrEqual(Date.now()); + const restarted = await f.restart(); + expect((await restarted.read(f.scope, topic.id)).requests[0]).toEqual(saved); + }); + it.each(['cancelled', 'failed'] as const)('also saves the actual terminal time for %s replies', async status => { + const f = await fixture(); + if (status === 'failed') f.run.mockRejectedValueOnce(new Error('failure')); + const topic = await f.service.create(f.scope); + const id = '22222222-2222-4222-8222-222222222222'; + await f.service.send(f.scope, topic.id, { requestId: id, text: '解释一下' }); + if (status === 'cancelled') await f.service.cancel(f.scope, topic.id, id); + await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0]).toMatchObject({ status, finishedAt: expect.any(String) })); + const saved = (await f.service.read(f.scope, topic.id)).requests[0]; + expect((await (await f.restart()).read(f.scope, topic.id)).requests[0].finishedAt).toBe(saved.finishedAt); + }); + it('does not change the selected discussion when the companion polls an older topic', async () => { const f = await fixture(); const old = await f.service.create(f.scope); diff --git a/tests/unit/message-timestamp.test.tsx b/tests/unit/message-timestamp.test.tsx new file mode 100644 index 00000000..9c52da4d --- /dev/null +++ b/tests/unit/message-timestamp.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { MessageTimestamp } from '@/pages/Chat/MessageTimestamp'; +import { formatMessageTimestamp } from '@/lib/message-timestamp'; +import { messageTimestamp } from '../../shared/message-timestamp'; + +const now = new Date(2026, 8, 29, 17, 8, 9); +describe('message timestamps', () => { + it('uses local dates, distinguishes older years, and exposes full precision without a ticking clock', () => { + expect(formatMessageTimestamp(now.getTime(), now)).toEqual({ iso: now.toISOString(), short: '17:08', full: '2026年9月29日 17:08:09' }); + expect(formatMessageTimestamp(new Date(2026, 8, 28, 23, 59).toISOString(), now)?.short).toBe('9月28日 23:59'); + expect(formatMessageTimestamp(new Date(2025, 8, 29, 17, 8).getTime(), now)?.short).toBe('2025年9月29日 17:08'); + expect(formatMessageTimestamp(new Date(2026, 8, 30, 0, 1).getTime(), now)?.short).toBe('9月30日 00:01'); + }); + it.each([undefined, null, '', 'now', '1', NaN, Infinity, -1, 1e20, '2026-99-99T00:00:00Z'])('does not invent a date for %s', value => { + expect(messageTimestamp(value)).toBeUndefined(); + expect(formatMessageTimestamp(value, now)).toBeUndefined(); + }); + it('renders a semantic time with full hover text; pending and legacy replies do not get fabricated times', () => { + const view = render(); + expect(screen.getByText('回复中…')).toHaveAttribute('data-message-time', 'pending'); + expect(view.container.querySelector('time')).toBeNull(); + view.rerender(); + expect(view.container.querySelector('time')).toHaveAttribute('datetime', now.toISOString()); + expect(view.container.querySelector('time')).toHaveAttribute('title', '2026年9月29日 17:08:09'); + view.rerender(); + expect(screen.getByText('时间未记录')).toBeVisible(); + expect(view.container.querySelector('time')).toBeNull(); + }); +}); diff --git a/tests/unit/pi-event-projector.test.ts b/tests/unit/pi-event-projector.test.ts index 4cbd9fa9..036457fa 100644 --- a/tests/unit/pi-event-projector.test.ts +++ b/tests/unit/pi-event-projector.test.ts @@ -58,6 +58,19 @@ function apply( } describe('Pi event projector', () => { + it.each(['stop', 'error', 'aborted'])('uses send time and terminal time for %s, never the assistant start time', async stopReason => { + let now = 2000; + const projector = new PiEventProjector({ createId: () => 'live-message', now: () => now }); + let snapshot = emptySnapshot(); + snapshot = apply(snapshot, await projector.project(snapshot, { type: 'message_start', message: { role: 'user', timestamp: 1000, content: [{ type: 'text', text: '你好' }] } })); + expect(snapshot.nodes[0]).toMatchObject({ role: 'user', timestamp: 1000 }); + snapshot = apply(snapshot, await projector.project(snapshot, { type: 'message_start', message: { role: 'assistant', timestamp: 2000, content: [] } })); + expect(snapshot.nodes.at(-1)).not.toHaveProperty('timestamp'); + now = 9000; + snapshot = apply(snapshot, await projector.project(snapshot, { type: 'message_end', message: { role: 'assistant', timestamp: 2000, stopReason, content: [{ type: 'text', text: '你好呀' }] } })); + expect(snapshot.nodes.at(-1)).toMatchObject({ role: 'assistant', timestamp: 9000 }); + }); + it('projects only subagent.v1 details and never exposes unknown raw details', async () => { const projector = new PiEventProjector({ createId: () => 'unused' }); let snapshot = emptySnapshot(); @@ -157,6 +170,7 @@ describe('Pi event projector', () => { it('keeps one assistant UI identity while content-index deltas become an authoritative message', async () => { const projector = new PiEventProjector({ createId: () => 'assistant-ui-a', + now: () => 9000, }); let snapshot = emptySnapshot(); const initialAssistant = { @@ -217,6 +231,7 @@ describe('Pi event projector', () => { kind: 'message', id: 'assistant-ui-a', role: 'assistant', + timestamp: 9000, status: 'complete', blocks: [{ kind: 'text', @@ -624,6 +639,7 @@ describe('Pi event projector', () => { kind: 'message', id: 'user-ui-a', clientRequestId: 'request-a', + timestamp: 20, role: 'user', status: 'complete', blocks: [ diff --git a/tests/unit/pi-session-projector.test.ts b/tests/unit/pi-session-projector.test.ts index d93020b9..4c30dddf 100644 --- a/tests/unit/pi-session-projector.test.ts +++ b/tests/unit/pi-session-projector.test.ts @@ -33,6 +33,25 @@ function baseSnapshot(): ConversationSnapshot { const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }; describe('Pi session projector', () => { + it('restores send and completion times from durable entries and leaves undated history unknown', async () => { + const sent = '2026-09-28T08:01:02.000Z', finished = '2026-09-28T08:01:42.000Z'; + const input = { snapshot: baseSnapshot(), workerGeneration: 1, + state: { sessionId: 'times', isStreaming: false, isCompacting: false }, + entries: { leafId: 'old', entries: [ + { type: 'message', id: 'u', parentId: null, timestamp: finished, message: { role: 'user', timestamp: Date.parse(sent), content: '你好' } }, + { type: 'message', id: 'a', parentId: 'u', timestamp: finished, message: { role: 'assistant', timestamp: Date.parse(sent), stopReason: 'stop', content: [{ type: 'text', text: '你好呀' }] } }, + { type: 'message', id: 'fallback', parentId: 'a', timestamp: sent, message: { role: 'user', timestamp: 'invalid', content: '较早问题' } }, + { type: 'message', id: 'old', parentId: 'fallback', timestamp: 'invalid', message: { role: 'assistant', timestamp: Date.parse(sent), stopReason: 'stop', content: [{ type: 'text', text: '无完成时间' }] } }, + ] } }; + const snapshot = await projectPiSessionSnapshot(input); + expect(snapshot.nodes).toMatchObject([ + { role: 'user', timestamp: Date.parse(sent) }, { role: 'assistant', timestamp: Date.parse(finished) }, + { role: 'user', timestamp: Date.parse(sent) }, { role: 'assistant', timestamp: undefined }, + ]); + expect((await projectPiSessionSnapshot({ ...input, snapshot })).nodes).toEqual(snapshot.nodes); + expect(input.entries.entries[3].timestamp).toBe('invalid'); + }); + it('restores the observed expired Works Square session as a safe login recovery notice', async () => { const snapshot = await projectPiSessionSnapshot({ snapshot: baseSnapshot(), workerGeneration: 1, diff --git a/tests/unit/teacher-conversation-panel.test.tsx b/tests/unit/teacher-conversation-panel.test.tsx index 272cbf65..88093f22 100644 --- a/tests/unit/teacher-conversation-panel.test.tsx +++ b/tests/unit/teacher-conversation-panel.test.tsx @@ -41,6 +41,36 @@ const props = { projectId: 'project-a', projectName: '天气', sourceId: 'source async function mount() { let view!: ReturnType; await act(async () => { view = render(); }); return view; } describe('continuous agent panel', () => { + it.each([undefined, 'suggestions', 'check-in'] as const)('shows question/check-in and reply times for %s across teacher reloads', async intent => { + const current = { ...request('timestamped'), createdAt: '2026-09-28T08:01:02Z', finishedAt: '2026-09-28T08:01:42Z', intent, + suggestedQuestions: ['接着聊'], response: '这一轮的回答' }; + const saved = topic([current, request('legacy')]); + api.conversation.mockResolvedValue({ topic: saved, before: null }); + api.seenConversation.mockImplementation(async () => saved); + const view = await mount(); + expect(view.container.querySelectorAll('time')).toHaveLength(2); + expect(view.container.querySelectorAll('time')[0]).toHaveAttribute('datetime', '2026-09-28T08:01:02.000Z'); + expect(view.container.querySelectorAll('time')[1]).toHaveAttribute('datetime', '2026-09-28T08:01:42.000Z'); + expect(screen.getAllByText('时间未记录')).toHaveLength(2); + expect(screen.getByRole('button', { name: '接着聊' })).toBeVisible(); + view.unmount(); + const reloaded = await mount(); + expect(reloaded.container.querySelectorAll('time')[1]).toHaveAttribute('datetime', '2026-09-28T08:01:42.000Z'); + expect(api.sendConversation).not.toHaveBeenCalled(); + }); + it('waits for the terminal reply timestamp while preserving the question time', async () => { + const current = { ...request('streaming'), status: 'running' as const, createdAt: '2026-09-28T08:01:02Z' }; + const saved = topic([current]); + api.conversation.mockResolvedValue({ topic: saved, before: null }); + api.seenConversation.mockImplementation(async () => saved); + const view = await mount(); + expect(view.container.querySelectorAll('time')).toHaveLength(1); + expect(view.container.querySelector('[data-message-time="pending"]')).toBeVisible(); + await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({ ...saved, revision: 2, requests: [{ ...current, status: 'completed', finishedAt: '2026-09-28T08:01:42Z' }] }) }))); + expect(view.container.querySelectorAll('time')).toHaveLength(2); + expect(view.container.querySelector('[data-message-time="pending"]')).toBeNull(); + }); + it('retries incomplete prose only in its original project/source and keeps current drafts and one send identity', async () => { const original = { ...request('broken', '现在项目是什么阶段?'), response: '它早就不是', replyIncomplete: true, references: [{ kind: 'code' as const, text: '原项目代码' }] }; diff --git a/tests/unit/teacher-conversation.test.ts b/tests/unit/teacher-conversation.test.ts index 82ed96b5..6d346a91 100644 --- a/tests/unit/teacher-conversation.test.ts +++ b/tests/unit/teacher-conversation.test.ts @@ -37,6 +37,21 @@ function turn(index: number): TeacherRequest { omittedMessages: 0, status: 'completed', response: '回答 ' + index }; } describe('continuous conversation persistence', () => { + it('preserves individual completion times and reads older replies without backfilling or rewriting them', async () => { + const dir = await root(); + const store = new TeacherConversationStore(dir, 'account', 'agent-a'); + const topic = await store.ensure(definition, 1); + const old = turn(1), recent = { ...turn(2), finishedAt: '2026-09-29T08:01:42.000Z' }; + topic.requests = [old, recent]; + await store.save(topic); + const first = await new TeacherConversationStore(dir, 'account', 'agent-a').read(); + expect(first.requests[0]).not.toHaveProperty('finishedAt'); + expect(first.requests[1].finishedAt).toBe(recent.finishedAt); + const second = await new TeacherConversationStore(dir, 'account', 'agent-a').read(); + expect(second.requests).toEqual(first.requests); + expect(second.requests[0].createdAt).toBe(old.createdAt); + }); + it('repairs retained replies on every page without rewriting their files during restart recovery', async () => { const dir = await root(); const store = new TeacherConversationStore(dir, 'account', 'agent-a');