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.
This commit is contained in:
jesse-ux
2026-09-25 02:04:56 +08:00
parent 88fe67df9b
commit a94a1d6748
6 changed files with 71 additions and 4 deletions
+2 -2
View File
@@ -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(标题口径两次返工;「旧标题不批量改」被本单推翻)
@@ -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。
- 浏览器走查未做。
@@ -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
@@ -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;
@@ -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");
@@ -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", () => {