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({
)}
+
{request.projectName || '之前的项目'}
} {request.intent === 'check-in' - ?智能体来看看你的进展
- :{request.text}
智能体来看看你的进展
{request.text}
{ref.text})}这次回复未能完整显示
{request.intent !== 'check-in' && request.text.trim() && } @@ -537,13 +539,12 @@ export function TeacherChatPanel({ ?回答正文已保留,部分附加内容未能显示。
} 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( +