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
+11
View File
@@ -456,6 +456,17 @@ one skeleton, none of them carrying the sidebar.
`/?c=<uuid>`; the footer is a link to `/`. Renaming, pinning, archiving and
deleting stay on `/`, backed by `Home()`'s optimistic-update and rollback
layer, which is far more than these four routes need.
- **New chat is not a home link.** The read-only 新建对话 action links to
`/?new=1`; Home consumes that intent before activation and opens a fresh local
consultation. It clears `new`, any `c`, and the login-return stub. Before the
first question there is no saved session, no `?c=`, and no new history row.
The first question uses the existing persistence flow. A reserved older
consultation keeps recovering in the background without taking over this
explicit new-chat landing or leaving its startup recovery notice there.
The account footer
still links to `/`, and a bare-home refresh keeps its existing landing rule.
The same action and mobile drawer-close behavior are retained; no extra
button, copy, loading state, or visual treatment is introduced.
- **Leaving the chat is a navigation, not a reload.** 星盘 / 星历 / 我的报告 are
`AppLink` on every page, including `/`, where they used to be a full document
load that threw away the React tree, the session list and the account. Only
+5
View File
@@ -1062,6 +1062,7 @@ export default function Home() {
setStartGreeting(nextProfile.name.trim() ? createStartGreeting(nextProfile.name) : "");
setOnboardingStep(missingProfileStep(nextProfile) ?? "name");
setSessions(nextSessions);
if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace");
setActiveSessionId(landingSessionId);
if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn();
if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace");
@@ -1074,6 +1075,10 @@ export default function Home() {
if (reservedConsultation?.status === "reserved") {
const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId);
if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending);
if (bootstrapSelection.urlAction === "new-chat") {
setActiveSessionId(landingSessionId);
setComposerNotice("");
}
}
if (modelCatalogResult.unavailable) {
setComposerNotice("模型服务暂时不可用,当前无法发送问题。");
+2 -2
View File
@@ -19,7 +19,7 @@ import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
import type { Ref } from "react";
import { AppLink } from "@/components/app-link";
import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url";
import { newChatHref, persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url";
import { prefetchSecondaryPage } from "@/lib/secondary-page-data";
import {
Sidebar,
@@ -248,7 +248,7 @@ export function AppSidebar({
{showExpandedContent ? <span>{creatingSession ? "正在创建" : "新建对话"}</span> : null}
</SidebarMenuButton>
) : (
<SidebarMenuLink className="new-chat" tooltip="新对话" href="/" onClick={closeDrawer}>
<SidebarMenuLink className="new-chat" tooltip="新对话" href={newChatHref()} onClick={closeDrawer}>
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>新建对话</span> : null}
</SidebarMenuLink>
+20 -1
View File
@@ -1,4 +1,5 @@
export const SESSION_URL_QUERY_KEY = "c";
export const NEW_CHAT_QUERY_KEY = "new";
export const SESSION_URL_RETURN_STORAGE_KEY = "jyotisha.session-url-return";
export const SESSION_MISSING_NOTICE = "该对话不存在或已被删除";
export const SESSION_LOOKUP_FAILED_NOTICE = "这条对话暂时读不到,请稍后重试。";
@@ -11,11 +12,20 @@ export type SessionUrlQuery = {
export type BootstrapSessionSelection = {
readonly sessionId: string;
readonly urlAction: "keep" | "replace-selected" | "replace-clear" | "none" | "lookup";
readonly urlAction: "keep" | "replace-selected" | "replace-clear" | "none" | "lookup" | "new-chat";
readonly missing: boolean;
readonly clearStoredReturn: boolean;
};
export function newChatHref(): string {
return "/?new=1";
}
export function parseNewChatIntent(search: string): boolean {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
return params.has(NEW_CHAT_QUERY_KEY);
}
export function parseSessionUrlQuery(search: string): SessionUrlQuery {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
if (!params.has(SESSION_URL_QUERY_KEY)) {
@@ -30,6 +40,7 @@ export function parseSessionUrlQuery(search: string): SessionUrlQuery {
export function sessionHref(search: string, sessionId: string | null, pathname = "/"): string {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
params.delete(NEW_CHAT_QUERY_KEY);
if (sessionId) params.set(SESSION_URL_QUERY_KEY, sessionId);
else params.delete(SESSION_URL_QUERY_KEY);
const query = params.toString();
@@ -79,6 +90,14 @@ export function resolveBootstrapSessionSelection(input: {
readonly search: string;
readonly storedReturnId: string | null;
}): BootstrapSessionSelection {
if (parseNewChatIntent(input.search)) {
return {
sessionId: input.defaultSessionId,
urlAction: "new-chat",
missing: false,
clearStoredReturn: true,
};
}
const query = parseSessionUrlQuery(input.search);
if (query.present) {
if (query.sessionId && input.listedIds.includes(query.sessionId)) {
+3 -2
View File
@@ -18,7 +18,7 @@ export type LandingSession = Readonly<{
messages: readonly unknown[];
}>;
export type LandingUrlAction = "keep" | "replace-selected" | "replace-clear" | "none" | "lookup";
export type LandingUrlAction = "keep" | "replace-selected" | "replace-clear" | "none" | "lookup" | "new-chat";
/**
* A rectification Case is opened only when the address bar names that session
@@ -43,7 +43,7 @@ export function resolveStarterHomeLandingSessionId(
selectedId: string,
urlAction: LandingUrlAction,
): string {
if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup") {
if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup" || urlAction === "new-chat") {
return selectedId;
}
const selected = sessions.find((session) => session.id === selectedId);
@@ -61,6 +61,7 @@ export function starterHomeLandingNeedsConsultation(
landingId: string,
urlAction: LandingUrlAction,
): boolean {
if (urlAction === "new-chat") return true;
if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup") return false;
const landing = sessions.find((session) => session.id === landingId);
return !landing || landing.sessionType === "birth_time_rectification";
+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="\/"/);