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/images/message-tooltips/README.md b/docs/images/message-tooltips/README.md new file mode 100644 index 00000000..d28d90c9 --- /dev/null +++ b/docs/images/message-tooltips/README.md @@ -0,0 +1,40 @@ +# 消息操作提示的窄屏边界 + +用户消息位于聊天右侧,操作提示原来从按钮左边向右展开。即使提示处于 `visibility: hidden`,其宽度仍扩大聊天滚动区域;鼠标悬停或键盘聚焦时,提示末尾被裁掉。 + +本次仅将 `.v2-user-actions .v2-assistant-action-tooltip` 改为 `left: auto; right: 0`,让用户消息提示向左展开。助手消息提示保留原有对齐方式。 + +## 真实页面对照 + +2026-10-22,Windows Edge。修改前使用 #439 / `ef6ae6b1` 的现成生产 bundle,修改后使用本工作树的生产构建。两套页面连接同一份 SQLite 中已保存的对话,使用生产 API;对话由 `cmd/workbenchjourney` 离线模型提供者实际读取 README 后生成。未替换 DOM、注入修复样式或拦截接口制造结果。 + +600px 中文,鼠标悬停“引用消息”: + +| 修改前 | 修改后 | +| --- | --- | +| ![中文提示修改前](before-zh-CN-600-hover.png) | ![中文提示修改后](after-zh-CN-600-hover.png) | + +390px 英文,使用 Tab 聚焦“Quote message”: + +| 修改前 | 修改后 | +| --- | --- | +| ![英文提示修改前](before-en-US-390-focus.png) | ![英文提示修改后](after-en-US-390-focus.png) | + +## 尺寸与滚动检查 + +以下各组都检查了初始隐藏状态,以及四个用户操作按钮逐一悬停、逐一键盘聚焦。修改后所有消息提示均位于聊天容器左右边界内。 + +| 语言 / 视口宽度 | 聊天 clientWidth | 修改前 scrollWidth | 修改后 scrollWidth | +| --- | ---: | ---: | ---: | +| 中文 / 600px | 600 | 621 | 600 | +| 中文 / 390px | 390 | 411 | 390 | +| English / 600px | 600 | 704 | 600 | +| English / 390px | 390 | 494 | 390 | + +[完整 DOM 尺寸记录](measurements.csv)包含每次测量的提示边界、可见状态和透明度。基线生产 bundle 摘要为 `ecb366bd9e5d692d8dedc260669f3ac456b31113ca1565b50b400add6f97752b`,修改后为 `ddca30555373a91473dd60bb522246d39fc0b39c5f3a023bafe0a216bfbdd945`。 + +另从真实输入框发送并保存了带长行的 JavaScript 代码块。代码区域的 `scrollWidth` 为 2570px,在 600px / 390px 视口下自身宽度分别为 401px / 237px。横向滚轮将代码自身的 `scrollLeft` 分别推进至 1200px / 2000px;聊天容器保持 `scrollLeft=0`,且 `scrollWidth=clientWidth`。完整代码原文保留。[滚动测量](code-scroll.json) + +![390px 代码块内部横向滚动](after-code-scroll-390.png) + +`npm run build` 通过。以上为生产网页验证,未作为原生打包窗口验证。 diff --git a/docs/images/message-tooltips/after-code-scroll-390.png b/docs/images/message-tooltips/after-code-scroll-390.png new file mode 100644 index 00000000..8b31decb Binary files /dev/null and b/docs/images/message-tooltips/after-code-scroll-390.png differ diff --git a/docs/images/message-tooltips/after-en-US-390-focus.png b/docs/images/message-tooltips/after-en-US-390-focus.png new file mode 100644 index 00000000..31c533ae Binary files /dev/null and b/docs/images/message-tooltips/after-en-US-390-focus.png differ diff --git a/docs/images/message-tooltips/after-zh-CN-600-hover.png b/docs/images/message-tooltips/after-zh-CN-600-hover.png new file mode 100644 index 00000000..44316a6c Binary files /dev/null and b/docs/images/message-tooltips/after-zh-CN-600-hover.png differ diff --git a/docs/images/message-tooltips/before-en-US-390-focus.png b/docs/images/message-tooltips/before-en-US-390-focus.png new file mode 100644 index 00000000..f56cd7cf Binary files /dev/null and b/docs/images/message-tooltips/before-en-US-390-focus.png differ diff --git a/docs/images/message-tooltips/before-zh-CN-600-hover.png b/docs/images/message-tooltips/before-zh-CN-600-hover.png new file mode 100644 index 00000000..58e58ede Binary files /dev/null and b/docs/images/message-tooltips/before-zh-CN-600-hover.png differ diff --git a/docs/images/message-tooltips/code-scroll.json b/docs/images/message-tooltips/code-scroll.json new file mode 100644 index 00000000..184f1da6 --- /dev/null +++ b/docs/images/message-tooltips/code-scroll.json @@ -0,0 +1,34 @@ +[ + { + "width": 600, + "before": { + "clientWidth": 401, + "scrollWidth": 2570, + "scrollLeft": 400, + "overflowX": "auto" + }, + "after": { + "codeLeft": 1200, + "chatLeft": 0, + "chatWidth": 600, + "chatScrollWidth": 600, + "fullCodePreserved": true + } + }, + { + "width": 390, + "before": { + "clientWidth": 237, + "scrollWidth": 2570, + "scrollLeft": 1200, + "overflowX": "auto" + }, + "after": { + "codeLeft": 2000, + "chatLeft": 0, + "chatWidth": 390, + "chatScrollWidth": 390, + "fullCodePreserved": true + } + } +] diff --git a/docs/images/message-tooltips/measurements.csv b/docs/images/message-tooltips/measurements.csv new file mode 100644 index 00000000..a845a53e --- /dev/null +++ b/docs/images/message-tooltips/measurements.csv @@ -0,0 +1,73 @@ +"phase","locale","viewportWidth","state","buttonIndex","clientWidth","scrollWidth","minTooltipLeft","maxTooltipRight","measuredTooltip","tooltipLeft","tooltipRight","visibility","opacity" +"before","zh-CN","600","idle",,"600","621","20","620.515625","引用选中文字或整条消息","481.515625","620.515625","hidden","0" +"before","zh-CN","600","hover","0","600","621","20","620.515625","复制消息","417.515625","479.515625","visible","1" +"before","zh-CN","600","hover","1","600","621","20","620.515625","回填文字到输入框","449.515625","555.515625","visible","1" +"before","zh-CN","600","hover","2","600","621","20","620.515625","引用选中文字或整条消息","481.515625","620.515625","visible","1" +"before","zh-CN","600","hover","3","600","621","20","620.515625","分支到新聊天","513.515625","597.515625","visible","1" +"before","zh-CN","600","focus","0","600","621","20","620.515625","复制消息","417.515625","479.515625","visible","1" +"before","zh-CN","600","focus","1","600","621","20","620.515625","回填文字到输入框","449.515625","555.515625","visible","1" +"before","zh-CN","600","focus","2","600","621","20","620.515625","引用选中文字或整条消息","481.515625","620.515625","visible","1" +"before","zh-CN","600","focus","3","600","621","20","620.515625","分支到新聊天","513.515625","597.515625","visible","1" +"before","zh-CN","390","idle",,"390","411","20","410.515625","引用选中文字或整条消息","271.515625","410.515625","hidden","0" +"before","zh-CN","390","hover","0","390","411","20","410.515625","复制消息","207.515625","269.515625","visible","1" +"before","zh-CN","390","hover","1","390","411","20","410.515625","回填文字到输入框","239.515625","345.515625","visible","1" +"before","zh-CN","390","hover","2","390","411","20","410.515625","引用选中文字或整条消息","271.515625","410.515625","visible","1" +"before","zh-CN","390","hover","3","390","411","20","410.515625","分支到新聊天","303.515625","387.515625","visible","1" +"before","zh-CN","390","focus","0","390","411","20","410.515625","复制消息","207.515625","269.515625","visible","1" +"before","zh-CN","390","focus","1","390","411","20","410.515625","回填文字到输入框","239.515625","345.515625","visible","1" +"before","zh-CN","390","focus","2","390","411","20","410.515625","引用选中文字或整条消息","271.515625","410.515625","visible","1" +"before","zh-CN","390","focus","3","390","411","20","410.515625","分支到新聊天","303.515625","387.515625","visible","1" +"before","en-US","600","idle",,"600","704","20","703.578125","Quote selected text or the whole message","481.515625","703.578125","hidden","0" +"before","en-US","600","hover","0","600","704","20","703.578125","Copy message","417.515625","505.96875","visible","1" +"before","en-US","600","hover","1","600","704","20","703.578125","Reuse text in composer","449.515625","580.515625","visible","1" +"before","en-US","600","hover","2","600","704","20","703.578125","Quote selected text or the whole message","481.515625","703.578125","visible","1" +"before","en-US","600","hover","3","600","704","20","703.578125","Branch into new chat","513.515625","633.4375","visible","1" +"before","en-US","600","focus","0","600","704","20","703.578125","Copy message","417.515625","505.96875","visible","1" +"before","en-US","600","focus","1","600","704","20","703.578125","Reuse text in composer","449.515625","580.515625","visible","1" +"before","en-US","600","focus","2","600","704","20","703.578125","Quote selected text or the whole message","481.515625","703.578125","visible","1" +"before","en-US","600","focus","3","600","704","20","703.578125","Branch into new chat","513.515625","633.4375","visible","1" +"before","en-US","390","idle",,"390","494","20","493.578125","Quote selected text or the whole message","271.515625","493.578125","hidden","0" +"before","en-US","390","hover","0","390","494","20","493.578125","Copy message","207.515625","295.96875","visible","1" +"before","en-US","390","hover","1","390","494","20","493.578125","Reuse text in composer","239.515625","370.515625","visible","1" +"before","en-US","390","hover","2","390","494","20","493.578125","Quote selected text or the whole message","271.515625","493.578125","visible","1" +"before","en-US","390","hover","3","390","494","20","493.578125","Branch into new chat","303.515625","423.4375","visible","1" +"before","en-US","390","focus","0","390","494","20","493.578125","Copy message","207.515625","295.96875","visible","1" +"before","en-US","390","focus","1","390","494","20","493.578125","Reuse text in composer","239.515625","370.515625","visible","1" +"before","en-US","390","focus","2","390","494","20","493.578125","Quote selected text or the whole message","271.515625","493.578125","visible","1" +"before","en-US","390","focus","3","390","494","20","493.578125","Branch into new chat","303.515625","423.4375","visible","1" +"after","zh-CN","600","idle",,"600","600","20","541.515625","引用选中文字或整条消息","370.515625","509.515625","hidden","0" +"after","zh-CN","600","hover","0","600","600","20","541.515625","复制消息","383.515625","445.515625","visible","1" +"after","zh-CN","600","hover","1","600","600","20","541.515625","回填文字到输入框","371.515625","477.515625","visible","1" +"after","zh-CN","600","hover","2","600","600","20","541.515625","引用选中文字或整条消息","370.515625","509.515625","visible","1" +"after","zh-CN","600","hover","3","600","600","20","541.515625","分支到新聊天","457.515625","541.515625","visible","1" +"after","zh-CN","600","focus","0","600","600","20","541.515625","复制消息","383.515625","445.515625","visible","1" +"after","zh-CN","600","focus","1","600","600","20","541.515625","回填文字到输入框","371.515625","477.515625","visible","1" +"after","zh-CN","600","focus","2","600","600","20","541.515625","引用选中文字或整条消息","370.515625","509.515625","visible","1" +"after","zh-CN","600","focus","3","600","600","20","541.515625","分支到新聊天","457.515625","541.515625","visible","1" +"after","zh-CN","390","idle",,"390","390","20","331.515625","引用选中文字或整条消息","160.515625","299.515625","hidden","0" +"after","zh-CN","390","hover","0","390","390","20","331.515625","复制消息","173.515625","235.515625","visible","1" +"after","zh-CN","390","hover","1","390","390","20","331.515625","回填文字到输入框","161.515625","267.515625","visible","1" +"after","zh-CN","390","hover","2","390","390","20","331.515625","引用选中文字或整条消息","160.515625","299.515625","visible","1" +"after","zh-CN","390","hover","3","390","390","20","331.515625","分支到新聊天","247.515625","331.515625","visible","1" +"after","zh-CN","390","focus","0","390","390","20","331.515625","复制消息","173.515625","235.515625","visible","1" +"after","zh-CN","390","focus","1","390","390","20","331.515625","回填文字到输入框","161.515625","267.515625","visible","1" +"after","zh-CN","390","focus","2","390","390","20","331.515625","引用选中文字或整条消息","160.515625","299.515625","visible","1" +"after","zh-CN","390","focus","3","390","390","20","331.515625","分支到新聊天","247.515625","331.515625","visible","1" +"after","en-US","600","idle",,"600","600","20","541.515625","Quote selected text or the whole message","287.453125","509.515625","hidden","0" +"after","en-US","600","hover","0","600","600","20","541.515625","Copy message","357.0625","445.515625","visible","1" +"after","en-US","600","hover","1","600","600","20","541.515625","Reuse text in composer","346.515625","477.515625","visible","1" +"after","en-US","600","hover","2","600","600","20","541.515625","Quote selected text or the whole message","287.453125","509.515625","visible","1" +"after","en-US","600","hover","3","600","600","20","541.515625","Branch into new chat","421.59375","541.515625","visible","1" +"after","en-US","600","focus","0","600","600","20","541.515625","Copy message","357.0625","445.515625","visible","1" +"after","en-US","600","focus","1","600","600","20","541.515625","Reuse text in composer","346.515625","477.515625","visible","1" +"after","en-US","600","focus","2","600","600","20","541.515625","Quote selected text or the whole message","287.453125","509.515625","visible","1" +"after","en-US","600","focus","3","600","600","20","541.515625","Branch into new chat","421.59375","541.515625","visible","1" +"after","en-US","390","idle",,"390","390","20","331.515625","Quote selected text or the whole message","77.453125","299.515625","hidden","0" +"after","en-US","390","hover","0","390","390","20","331.515625","Copy message","147.0625","235.515625","visible","1" +"after","en-US","390","hover","1","390","390","20","331.515625","Reuse text in composer","136.515625","267.515625","visible","1" +"after","en-US","390","hover","2","390","390","20","331.515625","Quote selected text or the whole message","77.453125","299.515625","visible","1" +"after","en-US","390","hover","3","390","390","20","331.515625","Branch into new chat","211.59375","331.515625","visible","1" +"after","en-US","390","focus","0","390","390","20","331.515625","Copy message","147.0625","235.515625","visible","1" +"after","en-US","390","focus","1","390","390","20","331.515625","Reuse text in composer","136.515625","267.515625","visible","1" +"after","en-US","390","focus","2","390","390","20","331.515625","Quote selected text or the whole message","77.453125","299.515625","visible","1" +"after","en-US","390","focus","3","390","390","20","331.515625","Branch into new chat","211.59375","331.515625","visible","1" 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 ?