fix(web): summarize session titles, sort by activity, and paginate history (BUG-553)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-06 14:34:30 +08:00
co-authored by Cursor
parent d04990fc4a
commit a1956deb63
36 changed files with 1089 additions and 64 deletions
@@ -71,9 +71,9 @@ test("free Agentic rectification turns bypass reservation, completion, and cance
test("standard consultation resolves and settles the session-pinned model version", () => {
assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/);
// Former value: select("id,model_id,model_config_version,session_type") without messages.
// Task 2 reads the last 12 stored messages as model history, so this select now includes messages.
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages"\)/);
// Former value: select("id,model_id,model_config_version,session_type,messages").
// First-round session titles need the current title, theme, and chart role.
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role"\)/);
assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
assert.match(consultRoute, /actualModelId: selectedModel\.id/);
assert.match(consultRoute, /modelConfigVersion: selectedModel\.configVersion/);
@@ -6,6 +6,8 @@ import {
chartLibraryOnCloudFailure,
chartLibrarySessionBranch,
} from "../src/lib/chart-library-session.ts";
import { sessionSidebarSubtitle, sessionSidebarTitle } from "../src/lib/home-profile.ts";
import { emptyProfile, type ChartLibraryRecord, type ChatSession } from "../src/lib/home-types.ts";
type RecordShape = { id: string; role: "self" | "other" };
@@ -53,3 +55,42 @@ test("a failed cloud read keeps only the profile-derived self chart", () => {
{ id: "self", role: "self" },
]);
});
test("sidebar titles drop the chart-name prefix and only subtitle others", () => {
const selfRecord: ChartLibraryRecord = {
id: "self",
role: "self",
relationship: "self",
updatedAt: 1,
profile: { ...emptyProfile, name: "本人" },
};
const library: ChartLibraryRecord[] = [selfRecord];
const base: ChatSession = {
id: "11111111-1111-4111-8111-111111111111",
title: "半年内换工作时机",
theme: "career",
modelId: "m",
messages: [],
updatedAt: 1,
sessionType: "consultation",
rectificationCaseId: null,
chartProfileId: "self",
chartProfileName: "本人",
chartProfileRole: "self",
pinned: false,
archivedAt: null,
messagesHydrated: true,
};
assert.equal(sessionSidebarTitle(base, library), "半年内换工作时机");
assert.equal(sessionSidebarSubtitle(base, library), null);
const other = { ...base, chartProfileId: "other-1", chartProfileName: "对方", chartProfileRole: "other" as const };
assert.equal(sessionSidebarSubtitle(other, library), "资料已删除 · 对方");
const liveOtherLibrary: ChartLibraryRecord[] = [{
...selfRecord,
id: "other-1",
role: "other",
relationship: "partner",
profile: { ...emptyProfile, name: "对方" },
}];
assert.equal(sessionSidebarSubtitle(other, liveOtherLibrary), "对方");
});
@@ -77,3 +77,13 @@ test("pin and archive flags are session metadata, not localStorage", () => {
assert.doesNotMatch(page, /localStorage\.setItem\(`\$\{prefix\}pinned`/);
assert.doesNotMatch(page, /writeSynastryHistory\(/);
});
test("session list GET pages by cursor and returns pinned on the first page", () => {
assert.match(listRoute, /clampSessionLimit/);
assert.match(listRoute, /parseSessionCursor/);
assert.match(listRoute, /eq\("pinned", false\)/);
assert.match(listRoute, /eq\("pinned", true\)/);
assert.match(listRoute, /isArchivedSessionQuery/);
assert.match(listRoute, /nextCursor/);
assert.match(listRoute, /limit \+ 1/);
});
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { chatSessionCreateSchema, chatSessionMetadataPatchSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts";
import { metadataUpdateValues } from "../src/lib/session-metadata-update.ts";
import { homeSurface } from "./home-surface.ts";
const sessionId = "11111111-1111-4111-8111-111111111111";
@@ -230,3 +231,11 @@ test("self-hosted staging bootstrap reads profile and sessions through same-orig
assert.match(accountRoute, /profile,/);
assert.doesNotMatch(accountRoute, /rectificationCase/);
});
test("metadata PATCH no longer writes updated_at", () => {
const values = metadataUpdateValues({ title: "半年内换工作时机" });
assert.ok(values);
assert.equal("updated_at" in values, false);
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
assert.doesNotMatch(itemRoute, /updated_at: new Date\(\)\.toISOString\(\)/);
});
+3 -2
View File
@@ -136,8 +136,9 @@ test("the first default consultation title is persisted with the user question",
// completed metadata patch only updates title/theme/model/chart binding.
assert.equal(sendSource.indexOf("await persistSession(userSession)"), -1);
assert.match(sendSource, /await persistSession\(completedSession\)/);
assert.match(sendSource, /const completedTitle = reply\.title && !isGenericSessionTitle\(reply\.title\)/);
assert.match(sendSource, /resolveSessionTitle\(question, reply\.title/);
assert.match(sendSource, /const modelTitle = streamedTitle \?\? reply\.title/);
assert.match(sendSource, /const completedTitle = modelTitle && !isGenericSessionTitle\(modelTitle\)/);
assert.match(sendSource, /resolveSessionTitle\(question, modelTitle/);
});
test("a truncated generation keeps the partial answer and does not wait for a successful run", () => {
+57
View File
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clampSessionLimit,
compareSessionCursor,
encodeSessionCursor,
nextSessionCursor,
parseSessionCursor,
SESSION_PAGE_SIZE,
} from "../src/lib/session-cursor.ts";
const leftId = "11111111-1111-4111-8111-111111111111";
const rightId = "22222222-2222-4222-8222-222222222222";
const stamp = "2026-09-06T04:00:00.000Z";
test("encodeSessionCursor round-trips a valid pair", () => {
const encoded = encodeSessionCursor(stamp, leftId);
assert.deepEqual(parseSessionCursor(encoded), { updatedAt: stamp, id: leftId });
});
test("parseSessionCursor returns null for illegal strings", () => {
assert.equal(parseSessionCursor(null), null);
assert.equal(parseSessionCursor(""), null);
assert.equal(parseSessionCursor("not-a-date,11111111-1111-4111-8111-111111111111"), null);
assert.equal(parseSessionCursor(`${stamp},not-a-uuid`), null);
assert.equal(parseSessionCursor(stamp), null);
});
test("compareSessionCursor uses id when the timestamp is the same", () => {
const sameTime = compareSessionCursor(
{ updatedAt: stamp, id: leftId },
{ updatedAt: stamp, id: rightId },
);
assert.ok(sameTime > 0);
const newerTime = compareSessionCursor(
{ updatedAt: "2026-09-06T05:00:00.000Z", id: leftId },
{ updatedAt: stamp, id: rightId },
);
assert.ok(newerTime < 0);
});
test("clampSessionLimit defaults to 40 and clamps 1100", () => {
assert.equal(clampSessionLimit(null), SESSION_PAGE_SIZE);
assert.equal(clampSessionLimit("0"), 1);
assert.equal(clampSessionLimit("101"), 100);
assert.equal(clampSessionLimit("40"), 40);
});
test("nextSessionCursor is null when the page is short, else encodes the limit-th row", () => {
const rows = [
{ id: leftId, updated_at: stamp },
{ id: rightId, updated_at: stamp },
];
assert.equal(nextSessionCursor(rows, 40), null);
assert.equal(nextSessionCursor(rows, 1), encodeSessionCursor(stamp, leftId));
});
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
beginSessionPageLoad,
groupSessionsByRecency,
mergeSessionPage,
recencyKeyFor,
sortSessions,
} from "../src/lib/session-groups.ts";
function session(id: string, updatedAt: number, pinned = false) {
return { id, pinned, updatedAt };
}
test("sortSessions puts pinned ahead of a newer unpinned row", () => {
const olderPinned = session("pin", 100, true);
const newer = session("new", 500);
assert.deepEqual(sortSessions([newer, olderPinned]).map((item) => item.id), ["pin", "new"]);
});
test("sortSessions orders same pin state by updatedAt descending", () => {
const early = session("early", 100);
const late = session("late", 400);
assert.deepEqual(sortSessions([early, late]).map((item) => item.id), ["late", "early"]);
});
test("sortSessions keeps the original order when pin and time are equal", () => {
const first = session("a", 200);
const second = session("b", 200);
assert.deepEqual(sortSessions([first, second]).map((item) => item.id), ["a", "b"]);
});
test("sortSessions does not drop archived rows", () => {
const archived = { ...session("arc", 50), archivedAt: "2026-09-01T00:00:00.000Z" };
const live = session("live", 80);
assert.deepEqual(sortSessions([archived, live]).map((item) => item.id), ["live", "arc"]);
});
test("groupSessionsByRecency uses local midnight for yesterday across 23:59 to 00:01", () => {
const now = new Date(2026, 8, 6, 0, 1, 0).getTime();
const justYesterday = new Date(2026, 8, 5, 23, 59, 0).getTime();
const justToday = new Date(2026, 8, 6, 0, 0, 0).getTime();
assert.equal(recencyKeyFor(justYesterday, now), "yesterday");
assert.equal(recencyKeyFor(justToday, now), "today");
});
test("groupSessionsByRecency splits day 7 and day 8, and day 30 and day 31", () => {
const now = new Date(2026, 8, 6, 12, 0, 0).getTime();
const todayStart = new Date(2026, 8, 6).getTime();
const dayMs = 24 * 60 * 60 * 1000;
const day7 = todayStart - 6 * dayMs + 12 * 60 * 60 * 1000;
const day8 = todayStart - 7 * dayMs + 12 * 60 * 60 * 1000;
const day30 = todayStart - 29 * dayMs + 12 * 60 * 60 * 1000;
const day31 = todayStart - 30 * dayMs + 12 * 60 * 60 * 1000;
assert.equal(recencyKeyFor(day7, now), "week");
assert.equal(recencyKeyFor(day8, now), "month");
assert.equal(recencyKeyFor(day30, now), "month");
assert.equal(recencyKeyFor(day31, now), "older");
const groups = groupSessionsByRecency([
session("today", now),
session("week", day7),
session("older", day31),
], now);
assert.deepEqual(groups.map((group) => group.label), ["今天", "最近 7 天", "更早"]);
});
test("mergeSessionPage keeps the local row and skips a duplicate id", () => {
const local = session("keep", 900);
const incoming = [session("keep", 100), session("next", 80)];
assert.deepEqual(mergeSessionPage([local], incoming).map((item) => item.id), ["keep", "next"]);
assert.equal(mergeSessionPage([local], incoming)[0]?.updatedAt, 900);
});
test("beginSessionPageLoad only starts one in-flight request", () => {
const inFlight = { current: false };
assert.equal(beginSessionPageLoad(inFlight, "cursor"), true);
assert.equal(beginSessionPageLoad(inFlight, "cursor"), false);
assert.equal(beginSessionPageLoad({ current: false }, null), false);
});
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
generateSessionTitle,
sanitizeSessionTitle,
shouldGenerateSessionTitle,
} from "../src/lib/session-title-agent.ts";
import { consultationAgentPublicEventSchema } from "../src/lib/consultation-agent-events.ts";
test("sanitizeSessionTitle strips quotes and trailing punctuation", () => {
assert.equal(sanitizeSessionTitle("「半年内换工作时机」。"), "半年内换工作时机");
assert.equal(sanitizeSessionTitle("\"半年内换工作时机\""), "半年内换工作时机");
});
test("sanitizeSessionTitle clips titles longer than 14 characters", () => {
assert.equal(sanitizeSessionTitle("今年下半年要不要换工作以及去哪座城市"), "今年下半年要不要换工作以及去");
});
test("sanitizeSessionTitle rejects short, wrapped, generic, or birth-stamped titles", () => {
assert.equal(sanitizeSessionTitle("问"), null);
assert.equal(sanitizeSessionTitle("换工作\n时机"), null);
assert.equal(sanitizeSessionTitle("新对话"), null);
assert.equal(sanitizeSessionTitle("1990年3月换工作"), null);
assert.equal(sanitizeSessionTitle("凌晨 04:50 的节奏"), null);
});
test("sanitizeSessionTitle keeps a valid summary", () => {
assert.equal(sanitizeSessionTitle("半年内换工作时机"), "半年内换工作时机");
});
test("shouldGenerateSessionTitle only runs on a first-round auto title", () => {
assert.equal(shouldGenerateSessionTitle({ title: "新对话", sessionType: "consultation" }, []), true);
assert.equal(shouldGenerateSessionTitle({ title: "我想问一下最近半年换工…", sessionType: "consultation" }, []), true);
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: "半年内换工作时机", sessionType: "consultation" }, []), false);
});
test("generateSessionTitle times out to null and keeps the timer ref'd", async () => {
const source = readFileSync(new URL("../src/lib/session-title-agent.ts", import.meta.url), "utf8");
assert.doesNotMatch(source, /AbortSignal\.timeout/);
assert.doesNotMatch(source, /\.unref\(/);
assert.match(source, /clearTimeout/);
const pending = new Set<unknown>();
let created = 0;
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
globalThis.setTimeout = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => {
created += 1;
const id = realSetTimeout(handler, delay, ...args);
pending.add(id);
return id;
}) as typeof setTimeout;
globalThis.clearTimeout = ((id?: ReturnType<typeof setTimeout>) => {
pending.delete(id);
realClearTimeout(id);
}) as typeof clearTimeout;
try {
const title = await generateSessionTitle({
question: "我想问一下最近半年换工作的时机",
timeoutMs: 20,
generateText: () => new Promise(() => {}),
});
assert.equal(title, null);
assert.ok(created >= 1);
assert.equal(pending.size, 0);
} finally {
globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout;
}
});
test("consultation public events include session.title", () => {
const parsed = consultationAgentPublicEventSchema.parse({
type: "session.title",
title: "半年内换工作时机",
});
assert.equal(parsed.type, "session.title");
assert.equal(parsed.title, "半年内换工作时机");
});
+10 -1
View File
@@ -190,7 +190,7 @@ test("uses one collapsed history action instead of icon-only session rows", () =
assert.match(appSidebar, /MessageSquareText/);
assert.match(appSidebar, /state === "collapsed" && !isMobile/);
assert.match(appSidebar, /favoriteSessions\.map/);
assert.match(appSidebar, /historySessions\.map/);
assert.match(appSidebar, /historyGroups\.map/);
assert.match(appSidebar, /星盘列表/);
assert.match(appSidebar, /收藏对话/);
assert.match(appSidebar, /历史对话/);
@@ -449,3 +449,12 @@ test("removes class-owned drawer state and obsolete sidebar anchoring", () => {
assert.doesNotMatch(globalStyles, /\.account-menu\s*\{/);
assert.doesNotMatch(globalStyles, /\.session-list\s*\{[^}]*overflow(?:-y)?:\s*auto/);
});
test("history renders recency group labels and a silent load-more sentinel", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
assert.match(appSidebar, /groupSessionsByRecency/);
assert.match(appSidebar, /sidebar-group-label/);
assert.match(appSidebar, /session-list-sentinel/);
assert.match(appSidebar, /sessionControls\.hasMore \? <div ref=\{loadMoreRef\}/);
assert.doesNotMatch(appSidebar, /加载更多|没有更多/);
});
+19
View File
@@ -5,6 +5,8 @@ import {
shouldHandleSidebarShortcut,
sidebarViewportForWidth,
} from "../src/lib/sidebar-state.ts";
import { beginSessionPageLoad, mergeSessionPage } from "../src/lib/session-groups.ts";
import { homeSurface } from "./home-surface.ts";
test("classifies the exact sidebar breakpoints", () => {
assert.equal(sidebarViewportForWidth(0), "mobile");
@@ -28,3 +30,20 @@ test("accepts Command or Control B only outside editable controls", () => {
assert.equal(shouldHandleSidebarShortcut({ ...shortcut, target: { isContentEditable: true } }), false);
assert.equal(shouldHandleSidebarShortcut({ ...shortcut, key: "k", target: null }), false);
});
test("renaming a session does not bump updatedAt", () => {
assert.match(homeSurface, /const nextSession = \{ \.\.\.session, title \};/);
assert.doesNotMatch(homeSurface, /const nextSession = \{ \.\.\.session, title, updatedAt: timestamp\(\) \};/);
});
test("loadMoreSessions merges by id and only starts one in-flight request", () => {
const inFlight = { current: false };
assert.equal(beginSessionPageLoad(inFlight, "cursor-1"), true);
assert.equal(beginSessionPageLoad(inFlight, "cursor-1"), false);
const merged = mergeSessionPage(
[{ id: "keep", updatedAt: 900 }],
[{ id: "keep", updatedAt: 1 }, { id: "next", updatedAt: 2 }],
);
assert.deepEqual(merged.map((item) => item.id), ["keep", "next"]);
assert.equal(merged[0]?.updatedAt, 900);
});