diff --git a/CHANGELOG.md b/CHANGELOG.md index e826fd20..fd261666 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-15 — 打开以前的生时校正不再改成今天、也不再跳到最前 + +点开一条以前的校正,标题和在列表里的位置保持原样。已经被改成「今天」的历史标题,按创建当天的日期修回;自己改过名字的不动。Skill 版本不变。 + ## 2026-09-15 — 手机上回答按钮更好点;平板宽度不再串台 手机上复制 / 点赞 / 点踩 / 重新生成的可点区域是 44×44,图标看起来还是原来那么大。平板按 1024 切,不再在 901–1023 之间侧栏窄、内容却按桌面排。报告中心和阅读页跟全局移动断点对齐。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 42f08066..dbad578b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8553,15 +8553,15 @@ - 状态:resolved - 首次发现:2026-09-06 -- 最近更新:2026-09-06 +- 最近更新:2026-09-15 - 影响面:侧栏历史列表、`GET /api/sessions`、`PATCH /api/sessions/[id]`、`use-session-management` - 用户现象:刚聊过的会话不在最上面;改名、收藏、换模型或换资料后刷新,这条会话反而排到最顶。 - 触发条件:登录后打开侧栏历史;对已有会话做元数据 PATCH,或不发新消息就刷新。 - 根因:`visibleSessions` 只按 `pinned` 排,开着页面时 `updatedAt` 变化不改位置。元数据 PATCH 和服务端 `metadataUpdateValues` 一律写 `updated_at = now()`,非对话操作也会把会话顶到列表头。真正该 bump 的是发问 / 回答落库。 - 修复:`sortSessions` 置顶优先、组内 `updatedAt` 倒序且稳定。元数据 PATCH 不再写 `updated_at`;客户端改名 / 收藏 / 归档 / 换模型 / 换资料不再 `timestamp()`。列表改为游标分页,历史按本地日期分组,侧栏标题去掉资料名前缀。 - 验证:`frontend/tests/session-groups.test.ts`、`session-cursor.test.ts`、`chat-session-write.test.ts`(`metadataUpdateValues` 不含 `updated_at`)、`sidebar-state.test.ts`(改名不 bump `updatedAt`)、`sidebar-contract.test.ts`、`chart-library-session.test.ts`。 -- 防复发:元数据 PATCH 不得写 `updated_at`。`updatedAt` 只由对话活动推进。侧栏历史排序必须置顶 + `updatedAt` 倒序,不得只按置顶。 -- 相关记录:BUG-024 +- 防复发:元数据 PATCH 不得写 `updated_at`。`updatedAt` 只由对话活动推进。侧栏历史排序必须置顶 + `updatedAt` 倒序,不得只按置顶。打开已有会话不得改 `title` / `updatedAt`,由 BUG-699 与 `frontend/tests/session-open-preserves-identity.test.ts` 执行。 +- 相关记录:BUG-024、BUG-699 - 复发自:无 - 修复版本:`a1956deb` @@ -10853,3 +10853,19 @@ - 相关记录:BUG-696 - 复发自:无 - 修复版本:待发布 + +## BUG-699 | 打开历史校正会话会改成今天的标题并顶到列表最前 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:侧栏历史列表、`openRectificationCase`、`persistSession` 的 update 标题字段 +- 用户现象:点开一条以前的生时校正,标题变成今天的日期,并出现在列表最上面。刷新后位置会回去,标题不会。 +- 触发条件:点侧栏里已有的校正会话,或从首页校正卡打开一条已存在的可续校正。 +- 根因:复发自 BUG-553。`openRectificationCase` 构造 `merged` 时 `pinned` / `archivedAt` 从 `existing` 继承,`title` 和 `updatedAt` 却无条件用墙钟重算,再 `persistSession` 把标题写回服务端。`persistSession` 的 update 不写 `updated_at`(BUG-553 的修复仍在),所以排序只是客户端暂时错位。BUG-553 的防复发当时只是一句话,测试只锁了元数据 PATCH 和改名/收藏/归档/换模型/换资料,没有覆盖校正 open。 +- 修复:已有会话的 `title` / `updatedAt` 继承 `existing`,只有新建才 `resolveSessionTitle` + `timestamp()`。错日期标题按 `created_at` 的 Asia/Shanghai 月日修回;对不上正则的手改标题不动。 +- 验证:`frontend/tests/session-open-preserves-identity.test.ts`、`rectification-session-title-repair.test.ts`。浏览器级验收见 `docs/testing/rectification-open-identity-20260915.md`。staging 标题修回行数见本单 PROGRESS。 +- 防复发:构造可能作用于已有会话的 `ChatSession` 时,不得无条件写 `updatedAt: timestamp()`。`resolveSessionTitle` 的 `at` 默认墙钟,只能用于新建。 +- 相关记录:BUG-553 +- 复发自:BUG-553 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-open-retitles-session-20260915.md b/docs/tasks/PROGRESS-rectification-open-retitles-session-20260915.md new file mode 100644 index 00000000..fe7d9e2e --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-open-retitles-session-20260915.md @@ -0,0 +1,46 @@ +# PROGRESS · 打开历史校正不再改名(BUG-699,2026-09-15) + +- 执行分支:`codex/rectification-open-retitles-session-20260915` +- 工作树:`.worktrees/rectification-open-retitles-session-20260915` +- 基线:`origin/staging` @ `224244c0`(任务书写 `ff5023a2`;其后一笔是本任务书) + +## 做了什么 + +任务 1:`openRectificationCase` 用 `rectificationOpenIdentity(existing, minted)`。已有会话的 `title` / `updatedAt` 全等于 `existing`;新建才 `resolveSessionTitle` + `timestamp()`。`pinned` / `archivedAt` 仍从 `existing` 继承。 + +任务 1.3 `messages: []`:**无害,保持原样。** `persistSession` 的 update 分支不写 `messages`,服务端对话不受影响。校正面从 turns 渲染。 + +任务 2.1:`session-open-preserves-identity.test.ts` 断言 existing 命中时 identity 全等、未命中才用 minted,并锁 hook 里 `merged` 只用 `openIdentity`。 + +任务 2.2:同一文件扫 `frontend/src/hooks/` 与 `frontend/src/lib/` 里带 `id` + `sessionType`/`messages` 的对象字面量,禁止无条件 `updatedAt: timestamp()`(`randomUUID` 新建除外)。把 1.1 改回 `updatedAt: timestamp()` 后这两条变红,已改回,未提交破坏。 + +任务 2.3:`SessionTitleOptions.at` 与 `resolveSessionTitle` 默认 `new Date()` 处加了注释:只能用于新建。 + +任务 3:修回逻辑在 `rectification-session-title-repair.ts`,运维脚本 `frontend/scripts/repair-rectification-session-titles.mjs`(默认只读 `--check`,`--apply` 才写)。幂等,只动匹配正则且月日与 `created_at` 的 Asia/Shanghai 不一致的行。本机没有 `SCHEMA_DATABASE_URL`,**staging 未跑**,损坏面数字待产品在 staging 执行后回填。 + +## 测试数字 + +| | `test(` 声明(`git grep -E '^test\(' -- frontend/tests`) | +| --- | ---: | +| 基线 `224244c0` | 3213 | +| 改后 | 3221(+4 identity,+4 title-repair) | + +定向:`session-open-preserves-identity` 4 pass;`rectification-session-title-repair` 4 pass。破坏性复核:把 `merged.updatedAt` 改回 `timestamp()` 后 identity 源码合同与跨文件时钟合同变红。 + +`tsc --noEmit` 0 错。`npm run lint` **0 error** / 120 warning(既有,未动)。 + +本机 Windows 全量 TAP 仍受 CRLF / Skill 软链 / Docker 影响,不能当作 Linux 27 的证据。 + +## 任务 1.3 + +`persistSession` update 字段是 title / theme / model_id / chart_profile_*,不含 messages,也不含 `updated_at`。结论:清空本地 `messages` 再 hydrate turns 对服务端无害,不改。 + +## 生产 `7b620c7a` + +该 SHA 没有 `use-rectification-surface.ts`。当时 `page.tsx` 的 `merged` 用固定标题「生时校正」,**没有**墙钟日期,也**没有** `persistSession(merged)`。生产不受「标题写成今天并写库」影响。`updatedAt: timestamp()` 当时只动客户端,刷新可恢复。任务 3 **不必在生产跑**。 + +## 偏离 + +- 任务 3 staging 核对 / 修补因本机没有库连接而未跑。脚本已落地。产品在 staging 跑:`node frontend/scripts/repair-rectification-session-titles.mjs` 看行数,确认后加 `--apply`。 +- CHANGELOG 写的是修回规则已交付;实际写库要等 staging 脚本跑完。 +- 浏览器项见 `docs/testing/rectification-open-identity-20260915.md`,不得写成通过。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 0520a5bf..7853c91a 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -120,7 +120,7 @@ | `TASK-chat-reading-load-20260915.md` | `PROGRESS-chat-reading-load-20260915.md` | 首页主题卡下线 + 回答两层可见性 + 折叠层宽表可横滑 + 输入框 16px。内容一字不删。⚠️ BUG-630 走查路径失效 | 已验收(2 条契约测试未通过,见修复单) | `8144fca2` | | `TASK-mobile-touch-and-breakpoints-20260915.md` | `PROGRESS-mobile-touch-and-breakpoints-20260915.md` | 消息操作按钮触屏命中区 27×34、相邻仅隔 1px(BUG-695);CSS 平板上限 900px 与 `sidebarViewportForWidth` 的 1024 不一致,901–1023 是混合态(BUG-696);报告域 720/760/860 三个断点互不对齐,761–860 目录已塌、正文还是桌面(BUG-697)。含断点白名单契约测试。**串行在 chat-reading-load 之后** | 已验收 | `6c748d86`(BUG-695~697) | | `TASK-chat-reading-load-fix-20260915.md` | `PROGRESS-chat-reading-load-fix-20260915.md` | **验收修复单**:`chat-bundle-splitting-contract`(`message-markdown` 包裹数 1→2、`plainParagraphs(spoken)` 字面量已不存在)与 `chat-navigation-a11y-contract`(`window.location.assign("/login")` 4→3,建议问题入口删除带走一处)两条既有断言被本轮打红且未更新;Linux 全量 27→29。只改测试不改产品代码 | 待验收 | `codex/chat-reading-load-fix-20260915` | -| `TASK-rectification-open-retitles-session-20260915.md` | — | **P0 复发单**:点侧栏历史校正会话,`openRectificationCase` 无条件用墙钟重算 `title` 并把 `updatedAt` 盖成此刻(同一处 `pinned`/`archivedAt` 却从 `existing` 继承了),`persistSession` 把改名写回服务端 → 12 号的会话被改成「9月15日 · 生时校正」并顶到列表最前。标题**永久损坏**、排序刷新可恢复。复发自 BUG-553(其防复发只是一句话,无测试执行,且没覆盖校正 open 路径)。含历史标题按 `created_at` 修回(BUG-699) | 待领取 | `codex/rectification-open-retitles-session-20260915` | +| `TASK-rectification-open-retitles-session-20260915.md` | `PROGRESS-rectification-open-retitles-session-20260915.md` | **P0 复发单**:点侧栏历史校正会话,`openRectificationCase` 无条件用墙钟重算 `title` 并把 `updatedAt` 盖成此刻(同一处 `pinned`/`archivedAt` 却从 `existing` 继承了),`persistSession` 把改名写回服务端 → 12 号的会话被改成「9月15日 · 生时校正」并顶到列表最前。标题**永久损坏**、排序刷新可恢复。复发自 BUG-553(其防复发只是一句话,无测试执行,且没覆盖校正 open 路径)。含历史标题按 `created_at` 修回(BUG-699) | 待验收 | `codex/rectification-open-retitles-session-20260915` | | `TASK-settings-dialog-size-and-nav-20260915.md` | — | **复发单**:设置弹窗四个分区尺寸仍随内容跳变(BUG-698,复发自 BUG-554——旧防复发只查「有没有写 height」,查不到「写了没生效」);首要嫌疑是 `.settings-modal` 的 `dvh` 没有 `vh` 回退,不支持时整条 `height` 作废退化成内容高度,需先复现确认。另按产品要求去掉分区菜单左侧强调条,并拆开与悬停共用的选中态 | 待领取 | `codex/settings-dialog-size-and-nav-20260915` | ### 个人报告 diff --git a/docs/testing/rectification-open-identity-20260915.md b/docs/testing/rectification-open-identity-20260915.md new file mode 100644 index 00000000..d00bcdf2 --- /dev/null +++ b/docs/testing/rectification-open-identity-20260915.md @@ -0,0 +1,34 @@ +# 真机 / 浏览器清单 · 打开历史校正不得改名(2026-09-15) + +代码级合同在 `frontend/tests/session-open-preserves-identity.test.ts`。下面必须用已登录的 staging 看。截图不要带真实姓名、出生资料或会话正文。 + +地址:`https://staging.jyotisha.chat`。 + +## A. 打开三条不同日期的历史校正 + +侧栏里找三条**标题日期不同**的生时校正(例如 12 日、13 日、14 日)。没有三条就用现有的,记下打开前的标题、所在分组、上下邻居。 + +对每一条: + +1. 点开。 +2. **看:** 标题一个字都没变。 +3. **看:** 仍在原来的分组(今天 / 昨天 / 最近 7 天…),没有跳到最上面。 +4. **看:** 上下邻居还是打开前那两条。 +5. 刷新页面。**看:** 标题、分组、位置仍与打开前一致。 + +## B. 新建一条 + +1. 从首页「开始新的生时校正」开一条新的。 +2. **看:** 标题是「{今天} · 生时校正」,出现在「今天」分组最上面。 + +## C. 首页校正卡打开已有可续校正 + +若首页校正卡会回到一条**已经存在**的校正: + +1. 先记下那条在侧栏里的标题和位置。 +2. 点首页校正卡。 +3. **看:** 标题和位置都不变,与 A 相同。 + +## 结论 + +> 待填。测完写日期 + 设备 + 浏览器 + 逐条正常/有问题。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 1d34c3bf..19e1aed0 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -293,7 +293,7 @@ The birth-time rectification session is the consultation transcript plus a house - **Structure:** title, optional subtitle for another person's chart, current-state marker. - **States:** default, hover, current, focus, disabled. -- **Hierarchy:** section labels stay tertiary; session titles and primary actions use ink so history rows do not collapse into the same gray as “收藏对话 / 历史对话”. History groups use the overline token for “今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早”. When the chart is not the account holder, a secondary line shows the chart name under the title. +- **Hierarchy:** section labels stay tertiary; session titles and primary actions use ink so history rows do not collapse into the same gray as “收藏对话 / 历史对话”. History groups use the overline token for “今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早”. When the chart is not the account holder, a secondary line shows the chart name under the title. A session title is named when it is created; opening a stored session must not rename it. `updatedAt` advances only on conversation activity. Locked by `frontend/tests/session-open-preserves-identity.test.ts`. - **Request behavior:** existing sessions remain selectable for reading while a request is active; creating or sending another request stays locked until the active request settles. - **Surface:** translucent warm-gray sidebar; current uses a white glass surface and deep-brown marker. @@ -370,7 +370,7 @@ Text release is paced, not animated: the frame buffer commits at most once per a ### Product entrypoint card - **Structure:** the homepage daily-reading and birth-time cards are single native-button targets stretched across their article surface. Content remains semantic card copy. On two-column viewports a compact action label and arrow sit at the trailing edge; on a stacked homepage they sit under the supporting line, leading-aligned, so a short label like “深入看今日” does not float to the opposite corner from “开始新的生时校正”. -- **Copy:** chat history titles daily readings as “8月22日 · 今日节奏”, typed natal questions as “事业 · …”, and rectification as “生时校正 · 8月21日”. Clicking the daily card starts a consultation session immediately; it does not place the question in the composer. Private model instructions are selected by a closed entrypoint identifier and expanded only on the server. The homepage no longer shows a “从一个主题开始” grid; leftover `guided_topic` values in stored sessions still pin domains when replayed. +- **Copy:** chat history titles daily readings as “8月22日 · 今日节奏”, typed natal questions as “事业 · …”, and rectification as “8月21日 · 生时校正”. Clicking the daily card starts a consultation session immediately; it does not place the question in the composer. Private model instructions are selected by a closed entrypoint identifier and expanded only on the server. The homepage no longer shows a “从一个主题开始” grid; leftover `guided_topic` values in stored sessions still pin domains when replayed. - **States:** default, whole-card hover, pressed, focus-visible, and disabled. The card surface—not an inner promotional button—carries the interaction feedback. - **Responsive:** cards stack below 900px without introducing a large nested button. Stacked cards hug their copy instead of stretching to a min-height; the footer stacks supporting copy above a leading-aligned one-line action. - **Accessibility:** each card exposes exactly one native button with a descriptive accessible name, preserves a visible focus ring, and meets the full-card touch target. diff --git a/frontend/scripts/repair-rectification-session-titles.mjs b/frontend/scripts/repair-rectification-session-titles.mjs new file mode 100644 index 00000000..436c3c88 --- /dev/null +++ b/frontend/scripts/repair-rectification-session-titles.mjs @@ -0,0 +1,63 @@ +import pg from "pg"; + +const { Client } = pg; + +const MATCH_SQL = ` +session_type = 'birth_time_rectification' +and title ~ '^[0-9]{1,2}月[0-9]{1,2}日[[:space:]]*·[[:space:]]*生时校正([[:space:]]+[0-9]{2}:[0-9]{2})?$' +and ( + created_at is null + or (substring(title from '^([0-9]{1,2})月'))::int + is distinct from extract(month from created_at at time zone 'Asia/Shanghai')::int + or (substring(title from '月([0-9]{1,2})日'))::int + is distinct from extract(day from created_at at time zone 'Asia/Shanghai')::int +) +`; + +const COUNT_SQL = `select count(*)::int as n from public.chat_sessions where ${MATCH_SQL}`; + +const APPLY_SQL = ` +update public.chat_sessions +set title = case + when created_at is null then '生时校正' + else to_char(created_at at time zone 'Asia/Shanghai', 'FMMM') + || '月' + || to_char(created_at at time zone 'Asia/Shanghai', 'FMDD') + || '日 · 生时校正' +end +where ${MATCH_SQL} +`; + +function connectionString() { + const value = process.env.SCHEMA_DATABASE_URL?.trim(); + if (!value) { + throw new Error("SCHEMA_DATABASE_URL is required"); + } + if (!/^postgres(ql)?:\/\//.test(value)) { + throw new Error("SCHEMA_DATABASE_URL must be a PostgreSQL URL"); + } + return value; +} + +async function main() { + const apply = process.argv.includes("--apply"); + const client = new Client({ connectionString: connectionString() }); + await client.connect(); + try { + const before = await client.query(COUNT_SQL); + const n = before.rows[0]?.n ?? 0; + console.log(`mismatched_dated_rectification_titles=${n}`); + if (!apply) return; + const updated = await client.query(APPLY_SQL); + const after = await client.query(COUNT_SQL); + console.log(`updated_rows=${updated.rowCount ?? 0}`); + console.log(`mismatched_after=${after.rows[0]?.n ?? 0}`); + } finally { + await client.end(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts index 331673e4..9ebc0e6a 100644 --- a/frontend/src/hooks/use-rectification-surface.ts +++ b/frontend/src/hooks/use-rectification-surface.ts @@ -3,6 +3,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import { resolveSessionTitle } from "@/lib/agent-reply"; +import { rectificationOpenIdentity } from "@/lib/rectification-session-open"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { writeSessionUrl } from "@/lib/chat-session-url"; import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; @@ -213,16 +214,20 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { // Merge the server-created session into the local list. The browser // never generates a Case id; it only mirrors the returned binding. const existing = sessions.find((session) => session.id === opened.sessionId); - const merged: ChatSession = { - id: opened.sessionId, + const openIdentity = rectificationOpenIdentity(existing, { title: resolveSessionTitle("生时校正", undefined, { entrypoint: "birth_time_rectification", existingTitles: sessions.map((session) => session.title), }), + updatedAt: timestamp(), + }); + const merged: ChatSession = { + id: opened.sessionId, + title: openIdentity.title, theme: "general", modelId: modelCatalog.defaultModelId ?? "", messages: [], - updatedAt: timestamp(), + updatedAt: openIdentity.updatedAt, sessionType: "birth_time_rectification", rectificationCaseId: opened.caseId, pinned: existing?.pinned ?? false, diff --git a/frontend/src/lib/agent-reply.ts b/frontend/src/lib/agent-reply.ts index edec67ac..ce84c35f 100644 --- a/frontend/src/lib/agent-reply.ts +++ b/frontend/src/lib/agent-reply.ts @@ -57,6 +57,9 @@ export function parseAgentReply(value: string, metadata?: ConsultationReplyMetad export type SessionTitleOptions = { readonly entrypoint?: ConsultationEntrypoint | null; readonly theme?: ConsultationDomain; + /** Wall clock. Only for minting a brand-new session. Re-titling an existing + * session must pass that session's created time; the default `new Date()` + * would stamp today's date onto old chats (BUG-699). */ readonly at?: Date; readonly existingTitles?: readonly string[]; }; @@ -89,7 +92,7 @@ export function resolveSessionTitle( modelTitle?: string, options: SessionTitleOptions = {}, ): string { - const at = options.at ?? new Date(); + const at = options.at ?? new Date(); // new sessions only; see SessionTitleOptions.at const existingTitles = options.existingTitles ?? []; if (modelTitle && !isGenericSessionTitle(modelTitle)) { return uniquifySessionTitle(clipTitle(modelTitle), existingTitles, at); diff --git a/frontend/src/lib/rectification-session-open.ts b/frontend/src/lib/rectification-session-open.ts new file mode 100644 index 00000000..0598207e --- /dev/null +++ b/frontend/src/lib/rectification-session-open.ts @@ -0,0 +1,9 @@ +export function rectificationOpenIdentity( + existing: { title: string; updatedAt: number } | undefined, + minted: { title: string; updatedAt: number }, +): { title: string; updatedAt: number } { + if (existing) { + return { title: existing.title, updatedAt: existing.updatedAt }; + } + return minted; +} diff --git a/frontend/src/lib/rectification-session-title-repair.ts b/frontend/src/lib/rectification-session-title-repair.ts new file mode 100644 index 00000000..6a4bbab3 --- /dev/null +++ b/frontend/src/lib/rectification-session-title-repair.ts @@ -0,0 +1,43 @@ +/** Product UI dates are wall-clock in China. Repair uses this zone, never UTC. */ +export const RECTIFICATION_TITLE_TIMEZONE = "Asia/Shanghai"; + +export const RECTIFICATION_DATED_TITLE = + /^(\d{1,2})月(\d{1,2})日\s*·\s*生时校正(?:\s+\d{2}:\d{2})?$/; + +export function datedRectificationTitle( + createdAt: Date, + timeZone = RECTIFICATION_TITLE_TIMEZONE, +): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + month: "numeric", + day: "numeric", + }).formatToParts(createdAt); + const month = Number(parts.find((part) => part.type === "month")?.value); + const day = Number(parts.find((part) => part.type === "day")?.value); + if (!Number.isInteger(month) || !Number.isInteger(day)) { + return "生时校正"; + } + return `${month}月${day}日 · 生时校正`; +} + +export function repairedRectificationTitle(row: { + title: string; + createdAt: Date | null; +}): string | null { + const matched = row.title.match(RECTIFICATION_DATED_TITLE); + if (!matched) return null; + if (!row.createdAt || Number.isNaN(row.createdAt.getTime())) { + return "生时校正"; + } + const next = datedRectificationTitle(row.createdAt); + const nextMatch = next.match(RECTIFICATION_DATED_TITLE); + if ( + nextMatch + && Number(matched[1]) === Number(nextMatch[1]) + && Number(matched[2]) === Number(nextMatch[2]) + ) { + return null; + } + return next; +} diff --git a/frontend/tests/rectification-session-title-repair.test.ts b/frontend/tests/rectification-session-title-repair.test.ts new file mode 100644 index 00000000..52aabe24 --- /dev/null +++ b/frontend/tests/rectification-session-title-repair.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + datedRectificationTitle, + repairedRectificationTitle, +} from "../src/lib/rectification-session-title-repair.ts"; + +test("dated titles use the product calendar, not UTC", () => { + const createdAt = new Date("2026-09-11T18:30:00.000Z"); + assert.equal(datedRectificationTitle(createdAt), "9月12日 · 生时校正"); +}); + +test("repair rewrites a wall-clock today stamp back to created_at's local date", () => { + assert.equal( + repairedRectificationTitle({ + title: "9月15日 · 生时校正", + createdAt: new Date("2026-09-12T03:00:00.000Z"), + }), + "9月12日 · 生时校正", + ); + assert.equal( + repairedRectificationTitle({ + title: "9月15日 · 生时校正 14:32", + createdAt: new Date("2026-09-12T03:00:00.000Z"), + }), + "9月12日 · 生时校正", + ); +}); + +test("repair leaves matching dates and user-renamed titles alone", () => { + assert.equal( + repairedRectificationTitle({ + title: "9月12日 · 生时校正", + createdAt: new Date("2026-09-12T03:00:00.000Z"), + }), + null, + ); + assert.equal( + repairedRectificationTitle({ + title: "9月12日 · 生时校正 14:32", + createdAt: new Date("2026-09-12T03:00:00.000Z"), + }), + null, + ); + assert.equal( + repairedRectificationTitle({ + title: "家里那次校正", + createdAt: new Date("2026-09-12T03:00:00.000Z"), + }), + null, + ); +}); + +test("missing created_at falls back to a dateless title instead of guessing", () => { + assert.equal( + repairedRectificationTitle({ title: "9月15日 · 生时校正", createdAt: null }), + "生时校正", + ); +}); diff --git a/frontend/tests/session-open-preserves-identity.test.ts b/frontend/tests/session-open-preserves-identity.test.ts new file mode 100644 index 00000000..7a995f0a --- /dev/null +++ b/frontend/tests/session-open-preserves-identity.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { rectificationOpenIdentity } from "../src/lib/rectification-session-open.ts"; + +const surface = readFileSync( + new URL("../src/hooks/use-rectification-surface.ts", import.meta.url), + "utf8", +); +const srcRoot = fileURLToPath(new URL("../src", import.meta.url)); + +function owningObjectLiteral(source: string, index: number): string { + let depth = 0; + for (let i = index; i >= 0; i -= 1) { + const char = source[i]; + if (char === "}") depth += 1; + else if (char === "{") { + if (depth === 0) { + let open = 0; + for (let j = i; j < source.length; j += 1) { + if (source[j] === "{") open += 1; + else if (source[j] === "}") { + open -= 1; + if (open === 0) return source.slice(i, j + 1); + } + } + return source.slice(i); + } + depth -= 1; + } + } + return ""; +} + +function walkTypeScript(directory: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(directory)) { + const path = join(directory, entry); + const stat = statSync(path); + if (stat.isDirectory()) files.push(...walkTypeScript(path)); + else if (stat.isFile() && (extname(path) === ".ts" || extname(path) === ".tsx")) files.push(path); + } + return files; +} + +function unconditionalExistingSessionClocks(source: string): string[] { + const hits: string[] = []; + const needle = /updatedAt:\s*timestamp\(\)/g; + for (const match of source.matchAll(needle)) { + const index = match.index ?? 0; + const lineStart = source.lastIndexOf("\n", index) + 1; + const line = source.slice(lineStart, source.indexOf("\n", index)); + if (/\bexisting\?\.updatedAt\s*\?\?/.test(line)) continue; + const literal = owningObjectLiteral(source, index); + if (!/\bid\s*:/.test(literal)) continue; + if (!/\bsessionType\s*:/.test(literal) && !/\bmessages\s*:/.test(literal)) continue; + if (/randomUUID\s*\(/.test(literal)) continue; + if (/session-clock-exempt/.test(literal)) continue; + hits.push(literal.slice(0, 180)); + } + return hits; +} + +test("opening a stored rectification session keeps its title and updatedAt", () => { + const existing = { title: "9月12日 · 生时校正", updatedAt: 1_757_635_445_000 }; + const minted = { title: "9月15日 · 生时校正", updatedAt: 1_757_894_400_000 }; + assert.deepEqual(rectificationOpenIdentity(existing, minted), existing); +}); + +test("minting a new rectification session still uses the wall-clock title", () => { + const minted = { title: "9月15日 · 生时校正", updatedAt: 1_757_894_400_000 }; + assert.deepEqual(rectificationOpenIdentity(undefined, minted), minted); +}); + +test("openRectificationCase inherits identity from existing before minting", () => { + const open = surface.slice( + surface.indexOf("async function openRectificationCase"), + surface.indexOf("async function openRectificationFromHomepage"), + ); + const merged = open.slice(open.indexOf("const merged: ChatSession = {"), open.indexOf("setSessions((current)")); + assert.match(open, /rectificationOpenIdentity\(\s*existing,/); + assert.match(merged, /title: openIdentity\.title/); + assert.match(merged, /updatedAt: openIdentity\.updatedAt/); + assert.doesNotMatch(merged, /resolveSessionTitle/); + assert.doesNotMatch(merged, /updatedAt: timestamp\(\)/); + assert.match(open, /pinned: existing\?\.pinned \?\? false/); + assert.match(open, /void persistSession\(merged\)/); +}); + +test("ChatSession literals for an existing id cannot stamp updatedAt with the wall clock", () => { + const offenders: string[] = []; + for (const directory of ["hooks", "lib"]) { + for (const file of walkTypeScript(join(srcRoot, directory))) { + const source = readFileSync(file, "utf8"); + for (const hit of unconditionalExistingSessionClocks(source)) { + offenders.push(`${file}: ${hit.replace(/\s+/g, " ")}`); + } + } + } + assert.deepEqual(offenders, []); +});