fix(web): 四个页面共用一份会话列表,空会话不入列
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 9m33s

对话、星盘、星历、报告进同一 (app) 外壳,列表只拉一次。服务端不再列出空咨询;新建复用已有空会话。新标题改成「生时校正 · M月D日」,侧栏副标题用创建时间。
This commit is contained in:
jesse-ux
2026-09-17 21:27:40 +08:00
parent 5203f9f0c3
commit e4e73f56c0
76 changed files with 1209 additions and 627 deletions
@@ -57,7 +57,7 @@ test("a closed account overlay does not render profile or logout content", () =>
});
test("home does not write overlay epoch or chat actions during render", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8");
assert.match(overlay, /model: AccountOverlayModel \| null/);
assert.doesNotMatch(overlay, /openEpoch/);
+8 -5
View File
@@ -59,18 +59,21 @@ test("general no-birth-time replies keep a question-specific session title", ()
});
test("daily and topic consultations get distinct dated or domain titles", () => {
const at = new Date(2026, 7, 22, 8, 36);
// 原值:8月22日 · 今日节奏;同名追加墙钟 08:36;8月22日 · 生时校正
// 新值:今日节奏 · 8月22日;同名不再加 HH:MM;生时校正 · 8月22日
// 原因:任务书 T4 类别在前,同名靠副标题区分
const at = new Date(Date.UTC(2026, 7, 22, 0, 36));
assert.equal(
resolveSessionTitle("深入看今日", undefined, { entrypoint: "daily_starlanguage", at }),
"8月22日 · 今日节奏",
"今日节奏 · 8月22日",
);
assert.equal(
resolveSessionTitle("深入看今日", undefined, {
entrypoint: "daily_starlanguage",
at,
existingTitles: ["8月22日 · 今日节奏"],
existingTitles: ["今日节奏 · 8月22日"],
}),
"8月22日 · 今日节奏 08:36",
"今日节奏 · 8月22日",
);
assert.equal(
resolveSessionTitle("请帮我梳理目前的事业方向和下一步重点。", undefined, { theme: "career", at }),
@@ -81,7 +84,7 @@ test("daily and topic consultations get distinct dated or domain titles", () =>
entrypoint: "birth_time_rectification",
at,
}),
"8月22日 · 生时校正",
"生时校正 · 8月22日",
);
});
+1 -1
View File
@@ -17,7 +17,7 @@ const accountRoute = readProjectFile("src/app/api/account/route.ts");
const avatarRoute = readProjectFile("src/app/api/account/avatar/route.ts");
const avatarComponent = readProjectFile("src/components/user-avatar.tsx");
const sidebar = readProjectFile("src/components/app-sidebar.tsx");
const page = readProjectFile("src/app/page.tsx");
const page = readProjectFile("src/app/(app)/page.tsx");
const profilePanel = readProjectFile("src/components/profile-panel.tsx");
const migration = readProjectFile("db/migrations/20260807010000_profile_beam_avatars.sql");
+1 -1
View File
@@ -12,7 +12,7 @@ const panelSource = readProjectFile("src/components/billing-panel.tsx");
const billingSource = `${hookSource}\n${panelSource}`;
const membershipLib = readProjectFile("src/lib/membership.ts");
const nextConfig = readProjectFile("next.config.ts");
const pageSource = readProjectFile("src/app/page.tsx");
const pageSource = readProjectFile("src/app/(app)/page.tsx");
const tabsSource = readProjectFile("src/components/ui/tabs.tsx");
function countMatches(source: string, pattern: RegExp) {
@@ -157,7 +157,7 @@ test("terminal CJK copy stays intact while homepage candidates remain unconfirme
});
test("completed rectification transcript does not repeat the birth place turn", () => {
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.match(
pageSource,
@@ -17,7 +17,7 @@ const candidateSource = readFileSync(
"utf8",
);
const pageSource = readFileSync(
new URL("../src/app/page.tsx", import.meta.url),
new URL("../src/app/(app)/page.tsx", import.meta.url),
"utf8",
);
const storeSource = readFileSync(
+18 -5
View File
@@ -6,7 +6,7 @@ import {
chartLibraryOnCloudFailure,
chartLibrarySessionBranch,
} from "../src/lib/chart-library-session.ts";
import { sessionSidebarSubtitle, sessionSidebarTitle } from "../src/lib/home-profile.ts";
import { sessionSidebarSubtitle, sessionSidebarTitle, toSidebarSessionRow } from "../src/lib/session-sidebar-row.ts";
import { emptyProfile, type ChartLibraryRecord, type ChatSession } from "../src/lib/home-types.ts";
type RecordShape = { id: string; role: "self" | "other" };
@@ -57,6 +57,10 @@ test("a failed cloud read keeps only the profile-derived self chart", () => {
});
test("sidebar titles drop the chart-name prefix and only subtitle others", () => {
// 原值:本人盘副标题 null;他人盘只有资料名
// 新值:一律「M月D日 HH:MM」,他人盘再加「 · 盘主」
// 原因:任务书 T4 副标题规则
const createdAt = Date.parse("2026-09-14T04:14:00.000Z");
const selfRecord: ChartLibraryRecord = {
id: "self",
role: "self",
@@ -71,7 +75,8 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () =>
theme: "career",
modelId: "m",
messages: [],
updatedAt: 1,
createdAt,
updatedAt: createdAt,
sessionType: "consultation",
rectificationCaseId: null,
chartProfileId: "self",
@@ -82,9 +87,9 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () =>
messagesHydrated: true,
};
assert.equal(sessionSidebarTitle(base, library), "半年内换工作时机");
assert.equal(sessionSidebarSubtitle(base, library), null);
assert.equal(sessionSidebarSubtitle(base, library), "9月14日 12:14");
const other = { ...base, chartProfileId: "other-1", chartProfileName: "对方", chartProfileRole: "other" as const };
assert.equal(sessionSidebarSubtitle(other, library), "资料已删除 · 对方");
assert.equal(sessionSidebarSubtitle(other, library), "9月14日 12:14 · 资料已删除 · 对方");
const liveOtherLibrary: ChartLibraryRecord[] = [{
...selfRecord,
id: "other-1",
@@ -92,5 +97,13 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () =>
relationship: "partner",
profile: { ...emptyProfile, name: "对方" },
}];
assert.equal(sessionSidebarSubtitle(other, liveOtherLibrary), "对方");
assert.equal(sessionSidebarSubtitle(other, liveOtherLibrary), "9月14日 12:14 · 对方");
assert.deepEqual(toSidebarSessionRow(other, liveOtherLibrary), {
id: other.id,
title: "半年内换工作时机",
subtitle: "9月14日 12:14 · 对方",
pinned: false,
archived: false,
updatedAt: createdAt,
});
});
+14 -20
View File
@@ -72,7 +72,7 @@ async function okView(western: Record<string, unknown> | null = null): Promise<C
/* 原值:直接 `renderToStaticMarkup(<ChartPageView … />)`
新值:外面裹一层 `SidebarProvider`
原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(secondary)/layout.tsx`
原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(app)/layout.tsx`
TASK-sidebar-unify D2)。页面组件自己不再自带 provider,而 46px 顶栏里的
`SidebarTrigger` 仍要读它。断言主语一字未改,改的是测试挂载环境。 */
function withSidebarProvider(element: React.ReactElement) {
@@ -236,7 +236,7 @@ test("the chart page shell is visible before the natal chart arrives", () => {
// 新值:断言 46px 顶栏的侧栏触发器在场;侧栏本体改由本文件下一条
// 「(secondary) layout 挂一份只读侧栏」断言,读的是 layout 源码
// 原因:TASK-sidebar-unify D2 把 provider + 侧栏从每页各一份上移到
// `app/(secondary)/layout.tsx`,页面组件的产物里本就不该再有侧栏。
// `app/(app)/layout.tsx`,页面组件的产物里本就不该再有侧栏。
// 断言主语(这一页不靠一次性「返回对话」链接回去)没变,覆盖没有减少。
assert.match(markup, /data-sidebar="trigger"/);
assert.doesNotMatch(markup, /返回对话/);
@@ -264,23 +264,17 @@ test("a pending western tab uses inline waiting copy instead of a spinner", asyn
assert.doesNotMatch(markup, /正在加载|InlineSpinner|skeleton/i);
});
test("the (secondary) layout mounts one read-only sidebar for all four routes", () => {
const layout = readFileSync(new URL("../src/app/(secondary)/layout.tsx", import.meta.url), "utf8");
// One provider, one sidebar, one inset — for /chart, /ephemeris, /reports and
// /reports/[reportId] together. Each page used to mount its own.
assert.match(layout, /<SidebarProvider>/);
test("the (app) layout mounts one sidebar for home and the four secondary routes", () => {
// 原值:`(secondary)/layout.tsx` 挂只读侧栏,`useSidebarData` 另拉一份列表
// 新值:`(app)/layout.tsx` 一份 SessionListProvider + SidebarProvider,首页也在里面
// 原因:TASK-session-list-single-source T2,列表只拉一次、侧栏不卸载
const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
assert.match(layout, /<SessionListProvider>/);
assert.match(layout, /<SidebarProvider escapeBlocked=\{registration\?\.escapeBlocked \?\? false\}>/);
assert.match(layout, /<AppSidebar\b/);
assert.match(layout, /<SidebarInset className="chat-panel secondary-panel">/);
assert.equal(layout.match(/<SidebarProvider>/g)?.length, 1);
// Read-only: no `controls`, so no rename / archive / delete / account menu.
assert.doesNotMatch(layout, /controls=/);
assert.match(layout, /useSidebarData\(\)/);
// The data hook never writes, and the pages never mount a second shell.
const hook = readFileSync(new URL("../src/hooks/use-sidebar-data.ts", import.meta.url), "utf8");
assert.doesNotMatch(hook, /method:\s*"(?:POST|PATCH|PUT|DELETE)"/);
assert.match(hook, /fetch\("\/api\/sessions\?limit=40"/);
assert.match(hook, /fetch\("\/api\/account"/);
assert.equal(layout.match(/<SidebarProvider/g)?.length, 1);
assert.match(layout, /controls=\{registration\?\.controls\}/);
const provider = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8");
assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}`/);
assert.match(provider, /fetch\("\/api\/account"/);
});
+3 -3
View File
@@ -19,12 +19,12 @@ const golden = JSON.parse(
const routeSource = readFileSync(new URL("../src/app/api/chart-view/route.ts", import.meta.url), "utf8");
const serviceSource = readFileSync(new URL("../src/lib/chart-view-service.ts", import.meta.url), "utf8");
const loadSource = readFileSync(new URL("../src/lib/chart-view-load.ts", import.meta.url), "utf8");
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
// 原值:`../src/app/chart/page.tsx`
// 新值:`../src/app/(secondary)/chart/page.tsx`
// 新值:`../src/app/(app)/chart/page.tsx`
// 原因:四个次级路由移进 `(secondary)` 路由组共享一份外壳(TASK-sidebar-unify D2)。
// 路由组括号不进 URL,`/chart` 一字未改;这里改的只是源码位置。
const chartPageSource = readFileSync(new URL("../src/app/(secondary)/chart/page.tsx", import.meta.url), "utf8");
const chartPageSource = readFileSync(new URL("../src/app/(app)/chart/page.tsx", import.meta.url), "utf8");
const profile = {
name: "示例",
+1 -1
View File
@@ -14,7 +14,7 @@ import {
import { chatReplyAnnouncer } from "../src/lib/chat-reply-announcement.ts";
import { RECTIFICATION_STOPPED_NOTICE } from "../src/lib/rectification-surface-state.ts";
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const composer = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
const rectification = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
@@ -6,7 +6,7 @@ import { noticeTone } from "../src/lib/chat-notice.ts";
import { nextAnchorState } from "../src/hooks/use-conversation-scroll-anchor.ts";
import { homeSurface } from "./home-surface.ts";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8");
const anchorSource = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8");
const globalsSource = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
@@ -39,7 +39,7 @@ test("nothing inside the chat panel focuses without preventScroll", () => {
// panel through the default scroll-into-view; the transcript has its own
// anchor and does not want the browser's.
const insidePanel = [
"src/app/page.tsx",
"src/app/(app)/page.tsx",
"src/hooks/use-consultation-run.ts",
"src/components/birth-time-choice-question.tsx",
"src/components/birth-time-rectification.tsx",
@@ -4,7 +4,7 @@ import test from "node:test";
import { bootstrapPrepareSettled } from "../src/lib/home-bootstrap.ts";
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
const bootstrap = readFileSync(new URL("../src/lib/home-bootstrap.ts", import.meta.url), "utf8");
const entrypoint = readFileSync(new URL("../src/lib/consultation-entrypoint.ts", import.meta.url), "utf8");
+16 -3
View File
@@ -12,14 +12,27 @@ 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 \}\)/);
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
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,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,created_at,updated_at,pinned,archived_at"/,
);
assert.doesNotMatch(listRoute, /select\(SESSION_LIST_COLUMNS\)[\s\S]*messages/);
// 原值:select(SESSION_LIST_COLUMNS) 之后不得出现 messages
// 新值:WHERE 用 .not("messages", "eq", [])SELECT 列仍不含 messages
// 原因:T3 空咨询过滤要看 messages,但不把正文带回列表
assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*messages/);
assert.doesNotMatch(
listRoute,
/SESSION_LIST_COLUMNS = "[^"]*context_summary/,
@@ -27,7 +40,7 @@ test("session list GET omits messages while detail GET returns them", () => {
assert.match(itemRoute, /export async function GET/);
assert.match(
itemRoute,
/sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/,
/sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at"/,
);
assert.match(page, /async function fetchSessionDetail\(/);
assert.match(page, /async function ensureSessionMessages\(/);
+1 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const rectification = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
@@ -31,8 +31,11 @@ const postgresFixture = readFileSync(
"utf8",
);
// 原值:列到 updated_at,pinned,archived_at
// 新值:加上 created_at
// 原因:任务书 T4 侧栏副标题要用创建时间
const SESSION_SELECT =
"id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at";
"id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at";
function functionBody(source: string): string {
const match = source.match(/as \$\$\r?\n([\s\S]*?)\r?\n\$\$;/);
+1 -1
View File
@@ -17,7 +17,7 @@ const availability = readFileSync(new URL("src/lib/epay/availability.ts", root),
const packagesRoute = readFileSync(new URL("src/app/api/payment/packages/route.ts", root), "utf8");
const testRoute = readFileSync(new URL("src/app/api/admin/epay-settings/test/route.ts", root), "utf8");
const gatewayPolicy = readFileSync(new URL("src/lib/epay/gateway-policy.ts", root), "utf8");
const page = readFileSync(new URL("src/app/page.tsx", root), "utf8");
const page = readFileSync(new URL("src/app/(app)/page.tsx", root), "utf8");
const billingHook = readFileSync(new URL("src/hooks/use-billing-panel.ts", root), "utf8");
const billingPanel = readFileSync(new URL("src/components/billing-panel.tsx", root), "utf8");
const billingSource = `${billingHook}\n${billingPanel}`;
+4 -4
View File
@@ -19,10 +19,10 @@ Object.assign(globalThis, { React });
const pageSource = readFileSync(new URL("../src/components/ephemeris/ephemeris-page.tsx", import.meta.url), "utf8");
// 原值:`../src/app/ephemeris/page.tsx`
// 新值:`../src/app/(secondary)/ephemeris/page.tsx`
// 新值:`../src/app/(app)/ephemeris/page.tsx`
// 原因:四个次级路由移进 `(secondary)` 路由组共享一份外壳(TASK-sidebar-unify D2)。
// 路由组括号不进 URL`/ephemeris` 一字未改;这里改的只是源码位置。
const routePage = readFileSync(new URL("../src/app/(secondary)/ephemeris/page.tsx", import.meta.url), "utf8");
const routePage = readFileSync(new URL("../src/app/(app)/ephemeris/page.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const panchangaGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-panchanga-range.golden.json", import.meta.url), "utf8"));
const natalGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-natal.golden.json", import.meta.url), "utf8"));
@@ -59,7 +59,7 @@ function payloadFor(date: "2026-09-14" | "2026-09-15", events: EphemerisOkRespon
/* 原值:直接 `renderToStaticMarkup(<EphemerisView … />)`
新值:外面裹一层 `SidebarProvider`
原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(secondary)/layout.tsx`
原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(app)/layout.tsx`
TASK-sidebar-unify D2)。页面组件自己不再自带 provider,而 46px 顶栏里的
`SidebarTrigger` 仍要读它。断言主语一字未改,改的是测试挂载环境。 */
function withSidebarProvider(element: React.ReactElement) {
@@ -80,7 +80,7 @@ function render(payload: EphemerisOkResponse | null, date = payload?.date ?? "20
test("the ephemeris page is its own route and never grows page.tsx", () => {
assert.match(routePage, /from "@\/components\/ephemeris\/ephemeris-page"/);
assert.doesNotMatch(readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"), /ephemeris-page/);
assert.doesNotMatch(readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"), /ephemeris-page/);
// 原值: assert.doesNotMatch(app-sidebar.tsx, /ephemeris/)
// 新值: 本单文件归属只锁 page.tsx / 本 route,不再锁 sidebar
// 原因: TASK-chart-page-20260915 任务 3 要求 sidebar 同时加「星盘」「星历」入口;文件归属「本单不改 sidebar」不是产品事实「sidebar 不得出现 ephemeris」(BUG-711
+1 -1
View File
@@ -20,7 +20,7 @@ import { homeSurface } from "./home-surface.ts";
// answer keeps a live marker, because that is content being generated, not data
// being loaded.
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const staleClientRecovery = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8");
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
// Measured 2026-09-16 after home-state-lowering batch 2 on origin/staging @ 37e6c519.
// Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1846.
+5 -1
View File
@@ -1,7 +1,7 @@
import { existsSync, readFileSync } from "node:fs";
const homeSurfaceFiles = [
"../src/app/page.tsx",
"../src/app/(app)/page.tsx",
"../src/lib/home-types.ts",
"../src/components/onboarding-chat-message.tsx",
"../src/lib/home-profile.ts",
@@ -20,6 +20,10 @@ const optionalHomeHookFiles = [
"../src/hooks/use-profile-onboarding.ts",
"../src/hooks/use-rectification-surface.ts",
"../src/hooks/use-synastry.ts",
"../src/hooks/use-session-list.ts",
"../src/hooks/use-home-shell-registration.ts",
"../src/lib/session-list-context.tsx",
"../src/lib/session-list-filter.ts",
] as const;
export const homeSurface = [
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { compilePostgrestOr } from "../src/lib/db/local-postgres-client-core.ts";
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");
test("or() compiles a comma-separated PostgREST filter", () => {
const parameters: unknown[] = [];
const sql = compilePostgrestOr(
"session_type.neq.consultation,messages.neq.[]",
parameters,
new Map([
["session_type", "text"],
["messages", "jsonb"],
]),
);
assert.equal(sql, '("session_type" <> $1 or "messages" <> $2)');
assert.deepEqual(parameters, ["consultation", "[]"]);
assert.match(core, /or\(expression: string\)/);
});
test("or() compiles the session cursor filter with a nested and", () => {
const cursor = sessionCursorFilter({
updatedAt: "2026-09-03T07:09:23Z",
id: "11111111-1111-4111-8111-111111111111",
});
const parameters: unknown[] = [];
const sql = compilePostgrestOr(
cursor,
parameters,
new Map([
["updated_at", "timestamptz"],
["id", "uuid"],
]),
);
assert.equal(
sql,
'("updated_at" < $1 or ("updated_at" = $2 and "id" < $3))',
);
assert.deepEqual(parameters, [
"2026-09-03T07:09:23Z",
"2026-09-03T07:09:23Z",
"11111111-1111-4111-8111-111111111111",
]);
assert.match(listRoute, /pageQuery = pageQuery\.or\(sessionCursorFilter\(cursor\)\)/);
});
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const panelPath = new URL("../src/components/parameter-freeze-panel.tsx", import.meta.url);
+1 -1
View File
@@ -18,7 +18,7 @@ const componentSource = readFileSync(
new URL("../src/components/personal-report/generate-personal-report-button.tsx", import.meta.url),
"utf8",
);
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const sidebarSource = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
const reportCenterSource = readFileSync(
new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url),
+1 -1
View File
@@ -449,7 +449,7 @@ test("the reader renders inside the app shell, in every phase", () => {
// 原值:`SecondaryShell` 的 import 与 `<SecondaryShell title="个人报告">` 的出现次数
// 新值:`SecondaryHeader` 的 import 与 `<SecondaryHeader title="个人报告" />` 的出现次数
// 原因:TASK-sidebar-unify D2 把 provider + 侧栏 + inset 上移到
// `app/(secondary)/layout.tsx``SecondaryShell` 拆剩 46px 顶栏并改名
// `app/(app)/layout.tsx``SecondaryShell` 拆剩 46px 顶栏并改名
// `SecondaryHeader`。断言主语(每个阶段都在 app 外壳里、都恰好带一次外壳、
// 没有一个阶段返回裸 `<main>`)一字未改。
assert.match(pageSource, /import \{ SecondaryHeader \} from "@\/components\/secondary-header";/);
@@ -10,7 +10,7 @@ const sidebar = read("../src/components/app-sidebar.tsx");
const sidebarRow = read("../src/components/sidebar-session-row.tsx");
const starter = read("../src/components/starter-home.tsx");
const styles = read("../src/app/globals.css");
const pageSource = read("../src/app/page.tsx");
const pageSource = read("../src/app/(app)/page.tsx");
const hook = read("../src/hooks/use-rectification-surface.ts");
const sessions = read("../src/hooks/use-session-management.ts");
const caseService = read("../src/lib/rectification-agentic/v9/case-service.ts");
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
test("loads birth-time rectification only when its onboarding stage is reached", () => {
assert.match(page, /import dynamic from "next\/dynamic"/);
@@ -17,7 +17,7 @@ const read = (relativePath: string) => readFileSync(new URL(relativePath, import
const run = read("../src/hooks/use-consultation-run.ts");
const sessions = read("../src/hooks/use-session-management.ts");
const consultRoute = read("../src/app/api/consult/route.ts");
const pageSource = read("../src/app/page.tsx");
const pageSource = read("../src/app/(app)/page.tsx");
const sendFn = run.slice(run.indexOf("async function send("), run.indexOf('await fetch("/api/consult"'));
test("a rectification session blocks consult send and keeps the draft", () => {
@@ -16,7 +16,7 @@ test("aligns the session transcript and composer to one readable column", () =>
});
test("keeps onboarding transcript and intake card on the same session column", () => {
const pageSource = readProjectFile("src/app/page.tsx");
const pageSource = readProjectFile("src/app/(app)/page.tsx");
assert.match(pageSource, /\$\{!profileComplete \? " is-onboarding" : ""\}/);
assert.match(globalStyles, /\.conversation\.is-onboarding \.welcome \{[\s\S]*width:\s*min\(calc\(var\(--session-column-width\) \+ \(var\(--session-column-gutter\) \* 2\)\), 100%\)/);
assert.match(globalStyles, /\.conversation\.is-onboarding\.is-empty \.welcome \{[\s\S]*width:\s*min\(var\(--session-column-width\), 100%\)/);
@@ -25,7 +25,7 @@ test("keeps onboarding transcript and intake card on the same session column", (
});
test("centers the starter home even when the conversation grid is not is-empty", () => {
const pageSource = readProjectFile("src/app/page.tsx");
const pageSource = readProjectFile("src/app/(app)/page.tsx");
// 原值:空态 `.welcome` 与 `.starter-list` 都是 1040px
// 新值:`.welcome` 收到 `--session-column-width``.starter-list` 已删
// 原因:空态输入框此前 1040px、会话态 760px,首条消息落地时输入框会当场变窄一截。
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
findReusableEmptyConsultation,
isListedSidebarSession,
} 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");
function session(change: Partial<ChatSession>): ChatSession {
return {
id: "11111111-1111-4111-8111-111111111111",
title: "新对话",
theme: "general",
modelId: "m",
messages: [],
updatedAt: 1,
sessionType: "consultation",
rectificationCaseId: null,
chartProfileId: null,
chartProfileName: null,
chartProfileRole: null,
pinned: false,
archivedAt: null,
messagesHydrated: true,
...change,
};
}
test("an empty hydrated consultation can be reused instead of creating another", () => {
const empty = session({ id: "empty" });
const listed = session({
id: "listed",
messagesHydrated: false,
title: "半年内换工作时机",
});
const rectification = session({
id: "rect",
sessionType: "birth_time_rectification",
title: "生时校正 · 9月14日",
});
assert.equal(findReusableEmptyConsultation([listed, empty, rectification])?.id, "empty");
assert.equal(findReusableEmptyConsultation([listed, rectification]), undefined);
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/);
});
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const provider = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8");
const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const sessionHook = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
test("the app layout owns one SessionListProvider and one SidebarProvider", () => {
assert.match(layout, /<SessionListProvider>/);
assert.equal(layout.match(/<SessionListProvider>/g)?.length, 1);
assert.match(layout, /<SidebarProvider escapeBlocked=\{registration\?\.escapeBlocked \?\? false\}>/);
assert.equal(layout.match(/<SidebarProvider/g)?.length, 1);
assert.match(layout, /<AppSidebar/);
assert.doesNotMatch(page, /<SidebarProvider/);
assert.doesNotMatch(page, /<AppSidebar/);
});
test("the provider fetches the session list once and home does not fetch it again", () => {
assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}`/);
assert.match(provider, /fetch\("\/api\/account"/);
assert.match(page, /await sessionListReady;/);
assert.doesNotMatch(page, /fetchSessions\(/);
assert.doesNotMatch(page, /fetchAccount\(/);
assert.match(page, /useHomeShellRegistration\(/);
});
test("a 401 clears the list and marks signedOut", () => {
assert.match(provider, /sessionResponse\.status === 401 \|\| accountResponse\.status === 401/);
assert.match(provider, /signedOut: true/);
assert.doesNotMatch(provider, /localStorage|sessionStorage|document\.cookie|indexedDB/i);
});
test("session writes no longer invalidate a second cache", () => {
assert.doesNotMatch(sessionHook, /invalidateSidebarCache/);
});
@@ -82,6 +82,6 @@ test("cloud lookup and session management call the server before clearing ?c=",
assert.match(pop, /lookupSessionById/);
assert.match(pop, /SESSION_LOOKUP_FAILED_NOTICE/);
assert.match(pop, /status === "missing"/);
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.match(page, /resolveLookupBootstrap/);
});
@@ -36,6 +36,7 @@ test("shouldGenerateSessionTitle only runs on a first-round auto title", () => {
assert.equal(shouldGenerateSessionTitle({ title: "新对话", sessionType: "consultation" }, [{ role: "user" }]), false);
assert.equal(shouldGenerateSessionTitle({ title: "新对话", sessionType: "birth_time_rectification" }, []), false);
assert.equal(shouldGenerateSessionTitle({ title: "9月6日 · 今日节奏", sessionType: "consultation" }, []), false);
assert.equal(shouldGenerateSessionTitle({ title: "今日节奏 · 9月6日", sessionType: "consultation" }, []), false);
assert.equal(shouldGenerateSessionTitle({ title: "半年内换工作时机", sessionType: "consultation" }, []), false);
});
+18 -12
View File
@@ -296,7 +296,7 @@ test("keeps app sidebar props as product data and callbacks", () => {
test("removes the user-facing admin entry and routes credit control to membership", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
const page = readProjectFile("src/app/page.tsx");
const page = readProjectFile("src/app/(app)/page.tsx");
assert.doesNotMatch(appSidebar, /adminUrl|account\.isAdmin|后台管理|KeyRound/);
assert.doesNotMatch(page, /admin-button|ShieldCheck|后台管理/);
@@ -307,20 +307,24 @@ test("removes the user-facing admin entry and routes credit control to membershi
});
test("composes the chat page with the app sidebar shell", () => {
const page = readProjectFile("src/app/page.tsx");
// 原值:page.tsx 自己挂 SidebarProvider / AppSidebar / SidebarInset
// 新值:外壳在 (app)/layout.tsx;首页只注册 controls 并保留 SidebarTrigger
// 原因:T2 列表只拉一次、四页共用一份侧栏
const page = readProjectFile("src/app/(app)/page.tsx");
const layout = readProjectFile("src/app/(app)/layout.tsx");
const shell = readProjectFile("src/hooks/use-home-shell-registration.ts");
assert.match(page, /const modalOpen = activeAccountDialog !== null \|\| onboardingPaywallOpen/);
assert.match(page, /<SidebarProvider escapeBlocked=\{accountMenuOpen \|\| modalOpen\}>/);
assert.match(page, /<main className="chat-app">[\s\S]*<AppSidebar\b/);
assert.match(
page,
/<SidebarInset className=\{`chat-panel\$\{rectificationSurfaceOpen \? " is-rectification" : ""\}`\} inert=\{modalOpen\}>/,
);
assert.match(layout, /<SidebarProvider escapeBlocked=\{registration\?\.escapeBlocked \?\? false\}>/);
assert.match(layout, /<main className="chat-app">[\s\S]*<AppSidebar\b/);
assert.match(layout, /insetClassName/);
assert.match(shell, /insetClassName: `chat-panel\$\{rectificationSurfaceOpen \? " is-rectification" : ""\}`/);
assert.match(page, /<SidebarTrigger placement="inset" \/>/);
assert.doesNotMatch(page, /<SidebarProvider/);
});
test("removes page-local mobile sidebar ownership", () => {
const page = readProjectFile("src/app/page.tsx");
const page = readProjectFile("src/app/(app)/page.tsx");
assert.doesNotMatch(page, /mobileSidebarOpen|setMobileSidebarOpen/);
assert.doesNotMatch(page, /className="sidebar-backdrop"/);
@@ -329,10 +333,12 @@ test("removes page-local mobile sidebar ownership", () => {
});
test("blocks the provider mobile Escape action behind layered account UI", () => {
const page = readProjectFile("src/app/page.tsx");
const layout = readProjectFile("src/app/(app)/layout.tsx");
const shell = readProjectFile("src/hooks/use-home-shell-registration.ts");
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
assert.match(page, /escapeBlocked=\{accountMenuOpen \|\| modalOpen\}/);
assert.match(shell, /escapeBlocked: accountMenuOpen \|\| modalOpen/);
assert.match(layout, /escapeBlocked=\{registration\?\.escapeBlocked \?\? false\}/);
assert.match(sidebar, /event\.key === "Escape" && isMobile && openMobile && !escapeBlocked/);
});
@@ -392,7 +398,7 @@ test("changes sidebar state without transition frames", () => {
});
test("keeps the chat title in the flexible left-aligned header column", () => {
const page = readProjectFile("src/app/page.tsx");
const page = readProjectFile("src/app/(app)/page.tsx");
const header = page.slice(page.indexOf('<header className="chat-header">'), page.indexOf("</header>", page.indexOf('<header className="chat-header">')));
assert.match(cssBlock(".chat-header"), /grid-template-columns:\s*auto\s+minmax\(0,\s*1fr\)\s+auto/);
-99
View File
@@ -1,99 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
SIDEBAR_CACHE_TTL_MS,
invalidateSidebarCache,
readSidebarCache,
sidebarCacheIsFresh,
writeSidebarCache,
} from "../src/lib/sidebar-data-cache.ts";
const hookSource = readFileSync(new URL("../src/hooks/use-sidebar-data.ts", import.meta.url), "utf8");
const sessionHookSource = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
const row = (id: string, title: string) => ({
id,
title,
pinned: false,
archived: false,
updatedAt: 1,
});
const account = {
name: "示例账户",
email: "example@invalid.test",
credits: 0,
initial: "示",
avatar: null,
};
test("the read-only sidebar reuses a fresh entry instead of fetching again", () => {
invalidateSidebarCache();
assert.equal(readSidebarCache(), null);
writeSidebarCache({ accountId: "acct-1", sessions: [row("a", "第一条")], account, fetchedAt: 1_000 });
const entry = readSidebarCache();
assert.ok(entry);
assert.equal(entry.sessions[0]?.title, "第一条");
// Inside the window: the hook returns without touching the network.
assert.equal(sidebarCacheIsFresh(entry, 1_000), true);
assert.equal(sidebarCacheIsFresh(entry, 1_000 + SIDEBAR_CACHE_TTL_MS - 1), true);
// Past it: shown from cache first, refreshed in the background — exactly one
// refetch, because the write that follows resets `fetchedAt`.
assert.equal(sidebarCacheIsFresh(entry, 1_000 + SIDEBAR_CACHE_TTL_MS), false);
assert.equal(sidebarCacheIsFresh(entry, 60_000_000), false);
assert.equal(sidebarCacheIsFresh(null, 1_000), false);
// A clock that jumped backwards must not read as fresh forever.
assert.equal(sidebarCacheIsFresh(entry, 0), false);
});
test("a session write on `/` makes the next secondary page read the new title", () => {
invalidateSidebarCache();
writeSidebarCache({ accountId: "acct-1", sessions: [row("a", "旧标题")], account, fetchedAt: 1_000 });
assert.equal(readSidebarCache()?.sessions[0]?.title, "旧标题");
invalidateSidebarCache();
assert.equal(readSidebarCache(), null);
assert.equal(sidebarCacheIsFresh(readSidebarCache(), 1_001), false);
// Every write path on `/` calls it: create, rename, delete, pin, archive.
for (const marker of [
/await persistSession\(nextSession\);\n\s*\/\*[\s\S]*?\*\/\n\s*invalidateSidebarCache\(\);/,
/删除聊天记录失败"\);\n\s*invalidateSidebarCache\(\);/,
/\{ pinned: nextPinned \}, "update"\)\.then\(invalidateSidebarCache\)/,
/\{ archived_at: nextArchivedAt \}, "update"\)\.then\(invalidateSidebarCache\)/,
/\);\n\s*invalidateSidebarCache\(\);\n\s*return nextSession;/,
]) {
assert.match(sessionHookSource, marker);
}
});
test("a second account in the same tab never reads the first one's rows", () => {
invalidateSidebarCache();
writeSidebarCache({ accountId: "acct-1", sessions: [row("a", "甲的对话")], account, fetchedAt: 1_000 });
writeSidebarCache({ accountId: "acct-2", sessions: [row("b", "乙的对话")], account, fetchedAt: 2_000 });
assert.equal(readSidebarCache()?.accountId, "acct-2");
assert.equal(readSidebarCache()?.sessions[0]?.title, "乙的对话");
invalidateSidebarCache();
});
test("the cache is memory only, and a 401 empties it", () => {
// Nothing durable: a stale list surviving a browser restart is worse than one
// fetch, and it would outlive a sign-out.
const cacheSource = readFileSync(new URL("../src/lib/sidebar-data-cache.ts", import.meta.url), "utf8");
assert.doesNotMatch(cacheSource, /localStorage|sessionStorage|document\.cookie|indexedDB/i);
assert.match(hookSource, /if \(sidebarCacheIsFresh\(cached, Date\.now\(\)\)\) return;/);
assert.match(hookSource, /invalidateSidebarCache\(\);\n\s*setState\(\{ sessions: \[\], account: null, settled: true, signedOut: true \}\);/);
// Read synchronously at mount so a cached list is on screen in the first
// frame — no skeleton, no spinner, per the unified-loading ruling.
assert.match(hookSource, /useState<SidebarDataState>\(\(\) => \{[\s\S]*?readSidebarCache\(\)/);
assert.doesNotMatch(hookSource, /skeleton|Spinner/i);
});
@@ -8,7 +8,7 @@ const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "
const siteStyles = readFileSync(new URL("../src/app/site-styles.ts", import.meta.url), "utf8");
const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8");
const adminCss = readFileSync(new URL("../src/app/admin/admin.css", import.meta.url), "utf8");
const homePage = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const homePage = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8");
test("Inter is loaded through next/font and applied as a CSS variable", () => {
@@ -54,7 +54,12 @@ test("site chrome owns globals.css; admin does not import it", () => {
assert.match(siteStyles, /import "\.\/globals\.css"/);
assert.match(siteStyles, /import "\.\/birth-time-choice\.css"/);
assert.doesNotMatch(layout, /globals\.css|birth-time-choice\.css|site-styles/);
assert.match(homePage, /import "@\/app\/site-styles"/);
// 原值:首页 page.tsx import site-styles
// 新值:(app)/layout.tsx import,首页不再各自引进
// 原因:T2 四页共用一份外壳
const appLayout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
assert.match(appLayout, /import "\.\.\/site-styles"/);
assert.doesNotMatch(homePage, /site-styles/);
assert.match(loginPage, /import "\.\.\/site-styles"/);
assert.doesNotMatch(adminLayout, /globals\.css|site-styles/);
assert.doesNotMatch(
+3 -3
View File
@@ -10,7 +10,7 @@ import {
shouldReloadFrozenBootstrap,
} from "../src/lib/stale-client-recovery.ts";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const layoutSource = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
const nextConfig = readFileSync(new URL("../next.config.ts", import.meta.url), "utf8");
const recoverySource = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8");
@@ -73,10 +73,10 @@ test("the web build stamps the Git SHA as Next deploymentId and does not cache t
assert.doesNotMatch(layoutSource, /export const dynamic = "force-dynamic"/);
const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8");
// 原值:`../src/app/reports/page.tsx`
// 新值:`../src/app/(secondary)/reports/page.tsx`
// 新值:`../src/app/(app)/reports/page.tsx`
// 原因:四个次级路由移进 `(secondary)` 路由组共享一份外壳(TASK-sidebar-unify D2)。
// 路由组括号不进 URL,`/reports` 一字未改;这里改的只是源码位置。
const reportsPage = readFileSync(new URL("../src/app/(secondary)/reports/page.tsx", import.meta.url), "utf8");
const reportsPage = readFileSync(new URL("../src/app/(app)/reports/page.tsx", import.meta.url), "utf8");
const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8");
assert.match(loginPage, /export const dynamic = "force-dynamic"/);
assert.match(reportsPage, /export const dynamic = "force-dynamic"/);