From a94a1d6748b17811c67f3564e0fbbf9e4192a687 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Fri, 25 Sep 2026 02:04:56 +0800 Subject: [PATCH] fix(sessions): keep rectification result titles when a new chat opens The case insert trigger writes the result time, then the existing open persist sends the minted title back. A before-update guard keeps that result unless the user chose a non-automatic name, and a new session reads the stored title into the sidebar. --- docs/BUG_HISTORY.md | 4 +-- ...ification-session-title-result-20260922.md | 2 +- .../src/hooks/use-rectification-surface.ts | 12 +++++++ ...000_rectification_session_title_result.sql | 36 ++++++++++++++++++- ...rectification-session-title-result.test.ts | 20 +++++++++++ .../session-open-preserves-identity.test.ts | 1 + 6 files changed, 71 insertions(+), 4 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 24508e87..c5ff3e94 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13613,8 +13613,8 @@ - 用户现象:副标题已经是最后活动时间,标题仍印日期,同一天多条校正完全同名;旧标题里的钟点是创建时间,和副标题对不上。 - 触发条件:侧栏列出多条生时校正;或校正结果(已确认、已采用、当前范围)变化后标题仍是日期。 - 根因:复发自 BUG-929。BUG-988 把副标题改成最后活动时间之后,标题继续承载日期,同一行两列是同一个事实,而且日期不够区分同日多条。BUG-929 当时决定旧标题不批量改。 -- 修复:标题算式只有 `public.rectification_session_title`。已确认优先于已采用,写 `生时校正 · HH:MM`;否则起止都通过 `agentic_rectification_is_clock` 时写 `生时校正 · HH:MM–HH:MM`(U+2013);否则 `生时校正`。触发器 `agentic_rectification_cases_title_from_result` 只挂在 `accepted_time`、`confirmed_time`、`candidate_range`。回填与触发器共用 `rectification_session_title_is_automatic`,只覆盖自动派生标题。不写 `updated_at`,不改 pinned、messages。今日节奏仍带日期。open 路径未改。 -- 验证:`database-rectification-session-title-result.test.ts` 在本机真实 Postgres 通过:四分支、`provolatile = i`、采用分钟与区间收窄不改 `updated_at`、手改「我的校正」不被覆盖、touch 触发器仍推进活动时间、从迁移文件切出的回填改写前五条、第六条不动、六行 `updated_at` / `pinned` / `messages` 逐字节不变、重跑 0 行。跨层对断测试读迁移里的正则,与 `isAutoDerivedSessionTitle` 同组输入答案相同;把该正则的间隔号改成连字符后测试转红,已撤回。 +- 修复:标题算式只有 `public.rectification_session_title`。已确认优先于已采用,写 `生时校正 · HH:MM`;否则起止都通过 `agentic_rectification_is_clock` 时写 `生时校正 · HH:MM–HH:MM`(U+2013);否则 `生时校正`。触发器 `agentic_rectification_cases_title_from_result` 只挂在 `accepted_time`、`confirmed_time`、`candidate_range`。回填与触发器共用 `rectification_session_title_is_automatic`,只覆盖自动派生标题。不写 `updated_at`,不改 pinned、messages。今日节奏仍带日期。已有会话的 open 仍不改 title / updatedAt。新建会话打开后读取库里的结果标题;`keep_rectification_result_title` 阻止登录用户把自动标题写回去盖掉结果,手改的非自动标题仍可保存。 +- 验证:`database-rectification-session-title-result.test.ts` 在本机真实 Postgres 通过:四分支、`provolatile = i`、采用分钟与区间收窄不改 `updated_at`、手改「我的校正」不被覆盖、touch 触发器仍推进活动时间、从迁移文件切出的回填改写前五条、第六条不动、六行 `updated_at` / `pinned` / `messages` 逐字节不变、重跑 0 行。`authenticated` 把结果标题改回「生时校正」不生效,改成「孩子校正」可以,且 `updated_at` 不变。跨层对断测试读迁移里的正则,与 `isAutoDerivedSessionTitle` 同组输入答案相同;把该正则的间隔号改成连字符后测试转红,已撤回。 - 防复发:标题算式只能有一处实现,运行时与回填共用。侧栏同一行的两列不得承载同一个事实。 - 相关记录:BUG-929、BUG-699、BUG-704、BUG-988、BUG-987、BUG-992、BUG-995 - 复发自:BUG-929(标题口径两次返工;「旧标题不批量改」被本单推翻) diff --git a/docs/tasks/PROGRESS-rectification-session-title-result-20260922.md b/docs/tasks/PROGRESS-rectification-session-title-result-20260922.md index 2f680239..712fcf93 100644 --- a/docs/tasks/PROGRESS-rectification-session-title-result-20260922.md +++ b/docs/tasks/PROGRESS-rectification-session-title-result-20260922.md @@ -49,7 +49,7 @@ Windows 上 `frontend/db/migrations` 里 4 个 Git symlink 被检出成相对路 ## 未改 / 未验证 - 没有 push,没有改 workflow,没有动 `page.tsx`、咨询 status 路由、SVG height。 -- open 之后客户端仍会 `persistSession` 写本地 minted 标题「生时校正」。任务书要求 open 路径不动。插入触发器先写成范围标题,这次 PATCH 可能把它写回「生时校正」,直到下一次 `accepted_time` / `confirmed_time` / `candidate_range` 变化再由触发器写回。刷新后的列表读的是库里的标题;已加载列表再打开时,本地标题与库一致,不会改回去。 +- 新建会话的 open 仍先 `persistSession` 本地 minted 标题(BUG-699 的结构断言没动)。插入触发器已经写成范围标题。补了 `keep_rectification_result_title`:登录用户不能把自动标题写回去盖掉结果,手改的非自动标题仍可保存。新建会话随后 `fetchSessionDetail`,侧栏改读库里的结果标题,不改 `updatedAt`。已有会话的 open 仍保留本地 title / updatedAt。 - 连字符范围 `生时校正 · 05:00-05:15`:SQL 不覆盖。`isAutoDerivedSessionTitle` 仍可能为 true,因为 `isGenericSessionTitle` 的「生时校正」前缀比本单三种形态更宽。对断表没有把这一条算进「必须相同」的那一组。`DATED_ENTRY_TITLE` 不认连字符。 - 首屏 gzip 基线未测到。`npm run build` 在 junction 上 Turbopack panic;`next build --webpack` 编译过了,但页面收集被 Skill symlink EPERM 打断,没有路由表,不能宣称 `/` 仍是 Static。 - 浏览器走查未做。 diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts index 460f3b04..29afe6c0 100644 --- a/frontend/src/hooks/use-rectification-surface.ts +++ b/frontend/src/hooks/use-rectification-surface.ts @@ -3,6 +3,7 @@ import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; import { resolveSessionTitle } from "@/lib/agent-reply"; +import { fetchSessionDetail } from "@/lib/home-cloud-sync"; import { rectificationOpenIdentity } from "@/lib/rectification-session-open"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { writeSessionUrl } from "@/lib/chat-session-url"; @@ -292,6 +293,17 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { }; setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]); void persistSession(merged).catch(() => {}); + // New sessions have no local title to preserve (BUG-699). The case insert + // trigger already wrote the result title. Read that back so the sidebar + // does not keep the minted "生时校正" after persist. + if (!existing) { + void fetchSessionDetail(opened.sessionId, modelCatalog).then((serverSession) => { + if (!serverSession || serverSession.sessionType !== "birth_time_rectification") return; + updateSession(opened.sessionId, (session) => ( + session.title === serverSession.title ? session : { ...session, title: serverSession.title } + )); + }).catch(() => {}); + } // One reveal: turns and snapshot are read before the surface mounts, so // the reader never sees an empty transcript or an empty question area diff --git a/frontend/supabase/migrations/20260925010000_rectification_session_title_result.sql b/frontend/supabase/migrations/20260925010000_rectification_session_title_result.sql index dd7d3174..86b90262 100644 --- a/frontend/supabase/migrations/20260925010000_rectification_session_title_result.sql +++ b/frontend/supabase/migrations/20260925010000_rectification_session_title_result.sql @@ -81,7 +81,7 @@ revoke all on function public.sync_rectification_session_title_from_case() grant execute on function public.rectification_session_title(time, time, jsonb) to schema_owner; grant execute on function public.rectification_session_title_is_automatic(text) - to schema_owner; + to schema_owner, authenticated, service_role; grant execute on function public.sync_rectification_session_title_from_case() to schema_owner; @@ -94,6 +94,40 @@ on public.agentic_rectification_cases for each row execute function public.sync_rectification_session_title_from_case(); +-- Client PATCH runs as authenticated and would otherwise write the minted +-- title "生时校正" back over the result the insert trigger just stored. +-- This function stays security invoker so current_user is the updating role. +-- The result trigger is security definer, owned by schema_owner, so its +-- update still sees current_user = schema_owner and is allowed. +create or replace function public.keep_rectification_result_title() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + if current_user <> 'schema_owner' + and new.session_type = 'birth_time_rectification' + and public.rectification_session_title_is_automatic(new.title) + and new.title is distinct from old.title then + new.title := old.title; + end if; + return new; +end; +$$; + +revoke all on function public.keep_rectification_result_title() + from public, anon, authenticated; +grant execute on function public.keep_rectification_result_title() + to schema_owner; + +drop trigger if exists chat_sessions_keep_rectification_result_title + on public.chat_sessions; + +create trigger chat_sessions_keep_rectification_result_title +before update of title on public.chat_sessions +for each row +execute function public.keep_rectification_result_title(); + do $backfill$ declare retitled integer; diff --git a/frontend/tests/database-rectification-session-title-result.test.ts b/frontend/tests/database-rectification-session-title-result.test.ts index a0617dc9..5e0d5118 100644 --- a/frontend/tests/database-rectification-session-title-result.test.ts +++ b/frontend/tests/database-rectification-session-title-result.test.ts @@ -239,6 +239,26 @@ test("rectification session titles follow the result and do not bump updated_at" fixture.psql(`update public.agentic_rectification_cases set accepted_time = '05:07'::time where id = '${adoptCase}'`); assert.equal(titleHex(fixture, adoptSession), utf8Hex("生时校正 · 05:07")); assert.equal(identityFingerprint(fixture, adoptSession), beforeAdopt); + const clientRewrite = fixture.psqlAs( + "app_runtime", + "app-runtime-test-password", + `set role authenticated; + select set_config('request.jwt.claim.sub', '${user}', true); + update public.chat_sessions set title = '生时校正' where id = '${adoptSession}' returning id::text;`, + ); + assert.match(clientRewrite, new RegExp(adoptSession), clientRewrite); + assert.equal(titleHex(fixture, adoptSession), utf8Hex("生时校正 · 05:07")); + assert.equal(identityFingerprint(fixture, adoptSession), beforeAdopt); + const clientRename = fixture.psqlAs( + "app_runtime", + "app-runtime-test-password", + `set role authenticated; + select set_config('request.jwt.claim.sub', '${user}', true); + update public.chat_sessions set title = '孩子校正' where id = '${adoptSession}' returning id::text;`, + ); + assert.match(clientRename, new RegExp(adoptSession), clientRename); + assert.equal(titleHex(fixture, adoptSession), utf8Hex("孩子校正")); + assert.equal(identityFingerprint(fixture, adoptSession), beforeAdopt); const narrowSession = randomUUID(); insertSession(narrowSession, "生时校正", "birth_time_rectification", "2026-09-12 08:00:00+00", false, "narrow"); diff --git a/frontend/tests/session-open-preserves-identity.test.ts b/frontend/tests/session-open-preserves-identity.test.ts index 34ea9940..2a86b76a 100644 --- a/frontend/tests/session-open-preserves-identity.test.ts +++ b/frontend/tests/session-open-preserves-identity.test.ts @@ -88,6 +88,7 @@ test("openRectificationCase inherits identity from existing before minting", () assert.doesNotMatch(merged, /updatedAt: timestamp\(\)/); assert.match(open, /pinned: existing\?\.pinned \?\? false/); assert.match(open, /void persistSession\(merged\)/); + assert.match(open, /if \(!existing\) \{[\s\S]*fetchSessionDetail\(opened\.sessionId, modelCatalog\)/); }); test("opening a stored chat does not retarget its person or the account chart", () => {