Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/images/message-timestamps/README.md
Original file line number Diff line number Diff line change
@@ -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 聚焦测试及生产构建通过;测试覆盖缺失、无效时间和正在生成的回复。此次没有原生桌面打包验证。
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/message-timestamps/after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/message-timestamps/before-narrow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/message-timestamps/before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ Ctrl+Shift+V(macOS 为 Cmd+Shift+V)按纯文本粘贴,保留 Markdown 标
围栏块中;块顶部显示“文本”和复制按钮,复制内容不包含围栏。代码围栏显示语言名称。
普通段落保留整条回复的复制入口。

已保存的用户消息和助手回复在操作图标旁显示本地时间。悬停时间可查看完整日期、秒和时区;
屏幕阅读器可读取同样的信息。仍在发送或生成的消息、没有有效原始时间的历史记录不显示时间。

聊天正文使用 14 px 字号、1.625 倍行高和正常字距,代码块保持 13 px。
已完成的段落采用智能换行,标题均衡换行,长标识符在空间不足时折行。
实时回复的新片段以 150 ms 的轻微模糊淡入显示,不额外等待或延迟输出;历史消息和
Expand Down
21 changes: 21 additions & 0 deletions web/src/v2/components/narrative.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<V2Narrative client={{} as APIClient} entries={entries} threadID="thread" />);
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);
Expand Down
24 changes: 21 additions & 3 deletions web/src/v2/components/narrative.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,25 @@ function MarkdownCodeBlock({ children, ...props }: React.HTMLAttributes<HTMLPreE

type BranchReply = (sourceEventID: string, operationKey: string) => Promise<void>;

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 <time className="v2-message-time" dateTime={date.toISOString()} title={full}
aria-label={t("消息时间:", "Message time: ") + full}>{formats.short.format(date)}</time>;
}

function AssistantMessageActions({ text, createdAt, branchSourceID, onBranch, user = false, onCompose, loadText }: {
text: string;
createdAt: string;
branchSourceID?: string;
onBranch?: BranchReply;
user?: boolean;
Expand Down Expand Up @@ -177,6 +194,7 @@ function AssistantMessageActions({ text, branchSourceID, onBranch, user = false,
{branching ? <LoaderCircle size={16} className="spin" aria-hidden="true" /> : <Split size={16} aria-hidden="true" />}
<span className="v2-assistant-action-tooltip" aria-hidden="true">{branchLabel}</span>
</button>
<MessageTime createdAt={createdAt} />
</div>{branchError && <p className="v2-assistant-action-error" role="alert">{branchError}</p>}</>;
}

Expand Down Expand Up @@ -244,7 +262,7 @@ const NarrativeRow = memo(function NarrativeRow({ client, entry, threadID, onOpe
{entry.deliveryMode === "steer" && entry.status !== "cancelled" && entry.status !== "pending" &&
<small className="v2-message-status">{t("已加入当前任务", "Added to current task")}</small>}
{entry.provisional && !entry.status && <small className="v2-message-status">{t("正在发送…", "Sending…")}</small>}</div>
{!entry.provisional && entry.status !== "pending" && <AssistantMessageActions user text={entry.text}
{!entry.provisional && entry.status !== "pending" && <AssistantMessageActions user text={entry.text} createdAt={entry.createdAt}
branchSourceID={entry.branchSourceID} onBranch={onBranch} onCompose={onCompose} loadText={async () => {
const saved = await client.get<ThreadSavedMessageView>(`/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."));
Expand All @@ -256,7 +274,7 @@ const NarrativeRow = memo(function NarrativeRow({ client, entry, threadID, onOpe
<MessageMarkdown entry={entry} onOpenFile={onOpenFile} />
{!entry.provisional && entry.changeSourceID && entry.runId && typeof client.threadChanges === "function" &&
<V2TurnChanges client={client} threadID={threadID} runID={entry.runId} sourceEventID={entry.changeSourceID} />}
{!entry.provisional && <AssistantMessageActions text={entry.text} branchSourceID={entry.branchSourceID} onBranch={onBranch} onCompose={onCompose} />}
{!entry.provisional && <AssistantMessageActions text={entry.text} createdAt={entry.createdAt} branchSourceID={entry.branchSourceID} onBranch={onBranch} onCompose={onCompose} />}
</li>;
if (entry.kind === "activity") return <li className="v2-activity-turn">
<V2ActivityGroup client={client} entry={entry} threadID={threadID} /></li>;
Expand Down
8 changes: 8 additions & 0 deletions web/src/v2/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading