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 中文,鼠标悬停“引用消息”:
+
+| 修改前 | 修改后 |
+| --- | --- |
+|  |  |
+
+390px 英文,使用 Tab 聚焦“Quote message”:
+
+| 修改前 | 修改后 |
+| --- | --- |
+|  |  |
+
+## 尺寸与滚动检查
+
+以下各组都检查了初始隐藏状态,以及四个用户操作按钮逐一悬停、逐一键盘聚焦。修改后所有消息提示均位于聊天容器左右边界内。
+
+| 语言 / 视口宽度 | 聊天 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)
+
+
+
+`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 ? : }
{branchLabel}
+
{branchError && {branchError}
}>;
}
@@ -244,7 +262,7 @@ const NarrativeRow = memo(function NarrativeRow({ client, entry, threadID, onOpe
{entry.deliveryMode === "steer" && entry.status !== "cancelled" && entry.status !== "pending" &&
{t("已加入当前任务", "Added to current task")}}
{entry.provisional && !entry.status && {t("正在发送…", "Sending…")}}
- {!entry.provisional && entry.status !== "pending" && {
const saved = await client.get(`/threads/${encodeURIComponent(threadID)}/messages/${encodeURIComponent(entry.id)}`);
if (saved.message_id !== entry.id || typeof saved.content !== "string") throw new Error(t("完整消息身份不匹配,请重新读取。", "The saved message identity did not match. Reload and try again."));
@@ -256,7 +274,7 @@ const NarrativeRow = memo(function NarrativeRow({ client, entry, threadID, onOpe
{!entry.provisional && entry.changeSourceID && entry.runId && typeof client.threadChanges === "function" &&
}
- {!entry.provisional && }
+ {!entry.provisional && }
;
if (entry.kind === "activity") return
;
diff --git a/web/src/v2/styles.css b/web/src/v2/styles.css
index d23a39db..f78e6504 100644
--- a/web/src/v2/styles.css
+++ b/web/src/v2/styles.css
@@ -350,6 +350,14 @@
margin-top: 8px;
}
+.v2-message-time {
+ color: var(--v2-muted);
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ margin-inline-start: 6px;
+ white-space: nowrap;
+}
+
.v2-shell .v2-assistant-action-btn {
align-items: center;
background: transparent;
@@ -395,6 +403,7 @@
transition: opacity 140ms var(--prayu-ease-out), transform 140ms var(--prayu-ease-out), visibility 140ms;
visibility: hidden;
}
+.v2-user-actions .v2-assistant-action-tooltip { left: auto; right: 0; }
.v2-assistant-action-btn:focus-visible > .v2-assistant-action-tooltip { opacity: 1; transform: translateY(0); visibility: visible; }
@media (hover: hover) {
.v2-assistant-action-btn:hover > .v2-assistant-action-tooltip { opacity: 1; transform: translateY(0); visibility: visible; }