fix(chat): honor new-chat intent from secondary pages
Independent Staging Quality Gate / validate (push) Successful in 13m41s
Independent Staging Quality Gate / publish (push) Successful in 3m30s

Create a fresh local consultation for explicit new-chat navigation and keep reserved recovery from taking over its landing. Add regression tests and record validation gaps for remote review.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
jesse-ux
2026-09-24 00:25:28 +08:00
co-authored by Claude Code
parent 1c95eb3979
commit 8902e48468
17 changed files with 368 additions and 6 deletions
+53
View File
@@ -3,6 +3,9 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import {
NEW_CHAT_QUERY_KEY,
newChatHref,
parseNewChatIntent,
SESSION_LOOKUP_FAILED_NOTICE,
SESSION_MISSING_NOTICE,
SESSION_URL_QUERY_KEY,
@@ -30,6 +33,56 @@ function sourceBetween(source: string, startMarker: string, endMarker: string) {
return source.slice(start, end);
}
test("new chat intent is presence-based and has one canonical href", () => {
assert.equal(NEW_CHAT_QUERY_KEY, "new");
assert.equal(newChatHref(), "/?new=1");
for (const search of ["?new=1", "?new=", "?new", "new=0"]) {
assert.equal(parseNewChatIntent(search), true);
}
assert.equal(parseNewChatIntent(""), false);
assert.equal(parseNewChatIntent("?renew=1"), false);
});
test("new chat intent overrides listed, unlisted and invalid c plus login return", () => {
for (const requestedId of [sessionB, sessionC, "not-a-uuid"]) {
assert.deepEqual(resolveBootstrapSessionSelection({
listedIds: [sessionA, sessionB],
defaultSessionId: sessionA,
search: `?new=1&c=${requestedId}`,
storedReturnId: sessionB,
}), {
sessionId: sessionA,
urlAction: "new-chat",
missing: false,
clearStoredReturn: true,
});
}
});
test("clearing a new chat intent removes c but preserves unrelated query keys", () => {
assert.equal(sessionHref("?new=1&x=1", null), "/?x=1");
assert.equal(sessionHref(`?new=&c=${sessionA}&x=1`, null), "/?x=1");
assert.equal(sessionHref("?new=1", null), "/");
});
test("persisting a new chat replaces the intent with the saved session id", () => {
assert.equal(sessionHref("?new=1", sessionA), `/?c=${sessionA}`);
assert.equal(sessionHref(`?new=1&c=${sessionB}`, sessionA), `/?c=${sessionA}`);
});
test("new chat bootstrap consumes the URL before activation and reuses local-only creation", () => {
const bootstrap = sourceBetween(page, "async function loadCloudData()", "void loadCloudData();");
const clearIntent = 'if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace");';
assert.ok(bootstrap.includes(clearIntent));
assert.ok(bootstrap.indexOf(clearIntent) < bootstrap.indexOf("setActiveSessionId(landingSessionId)"));
assert.match(bootstrap, /if \(bootstrapSelection\.clearStoredReturn\) clearLoginSessionReturn\(\)/);
const landing = sourceBetween(bootstrap, "if (starterHomeLandingNeedsConsultation(", "const activeListed");
assert.equal((landing.match(/createSession\(/g) ?? []).length, 1);
assert.match(landing, /nextSessions = \[homeSession, \.\.\.nextSessions\]/);
assert.match(landing, /landingSessionId = homeSession\.id/);
assert.doesNotMatch(landing, /persistSession|writeChatSession|fetch\(|writeSessionUrl/);
});
test("bootstrap reads a listed ?c= session and ignores login storage", () => {
assert.deepEqual(
resolveBootstrapSessionSelection({
@@ -84,6 +84,25 @@ test("default landing replaces a latest rectification session with an empty cons
assert.match(page, /starterHomeLandingNeedsConsultation\(/);
});
test("new chat landing requests a fresh consultation even with an existing ordinary chat", () => {
const sessions = [
{ id: consultationId, sessionType: "consultation", messages: [{ role: "user" }] },
{ id: emptyConsultationId, sessionType: "consultation", messages: [] },
{ id: rectificationId, sessionType: "birth_time_rectification", messages: [] },
];
for (const session of sessions) {
assert.equal(resolveStarterHomeLandingSessionId(sessions, session.id, "new-chat"), session.id);
assert.equal(starterHomeLandingNeedsConsultation(sessions, session.id, "new-chat"), true);
}
assert.equal(starterHomeLandingNeedsConsultation([], consultationId, "new-chat"), true);
});
test("bare home still opens the selected ordinary chat without creating another", () => {
const sessions = [{ id: consultationId, sessionType: "consultation", messages: [{ role: "user" }] }];
assert.equal(resolveStarterHomeLandingSessionId(sessions, consultationId, "none"), consultationId);
assert.equal(starterHomeLandingNeedsConsultation(sessions, consultationId, "none"), false);
});
test("prepare phase settles only when every applicable item has an answer", () => {
// 原值: BootstrapPrepareState 含 profileComplete + onboardingSettled,未完成建议问题不得揭幕。
// 新值: 揭幕只等入口摘要、今日星语(若适用)与校正会话(若适用)。
+79
View File
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { createSession } from "../src/lib/home-cloud-sync.ts";
import { isUnsavedEmptyConsultation } from "../src/lib/session-list-filter.ts";
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const run = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8");
function sourceBetween(source: string, start: string, end: string): string {
const from = source.indexOf(start);
const to = source.indexOf(end, from);
assert.ok(from >= 0 && to > from, `missing source boundary: ${start}`);
return source.slice(from, to);
}
function bootstrapWithRecovery(urlAction: "new-chat" | "none") {
// Exercise the real recovery function and Home's activation block together.
// The synthetic ordinary chat has no engine payload or private user data.
const oldSession = createSession("test-model");
oldSession.messages = [{ role: "user", text: "测试问题" }];
const localSession = createSession("test-model");
const pending: { current: { sessionId: string; phase: string } | null } = { current: null };
const state = { active: "", pendingSession: "", pendingRequest: "", phase: "", notice: "", storedCleared: false };
const writes: unknown[][] = [];
const recovery = sourceBetween(run, " function restoreConsultationRecovery(", " consultationRecoveryCheck.current =");
const activation = sourceBetween(page, " if (bootstrapSelection.urlAction === \"new-chat\") writeSessionUrl", " if (modelCatalogResult.unavailable)");
const compiled = ts.transpileModule(`${recovery}\n${activation}`, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
}).outputText;
runInNewContext(compiled, {
AbortController,
navigator: { onLine: true },
pendingConsultation: pending,
bootstrapSelection: { urlAction, clearStoredReturn: urlAction === "new-chat" },
landingSessionId: localSession.id,
nextSessions: [localSession, oldSession],
reservedConsultation: { status: "reserved", sessionId: oldSession.id, requestId: "test-request" },
storedPending: null,
consultSendBlockedByRectificationSession: () => false,
isUnsavedEmptyConsultation,
setActiveSessionId: (id: string) => { state.active = id; },
setPendingSessionId: (id: string) => { state.pendingSession = id; },
setPendingRequestId: (id: string) => { state.pendingRequest = id; },
setConsultationPhase: (phase: string) => { state.phase = phase; },
setComposerNotice: (notice: string) => { state.notice = notice; },
setStreamingReply: () => {},
clearLoginSessionReturn: () => { state.storedCleared = true; },
writeSessionUrl: (...args: unknown[]) => { writes.push(args); },
updateSession: () => { assert.fail("the existing user question must not be duplicated"); },
});
return { state, pending, writes, localSession, oldSession };
}
test("new chat keeps its local landing while a reserved older consultation recovers", () => {
const { state, pending, writes, localSession, oldSession } = bootstrapWithRecovery("new-chat");
assert.equal(state.active, localSession.id);
assert.equal(state.notice, "");
assert.equal(state.storedCleared, true);
assert.deepEqual(writes, [[null, "replace"]]);
assert.equal(isUnsavedEmptyConsultation(localSession), true);
assert.equal(state.pendingSession, oldSession.id);
assert.equal(state.pendingRequest, "test-request");
assert.equal(state.phase, "recovering");
assert.equal(pending.current?.sessionId, oldSession.id);
assert.equal(pending.current?.phase, "recovering");
});
test("ordinary home landing still activates and announces the reserved consultation", () => {
const { state, pending, writes, oldSession } = bootstrapWithRecovery("none");
assert.equal(state.active, oldSession.id);
assert.equal(state.notice, "回答仍在后台生成,正在自动恢复。");
assert.equal(state.storedCleared, false);
assert.deepEqual(writes, []);
assert.equal(pending.current?.sessionId, oldSession.id);
});
+4
View File
@@ -238,6 +238,10 @@ test("the same component renders read-only when `/` is not the one mounting it",
const readonlyRow = sessionRow.slice(sessionRow.indexOf('data-readonly="true"'), sessionRow.indexOf("const {\n disabled,"));
assert.doesNotMatch(readonlyRow, /session-menu-trigger/);
// Only the new-chat action carries intent; the footer remains a home link.
assert.match(appSidebar, /<SidebarMenuLink className="new-chat"[^>]*href=\{newChatHref\(\)\}[^>]*onClick=\{closeDrawer\}/);
assert.doesNotMatch(appSidebar, /<SidebarMenuLink className="new-chat"[^>]*href="\/"/);
// The footer is the same 56px `.profile-trigger`, as a link with no chevron.
assert.match(appSidebar, /<AppLink\n\s*className="profile-trigger"\n\s*href="\/"/);