fix(web): keep rectification sessions listed and persist chats on first send
Independent Staging Quality Gate / validate (push) Failing after 16m52s
Independent Staging Quality Gate / publish (push) Skipped

Empty-consultation filtering is now session_type scoped so birth-time rows stay in the sidebar. Subtitles use updatedAt with sort/group/cursor. New chats stay local until the first send.
This commit is contained in:
jesse-ux
2026-09-21 16:39:32 +08:00
parent f8d65e484d
commit e71e4f9200
21 changed files with 461 additions and 186 deletions
+21 -11
View File
@@ -4,6 +4,7 @@ import test from "node:test";
import { homeSurface as page } from "./home-surface.ts";
const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
const listFilter = readFileSync(new URL("../src/lib/session-list-filter.ts", import.meta.url), "utf8");
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const sql = readFileSync(
@@ -12,26 +13,35 @@ const sql = readFileSync(
);
const sendSource = page.slice(page.indexOf(" async function send("), page.indexOf("\n\n consultationReplay.current"));
test("session list GET drops empty consultations and still returns a reusable draft", () => {
assert.match(listRoute, /\.not\("messages", "eq", \[\]\)/);
assert.match(listRoute, /\.eq\("session_type", "consultation"\)[\s\S]*\.eq\("messages", \[\]\)/);
assert.match(listRoute, /return NextResponse\.json\(\{ sessions, nextCursor, draft \}\)/);
test("session list GET drops empty consultations without swallowing rectification rows", () => {
// 原值:.not("messages", "eq", []) 全表过滤,响应带 draft
// 新值:or(session_type.neq.consultation,messages.neq.[]),响应只有 sessions/nextCursor
// 原因:校正会话 messages 永远是 [](BUG-987);第一问前不落库后不再返回 draft(BUG-989)
assert.match(listFilter, /session_type\.neq\.consultation,messages\.neq\.\[\]/);
assert.match(listRoute, /excludeEmptyConsultations/);
assert.match(listRoute, /session_type/);
assert.doesNotMatch(listRoute, /\.not\("messages", "eq", \[\]\)/);
assert.doesNotMatch(listFilter, /\.not\("messages", "eq", \[\]\)/);
assert.match(listRoute, /return NextResponse\.json\(\{ sessions, nextCursor \}\)/);
assert.doesNotMatch(listRoute, /draft/);
assert.doesNotMatch(itemRoute, /\.or\("session_type\.neq\.consultation,messages\.neq\.\[\]"\)/);
assert.doesNotMatch(itemRoute, /\.not\("messages", "eq", \[\]\)/);
});
test("session list GET omits messages while detail GET returns them", () => {
// Former list/detail column strings ended at updated_at; pinned and archived_at
// were added when those flags moved off localStorage.
// 原值:列表列到 updated_at,pinned,archived_at,不选 created_at
// 新值:加上 created_at,供侧栏副标题用会话创建时间
// 原因:任务书 T4 副标题是 M月D日 HH:MM(创建时间,Asia/Shanghai)
// 原值:列表列含 created_at,供副标题用创建时间
// 新值:SESSION_LIST_COLUMNS 不含 created_at
// 原因:副标题改最后活动时间,与排序/分组/游标同源(BUG-988)
assert.match(
listRoute,
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at"/,
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/,
);
// 原值:select(SESSION_LIST_COLUMNS) 之后不得出现 messages
// 新值:WHERE 用 .not("messages", "eq", []),SELECT 列仍不含 messages
// 原因:T3 空咨询过滤要看 messages,但不把正文带回列表
assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*created_at/);
// 原值:WHERE 用 .not("messages", "eq", []),SELECT 列仍不含 messages
// 新值:WHERE 用 session_type 限定的 or(),SELECT 列仍不含 messages
// 原因:T1 空咨询过滤要看 messages,但不把正文带回列表
assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*messages/);
assert.doesNotMatch(
listRoute,
+8 -2
View File
@@ -174,8 +174,14 @@ test("popstate to a missing session query reuses selectSession side effects for
test("creating and leaving a session keep the address bar in sync", () => {
const startNewChat = sourceBetween(page, "async function startNewChat(", "function selectSession(");
assert.match(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/);
assert.match(startNewChat, /window\.history\.replaceState\(null, "", previousHref\)/);
// 原值:新建立刻 writeSessionUrl(nextSession.id, "push"),失败再 replaceState 回旧地址
// 新值:本地创建不写 ?c=;POST 成功后 persistSession(create) 才 push
// 原因:第一问前不落库(BUG-989),未落库会话没有服务端身份
assert.doesNotMatch(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/);
assert.doesNotMatch(startNewChat, /persistSession\(/);
const persistSession = sourceBetween(page, "async function persistSession(", "async function ensureSessionMessages(");
assert.match(persistSession, /if \(mode === "create"\) \{/);
assert.match(persistSession, /writeSessionUrl\(session\.id, "push"\)/);
// 原值:delete/archive 函数体内直接 `writeSessionUrl(fallbackId || null, "replace")`
// 新值:删/归档当前会话走 `activateFallbackSession`,由它 `writeSessionUrl(null|fallbackId, "replace")`
+5 -1
View File
@@ -313,7 +313,11 @@ test("self-hosted staging bootstrap reads profile and sessions through same-orig
assert.doesNotMatch(page, /createBrowserSupabaseClient/);
assert.match(page, /fetch\("\/api\/account"/);
assert.match(page, /fetch\("\/api\/sessions"/);
assert.match(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/);
// 原值:启动时空列表立刻 writeChatSession(initialSession.id, ..., "create")
// 新值:本地 createSession,第一问 send() 才 persistSession(..., "create")
// 原因:BUG-989 第一问之前不落库
assert.doesNotMatch(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/);
assert.match(page, /await persistSession\(currentSession, "create"\)/);
assert.match(page, /fetch\(`\/api\/sessions\/\$\{encodeURIComponent\(sessionId\)\}`/);
assert.match(accountRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/);
assert.match(accountRoute, /profile,/);
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
closeLocalPostgresDataPools,
createLocalPostgresDataClient,
} from "../src/lib/db/local-postgres-client-core.ts";
import { applyArchiveFilter, excludeEmptyConsultations } from "../src/lib/session-list-filter.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
const emptyConsultation = "11111111-1111-4111-8111-111111111111";
const emptyRectification = "22222222-2222-4222-8222-222222222222";
const filledConsultation = "33333333-3333-4333-8333-333333333333";
const archivedRectification = "44444444-4444-4444-8444-444444444444";
test("session list query keeps empty rectification rows and drops empty consultations", {
skip: docker ? false : "docker unavailable",
}, async () => {
const fixture = startPostgresFixture();
try {
const migration = spawnSync(process.execPath, [runner], {
encoding: "utf8",
env: {
...process.env,
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
},
});
assert.equal(migration.status, 0, migration.stderr);
fixture.psqlAs(
"identity_runtime",
"identity-runtime-test-password",
`insert into identity.users(name,email,email_verified,email_verified_at) values ('Fictional List','list-visibility@example.com',true,now());`,
);
const userId = fixture.psql("select id from identity.users where email='list-visibility@example.com'");
fixture.psql(`
insert into public.chat_sessions (id, user_id, title, theme, model_id, messages, session_type, pinned, archived_at, updated_at)
values
('${emptyConsultation}', '${userId}', 'Empty consult', 'general', 'test-model', '[]', 'consultation', false, null, now()),
('${emptyRectification}', '${userId}', 'Empty rectification', 'general', 'test-model', '[]', 'birth_time_rectification', false, null, now()),
('${filledConsultation}', '${userId}', 'Filled consult', 'general', 'test-model', '[{"role":"user","text":"问一句"}]', 'consultation', false, null, now()),
('${archivedRectification}', '${userId}', 'Archived rectification', 'general', 'test-model', '[]', 'birth_time_rectification', false, '2026-09-08T00:00:00Z', now());
`);
const local = createLocalPostgresDataClient(
fixture.connectionUrl("app_runtime", "app-runtime-test-password"),
{ id: userId, email: "list-visibility@example.com" },
);
const live = await excludeEmptyConsultations(
applyArchiveFilter(
local.from("chat_sessions").select("id").eq("user_id", userId).eq("pinned", false),
false,
),
);
assert.equal(live.error, null, live.error?.message);
const liveIds = (live.data as { id: string }[]).map((row) => row.id).sort();
assert.deepEqual(liveIds, [emptyRectification, filledConsultation].sort());
const archived = await excludeEmptyConsultations(
applyArchiveFilter(
local.from("chat_sessions").select("id").eq("user_id", userId).eq("pinned", false),
true,
),
);
assert.equal(archived.error, null, archived.error?.message);
assert.deepEqual(
(archived.data as { id: string }[]).map((row) => row.id),
[archivedRectification],
);
} finally {
await closeLocalPostgresDataPools();
fixture.stop();
}
});
+8
View File
@@ -7,6 +7,7 @@ import { sessionCursorFilter } from "../src/lib/session-cursor.ts";
const core = readFileSync(new URL("../src/lib/db/local-postgres-client-core.ts", import.meta.url), "utf8");
const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
const listFilter = readFileSync(new URL("../src/lib/session-list-filter.ts", import.meta.url), "utf8");
test("or() compiles a comma-separated PostgREST filter", () => {
const parameters: unknown[] = [];
@@ -48,3 +49,10 @@ test("or() compiles the session cursor filter with a nested and", () => {
]);
assert.match(listRoute, /pageQuery = pageQuery\.or\(sessionCursorFilter\(cursor\)\)/);
});
test("session list empty-consultation filter is session_type scoped", () => {
assert.match(listFilter, /session_type\.neq\.consultation,messages\.neq\.\[\]/);
assert.match(listRoute, /excludeEmptyConsultations/);
assert.doesNotMatch(listRoute, /\.not\("messages", "eq", \[\]\)/);
assert.doesNotMatch(listFilter, /\.not\("messages", "eq", \[\]\)/);
});
+41
View File
@@ -8,6 +8,9 @@ import {
recencyKeyFor,
sortSessions,
} from "../src/lib/session-groups.ts";
import { sessionSidebarSubtitle } from "../src/lib/session-sidebar-row.ts";
import { shanghaiDateTimeLabel } from "../src/lib/session-shanghai-clock.ts";
import type { ChatSession } from "../src/lib/home-types.ts";
function session(id: string, updatedAt: number, pinned = false) {
return { id, pinned, updatedAt };
@@ -72,6 +75,44 @@ test("mergeSessionPage keeps the local row and skips a duplicate id", () => {
assert.equal(mergeSessionPage([local], incoming)[0]?.updatedAt, 900);
});
test("sidebar subtitle follows updatedAt so the visible clock matches sortSessions", () => {
const createdEarly = Date.parse("2026-09-07T05:42:00.000Z");
const activeLate = Date.parse("2026-09-16T10:01:00.000Z");
const mid = Date.parse("2026-09-17T14:53:00.000Z");
const newest = Date.parse("2026-09-18T00:01:00.000Z");
function row(id: string, createdAt: number, updatedAt: number): ChatSession {
return {
id,
title: id,
theme: "general",
modelId: "m",
messages: [{ role: "user", text: "问一句" }],
createdAt,
updatedAt,
sessionType: "consultation",
rectificationCaseId: null,
chartProfileId: null,
chartProfileName: null,
chartProfileRole: null,
pinned: false,
archivedAt: null,
messagesHydrated: true,
};
}
const earlyCreatedLateActive = row("early-created", createdEarly, activeLate);
const sessions = [
earlyCreatedLateActive,
row("mid", mid, mid),
row("newest", newest, newest),
];
assert.deepEqual(sortSessions(sessions).map((item) => item.id), ["newest", "mid", "early-created"]);
const clocks = sortSessions(sessions).map((item) => sessionSidebarSubtitle(item));
const expected = sortSessions(sessions).map((item) => shanghaiDateTimeLabel(new Date(item.updatedAt)));
assert.deepEqual(clocks, expected);
assert.equal(sessionSidebarSubtitle(earlyCreatedLateActive), shanghaiDateTimeLabel(new Date(activeLate)));
assert.notEqual(sessionSidebarSubtitle(earlyCreatedLateActive), shanghaiDateTimeLabel(new Date(createdEarly)));
});
test("beginSessionPageLoad only starts one in-flight request", () => {
const inFlight = { current: false };
assert.equal(beginSessionPageLoad(inFlight, "cursor"), true);
+72 -6
View File
@@ -3,13 +3,19 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import {
findReusableEmptyConsultation,
cloudListIncludesSession,
EMPTY_CONSULTATION_LIST_FILTER,
isListedSidebarSession,
isUnsavedEmptyConsultation,
replaceUnsavedEmptyConsultations,
} from "../src/lib/session-list-filter.ts";
import type { ChatSession } from "../src/lib/home-types.ts";
const management = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
const listContext = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8");
const consultRun = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8");
function session(change: Partial<ChatSession>): ChatSession {
return {
@@ -31,7 +37,7 @@ function session(change: Partial<ChatSession>): ChatSession {
};
}
test("an empty hydrated consultation can be reused instead of creating another", () => {
test("empty consultations stay off the sidebar while rectification rows stay on it", () => {
const empty = session({ id: "empty" });
const listed = session({
id: "listed",
@@ -43,11 +49,71 @@ test("an empty hydrated consultation can be reused instead of creating another",
sessionType: "birth_time_rectification",
title: "生时校正 · 9月14日",
});
assert.equal(findReusableEmptyConsultation([listed, empty, rectification])?.id, "empty");
assert.equal(findReusableEmptyConsultation([listed, rectification]), undefined);
const archivedRectification = session({
id: "archived-rect",
sessionType: "birth_time_rectification",
archivedAt: "2026-09-08T00:00:00.000Z",
});
assert.equal(isListedSidebarSession(empty), false);
assert.equal(isListedSidebarSession(listed), true);
assert.equal(isListedSidebarSession(rectification), true);
assert.match(management, /findReusableEmptyConsultation\(sessions\)/);
assert.match(page, /readDraftConsultation\(listBoot\.draftRow/);
assert.equal(isListedSidebarSession(archivedRectification), false);
assert.equal(isUnsavedEmptyConsultation(empty), true);
assert.equal(isUnsavedEmptyConsultation(rectification), false);
});
test("server list filter and sidebar listing agree on empty consultation, rectification, and archive", () => {
const cases = [
{ sessionType: "consultation" as const, messagesEmpty: true, archived: false, archivedView: false, listed: false },
{ sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: false, archivedView: false, listed: true },
{ sessionType: "consultation" as const, messagesEmpty: false, archived: false, archivedView: false, listed: true },
{ sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: true, archivedView: false, listed: false },
{ sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: true, archivedView: true, listed: true },
{ sessionType: "consultation" as const, messagesEmpty: true, archived: true, archivedView: true, listed: false },
];
for (const row of cases) {
const cloud = cloudListIncludesSession(row);
const client = isListedSidebarSession(session({
sessionType: row.sessionType,
messages: row.messagesEmpty ? [] : [{ role: "user", text: "问一句" }],
archivedAt: row.archived ? "2026-09-08T00:00:00.000Z" : null,
messagesHydrated: true,
}));
assert.equal(cloud, row.listed, JSON.stringify(row));
if (!row.archivedView) assert.equal(client, row.listed, JSON.stringify(row));
}
assert.equal(EMPTY_CONSULTATION_LIST_FILTER, "session_type.neq.consultation,messages.neq.[]");
assert.match(listRoute, /excludeEmptyConsultations/);
assert.match(listRoute, /from "@\/lib\/session-list-filter"/);
});
test("new chat stays local until the first send and does not reuse a draft row", () => {
const empty = session({ id: "empty" });
const listed = session({
id: "listed",
messages: [{ role: "user", text: "问一句" }],
title: "半年内换工作时机",
});
const next = session({ id: "next" });
assert.deepEqual(
replaceUnsavedEmptyConsultations([listed, empty], next).map((item) => item.id),
["next", "listed"],
);
assert.doesNotMatch(management, /findReusableEmptyConsultation/);
assert.doesNotMatch(page, /readDraftConsultation|findReusableEmptyConsultation|writeChatSession/);
assert.doesNotMatch(listContext, /draftRow/);
assert.match(management, /replaceUnsavedEmptyConsultations\(current, nextSession\)/);
assert.doesNotMatch(management, /writeSessionUrl\(nextSession\.id, "push"\)/);
assert.match(consultRun, /isUnsavedEmptyConsultation\(currentSession\)/);
assert.match(consultRun, /await persistSession\(currentSession, "create"\)/);
assert.match(management, /if \(mode === "create"\) \{/);
assert.match(management, /cloudCreatedIds\.current\.add\(session\.id\)/);
assert.match(management, /writeSessionUrl\(session\.id, "push"\)/);
const persistBlockStart = consultRun.indexOf("if (isUnsavedEmptyConsultation(currentSession))");
const persistBlockEnd = consultRun.indexOf("const [year, month, day]");
assert.ok(persistBlockStart >= 0 && persistBlockEnd > persistBlockStart);
const persistBlock = consultRun.slice(persistBlockStart, persistBlockEnd);
assert.match(persistBlock, /setSessions\(remaining\)/);
assert.match(persistBlock, /setRequestError/);
assert.doesNotMatch(persistBlock, /setDraft\(/);
});