diff --git a/docs/images/message-timestamps/README.md b/docs/images/message-timestamps/README.md new file mode 100644 index 00000000..8ebe50e9 --- /dev/null +++ b/docs/images/message-timestamps/README.md @@ -0,0 +1,12 @@ +# 消息时间:真实页面对照 + +前后图片均来自 Windows 上的 Chromium,使用生产构建和同一个本地 Go 服务、同一条已保存聊天。测试服务通过实际文件工具读取 README.md,返回离线验证回复;没有修改页面 DOM 来模拟消息或时间。 + +- 基线:`10311148d5f581d68e4603e9a435c257015cc81d`。 +- 桌面视口:1280 × 900,见 [before.png](before.png) / [after.png](after.png)。 +- 窄视口:600 × 900,见 [before-narrow.png](before-narrow.png) / [after-narrow.png](after-narrow.png)。 +- 用户消息原始时间为 `2026-10-21T17:47:40.211Z`;助手消息为 `2026-10-21T17:47:40.402Z`。本机 UTC+8,界面均显示 `01:47`。 +- 实际页面的 `title` 与读屏标签包含 `2026年10月22日 GMT+8 01:47:40`,`time` 元素保留原始 ISO 时间。 +- 已检查两个视口中时间与复制、引用、分支等操作的排列。600 px 的正文横向滚动条在基线中也存在。 + +15 项 narrative / narrative-streaming 聚焦测试及生产构建通过;测试覆盖缺失、无效时间和正在生成的回复。此次没有原生桌面打包验证。 diff --git a/docs/images/message-timestamps/after-narrow.png b/docs/images/message-timestamps/after-narrow.png new file mode 100644 index 00000000..cfb823e5 Binary files /dev/null and b/docs/images/message-timestamps/after-narrow.png differ diff --git a/docs/images/message-timestamps/after.png b/docs/images/message-timestamps/after.png new file mode 100644 index 00000000..9dea4b00 Binary files /dev/null and b/docs/images/message-timestamps/after.png differ diff --git a/docs/images/message-timestamps/before-narrow.png b/docs/images/message-timestamps/before-narrow.png new file mode 100644 index 00000000..ad5fbc84 Binary files /dev/null and b/docs/images/message-timestamps/before-narrow.png differ diff --git a/docs/images/message-timestamps/before.png b/docs/images/message-timestamps/before.png new file mode 100644 index 00000000..b5b7f3d2 Binary files /dev/null and b/docs/images/message-timestamps/before.png differ diff --git a/docs/usage.md b/docs/usage.md index d420e70a..921959bd 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -175,6 +175,9 @@ Ctrl+Shift+V(macOS 为 Cmd+Shift+V)按纯文本粘贴,保留 Markdown 标 围栏块中;块顶部显示“文本”和复制按钮,复制内容不包含围栏。代码围栏显示语言名称。 普通段落保留整条回复的复制入口。 +已保存的用户消息和助手回复在操作图标旁显示本地时间。悬停时间可查看完整日期、秒和时区; +屏幕阅读器可读取同样的信息。仍在发送或生成的消息、没有有效原始时间的历史记录不显示时间。 + 聊天正文使用 14 px 字号、1.625 倍行高和正常字距,代码块保持 13 px。 已完成的段落采用智能换行,标题均衡换行,长标识符在空间不足时折行。 实时回复的新片段以 150 ms 的轻微模糊淡入显示,不额外等待或延迟输出;历史消息和 diff --git a/web/src/v2/components/narrative.test.tsx b/web/src/v2/components/narrative.test.tsx index 0f7cceb6..363e7134 100644 --- a/web/src/v2/components/narrative.test.tsx +++ b/web/src/v2/components/narrative.test.tsx @@ -9,6 +9,27 @@ const markdown = vi.hoisted(() => vi.fn(({ children }: { children: string }) => vi.mock("react-markdown", () => ({ default: markdown })); afterEach(() => { cleanup(); markdown.mockClear(); }); +it("shows stored message times with a full local date and timezone, without replacing missing timestamps", () => { + const createdAt = "2026-10-21T09:16:43Z"; + const entries: NarrativeEntry[] = [ + { id: "user", kind: "user", text: "Request", createdAt, provisional: false }, + { id: "reply", kind: "assistant", text: "Reply", createdAt, provisional: false }, + { id: "missing", kind: "assistant", text: "Older reply", createdAt: "", provisional: false }, + { id: "invalid", kind: "assistant", text: "Invalid time", createdAt: "invalid", provisional: false }, + { id: "live", kind: "assistant", text: "Streaming", createdAt, provisional: true }, + ]; + const { container } = render(); + const times = container.querySelectorAll("time"); + expect(times).toHaveLength(2); + for (const time of times) { + expect(time).toHaveAttribute("datetime", "2026-10-21T09:16:43.000Z"); + expect(time.textContent).toMatch(/^\d{2}:\d{2}$/u); + expect(time.title).toContain("2026"); + expect(time.title).toContain("43"); + expect(time).toHaveAccessibleName("Message time: " + time.title); + } +}); + it("copies and reuses processed user text, quotes only the selection, and branches from that input", async () => { const user = userEvent.setup(); const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); diff --git a/web/src/v2/components/narrative.tsx b/web/src/v2/components/narrative.tsx index b9d18aa0..32d60279 100644 --- a/web/src/v2/components/narrative.tsx +++ b/web/src/v2/components/narrative.tsx @@ -81,8 +81,25 @@ function MarkdownCodeBlock({ children, ...props }: React.HTMLAttributes Promise; -function AssistantMessageActions({ text, branchSourceID, onBranch, user = false, onCompose, loadText }: { +const messageTimeFormats = Object.fromEntries((["zh-CN", "en-US"] as const).map((locale) => [locale, { + short: new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }), + full: new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "long" }), +}])); + +function MessageTime({ createdAt }: { createdAt: string }) { + const { locale, t } = useLocale(); + if (!createdAt) return null; + const date = new Date(createdAt); + if (!Number.isFinite(date.getTime())) return null; + const formats = messageTimeFormats[locale]; + const full = formats.full.format(date); + return ; +} + +function AssistantMessageActions({ text, createdAt, branchSourceID, onBranch, user = false, onCompose, loadText }: { text: string; + createdAt: string; branchSourceID?: string; onBranch?: BranchReply; user?: boolean; @@ -177,6 +194,7 @@ function AssistantMessageActions({ text, branchSourceID, onBranch, user = false, {branching ?