diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 60b59033..47926597 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -9286,3 +9286,19 @@ - 复发自:无 - 修复版本:待发布 +## BUG-599 | 回访登录自动打开生时校正并挤歪首页 + +- 状态:resolved +- 首次发现:2026-09-09 +- 最近更新:2026-09-09 +- 影响面:登录后首页、`resolveBootstrapSessionSelection`、生时校正 Case open、`.welcome` 布局 +- 用户现象:以前登录过的账户一进首页,主栏内容挤到左边;用户未点任何入口就出现「校正服务暂时不可用」。 +- 触发条件:登录落到 `/`(没有 `?c=`)。会话列表第一项是历史生时校正;该会话列表不带消息,校正会话又跳过消息水合。 +- 根因:bootstrap 把 `sessions[0]` 当成当前会话。校正会话会在 prepare 阶段自动 `POST /api/rectification/cases/open`。打开失败时 catch-all 文案是「校正服务暂时不可用」,并画在首页卡片下方。同时 `.conversation.is-empty` 要求 `messagesHydrated`,校正会话保持 false,首页 1040px 容器没有水平居中。 +- 修复:仅当地址栏 `?c=` 指向该校正会话时才自动打开。默认 `/` 落地改到空咨询会话(没有则新建)。`.welcome` / `.starter-list` 增加 `margin-inline: auto`;空校正会话在未打开 surface 时也使用 `is-empty`。 +- 验证:`frontend/tests/home-bootstrap-reveal.test.ts`、`frontend/tests/rectification-surface-contract.test.ts`、`frontend/tests/session-conversation-layout.test.ts`。 +- 防复发:裸 `/` 不得因最近一条历史是生时校正就调用 Case open;首页居中不得依赖校正会话的 `messagesHydrated`。 +- 相关记录:BUG-027、BUG-034 +- 复发自:无 +- 修复版本:待发布 + diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c347ce86..05749ff4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1903,11 +1903,13 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class /* Starter workbench */ .welcome { width: min(1040px, 100%); + margin-inline: auto; padding: var(--space-8) 0 var(--space-12); } .starter-list { width: min(1040px, 100%); + margin-inline: auto; display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-6); @@ -2229,6 +2231,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .composer-wrap-starter .composer, .composer-wrap-starter .composer-footer { width: min(1040px, 100%); + margin-inline: auto; } @media (min-width: 768px) and (max-width: 900px) { diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 491119e9..cf092267 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -231,7 +231,10 @@ import { bootstrapLoadingCopy, bootstrapPrepareSettled, bootstrapRevealDelayMs, + resolveStarterHomeLandingSessionId, sessionIdsToPrefetch, + shouldAutoOpenRectificationSession, + starterHomeLandingNeedsConsultation, type BootstrapPhase, } from "@/lib/home-bootstrap"; @@ -520,16 +523,19 @@ export default function Home() { return () => window.removeEventListener("popstate", onPopState); }, [hydrated]); - // A rectification session selected at bootstrap (deep link, refresh, the - // most recent session) is opened and hydrated during the prepare phase, so - // the reveal shows the surface itself rather than a plain transcript that is - // swapped out a moment later. After the reveal the same effect serves - // history navigation. + // A rectification session named in `?c=` (refresh, deep link, login return) + // is opened during prepare so the reveal shows the surface. Bare `/` after + // login must not open the latest history item in the background. useEffect(() => { if ((!hydrated && bootstrapPhase === "account") || !account || !modelCatalog - || activeSession?.sessionType !== "birth_time_rectification" + || !activeSession + || !shouldAutoOpenRectificationSession({ + sessionType: activeSession.sessionType, + sessionId: activeSession.id, + search: window.location.search, + }) || activeSession.id === rectificationSessionId || rectificationLoading || rectificationMutationPending @@ -671,7 +677,11 @@ export default function Home() { dailyStarlanguageApplicable: Boolean(accountId) && profileComplete && natalMinuteAvailable, dailyStarlanguageSettled: dailyStarlanguage.kind !== "pending", entrySummarySettled: rectificationEntrySummarySettled, - rectificationApplicable: activeSession?.sessionType === "birth_time_rectification", + rectificationApplicable: shouldAutoOpenRectificationSession({ + sessionType: activeSession?.sessionType, + sessionId: activeSession?.id, + search: typeof window === "undefined" ? "" : window.location.search, + }), rectificationSettled: activeSession?.id === rectificationSessionId || rectificationError !== "" || !profileComplete, @@ -1013,7 +1023,35 @@ export default function Home() { search: window.location.search, storedReturnId: readLoginSessionReturn(), }); - const activeListed = nextSessions.find((session) => session.id === bootstrapSelection.sessionId) + let landingSessionId = resolveStarterHomeLandingSessionId( + nextSessions, + bootstrapSelection.sessionId, + bootstrapSelection.urlAction, + ); + if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) { + if (controller.signal.aborted) return; + const homeSession = createSession( + nextModelCatalog?.defaultModelId ?? "", + "consultation", + chartSnapshotForSession("self", [], nextProfile), + ); + if (nextModelCatalog) { + await writeChatSession(homeSession.id, { + title: homeSession.title, + theme: homeSession.theme, + model_id: homeSession.modelId, + messages: [], + session_type: homeSession.sessionType, + rectification_case_id: homeSession.rectificationCaseId, + chart_profile_id: homeSession.chartProfileId, + chart_profile_name: homeSession.chartProfileName, + chart_profile_role: homeSession.chartProfileRole, + }, "create"); + } + nextSessions = [homeSession, ...nextSessions]; + landingSessionId = homeSession.id; + } + const activeListed = nextSessions.find((session) => session.id === landingSessionId) ?? nextSessions[0]; if (activeListed && !activeListed.messagesHydrated && activeListed.sessionType === "consultation") { try { @@ -1035,7 +1073,7 @@ export default function Home() { setStartGreeting(nextProfile.name.trim() ? createStartGreeting(nextProfile.name) : ""); setOnboardingStep(missingProfileStep(nextProfile) ?? "name"); setSessions(nextSessions); - setActiveSessionId(bootstrapSelection.sessionId); + setActiveSessionId(landingSessionId); if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn(); if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace"); if (bootstrapSelection.urlAction === "replace-selected") writeSessionUrl(bootstrapSelection.sessionId, "replace"); @@ -1744,7 +1782,7 @@ export default function Home() { {!rectificationSurfaceOpen && ( -
+
{!activeSession?.messages.length ? (
{!profileComplete ? ( diff --git a/frontend/src/lib/home-bootstrap.ts b/frontend/src/lib/home-bootstrap.ts index b39285b2..e577bd30 100644 --- a/frontend/src/lib/home-bootstrap.ts +++ b/frontend/src/lib/home-bootstrap.ts @@ -8,8 +8,62 @@ * settled, or when the prepare budget runs out — never with a spinner inside. */ +import { parseSessionUrlQuery } from "./chat-session-url.ts"; + export type BootstrapPhase = "account" | "prepare"; +export type LandingSession = Readonly<{ + id: string; + sessionType: string; + messages: readonly unknown[]; +}>; + +export type LandingUrlAction = "keep" | "replace-selected" | "replace-clear" | "none"; + +/** + * A rectification Case is opened only when the address bar names that session + * (`?c=`), including refresh and a login return that restored the URL. Bare `/` + * after login must not open the latest history item in the background. + */ +export function shouldAutoOpenRectificationSession(input: { + readonly sessionType: string | undefined; + readonly sessionId: string | undefined; + readonly search: string; +}): boolean { + if (input.sessionType !== "birth_time_rectification" || !input.sessionId) return false; + return parseSessionUrlQuery(input.search).sessionId === input.sessionId; +} + +/** + * Default `/` landing prefers an empty consultation so the starter home can + * center. Deep links and login-return URLs keep the requested session. + */ +export function resolveStarterHomeLandingSessionId( + sessions: readonly LandingSession[], + selectedId: string, + urlAction: LandingUrlAction, +): string { + if (urlAction === "keep" || urlAction === "replace-selected") return selectedId; + const selected = sessions.find((session) => session.id === selectedId); + if (selected?.sessionType !== "birth_time_rectification") return selectedId; + const emptyConsultation = sessions.find( + (session) => session.sessionType === "consultation" && session.messages.length === 0, + ); + if (emptyConsultation) return emptyConsultation.id; + const anyConsultation = sessions.find((session) => session.sessionType === "consultation"); + return anyConsultation?.id ?? selectedId; +} + +export function starterHomeLandingNeedsConsultation( + sessions: readonly LandingSession[], + landingId: string, + urlAction: LandingUrlAction, +): boolean { + if (urlAction === "keep" || urlAction === "replace-selected") return false; + const landing = sessions.find((session) => session.id === landingId); + return !landing || landing.sessionType === "birth_time_rectification"; +} + export const BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000; export const SESSION_PREFETCH_COUNT = 5; @@ -19,7 +73,7 @@ export type BootstrapPrepareState = Readonly<{ dailyStarlanguageApplicable: boolean; dailyStarlanguageSettled: boolean; entrySummarySettled: boolean; - /** The selected session is a rectification session: its Case must be open and hydrated before the reveal. */ + /** A URL-named rectification session must be opened and hydrated before reveal. */ rectificationApplicable?: boolean; rectificationSettled?: boolean; }>; diff --git a/frontend/tests/chat-session-url.test.ts b/frontend/tests/chat-session-url.test.ts index 55603071..f1125119 100644 --- a/frontend/tests/chat-session-url.test.ts +++ b/frontend/tests/chat-session-url.test.ts @@ -117,7 +117,7 @@ test("default bootstrap selection does not write a session URL", () => { const bootstrap = sourceBetween(page, "async function loadCloudData()", "void loadCloudData();"); assert.match(bootstrap, /defaultSessionId: nextSessions\[0\]\.id/); - assert.match(bootstrap, /setActiveSessionId\(bootstrapSelection\.sessionId\)/); + assert.match(bootstrap, /setActiveSessionId\(landingSessionId\)/); assert.match(bootstrap, /urlAction === "replace-clear"/); assert.match(bootstrap, /urlAction === "replace-selected"/); assert.doesNotMatch(bootstrap, /writeSessionUrl\([^)]*, "push"\)/); diff --git a/frontend/tests/home-bootstrap-reveal.test.ts b/frontend/tests/home-bootstrap-reveal.test.ts index cd396578..ce54fe0e 100644 --- a/frontend/tests/home-bootstrap-reveal.test.ts +++ b/frontend/tests/home-bootstrap-reveal.test.ts @@ -8,7 +8,10 @@ import { bootstrapLoadingCopy, bootstrapPrepareSettled, bootstrapRevealDelayMs, + resolveStarterHomeLandingSessionId, sessionIdsToPrefetch, + shouldAutoOpenRectificationSession, + starterHomeLandingNeedsConsultation, } from "../src/lib/home-bootstrap.ts"; import { homeSurface } from "./home-surface.ts"; @@ -22,6 +25,65 @@ const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", i 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"); +const rectificationId = "11111111-1111-4111-8111-111111111111"; +const consultationId = "22222222-2222-4222-8222-222222222222"; +const emptyConsultationId = "33333333-3333-4333-8333-333333333333"; + +test("bare / after login does not auto-open the latest rectification session", () => { + assert.equal(shouldAutoOpenRectificationSession({ + sessionType: "birth_time_rectification", + sessionId: rectificationId, + search: "", + }), false); + assert.equal(shouldAutoOpenRectificationSession({ + sessionType: "birth_time_rectification", + sessionId: rectificationId, + search: `?c=${rectificationId}`, + }), true); + assert.equal(shouldAutoOpenRectificationSession({ + sessionType: "birth_time_rectification", + sessionId: rectificationId, + search: `?c=${consultationId}`, + }), false); + assert.equal(shouldAutoOpenRectificationSession({ + sessionType: "consultation", + sessionId: consultationId, + search: `?c=${consultationId}`, + }), false); +}); + +test("default landing replaces a latest rectification session with an empty consultation", () => { + const sessions = [ + { id: rectificationId, sessionType: "birth_time_rectification", messages: [] }, + { id: emptyConsultationId, sessionType: "consultation", messages: [] }, + { id: consultationId, sessionType: "consultation", messages: [{ role: "user" }] }, + ]; + assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "none"), emptyConsultationId); + assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "replace-clear"), emptyConsultationId); + assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "keep"), rectificationId); + assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "replace-selected"), rectificationId); + assert.equal(resolveStarterHomeLandingSessionId(sessions, consultationId, "none"), consultationId); + assert.equal( + starterHomeLandingNeedsConsultation( + [{ id: rectificationId, sessionType: "birth_time_rectification", messages: [] }], + rectificationId, + "none", + ), + true, + ); + assert.equal( + starterHomeLandingNeedsConsultation(sessions, emptyConsultationId, "none"), + false, + ); + assert.equal( + starterHomeLandingNeedsConsultation(sessions, rectificationId, "keep"), + false, + ); + assert.match(page, /resolveStarterHomeLandingSessionId\(/); + assert.match(page, /shouldAutoOpenRectificationSession\(/); + assert.match(page, /starterHomeLandingNeedsConsultation\(/); +}); + test("prepare phase settles only when every applicable item has an answer", () => { const base = { profileComplete: true, diff --git a/frontend/tests/rectification-surface-contract.test.ts b/frontend/tests/rectification-surface-contract.test.ts index 39c1864f..99f3d303 100644 --- a/frontend/tests/rectification-surface-contract.test.ts +++ b/frontend/tests/rectification-surface-contract.test.ts @@ -45,11 +45,13 @@ test("the rectification surface is revealed once: Case hydration precedes the sw assert.match(chat, /useEffect\(\(\) => \(\) => \{\s*runAbort\.current\?\.abort\(\);\s*snapshotAbort\.current\?\.abort\(\);\s*\}, \[\]\);/); }); -test("a rectification session selected at bootstrap is hydrated before the reveal, and popstate defers the same way", () => { +test("a rectification session named in the URL is hydrated before the reveal; bare / does not", () => { // The resume effect runs during the prepare phase, not only after the reveal. assert.match(page, /if \(\(!hydrated && bootstrapPhase === "account"\)\s*\|\| !account/); + assert.match(page, /shouldAutoOpenRectificationSession\(\{/); assert.match(bootstrap, /if \(state\.rectificationApplicable && !state\.rectificationSettled\) return false;/); - assert.match(page, /rectificationApplicable: activeSession\?\.sessionType === "birth_time_rectification",\s*rectificationSettled: activeSession\?\.id === rectificationSessionId\s*\|\| rectificationError !== ""\s*\|\| !profileComplete,/); + assert.match(page, /rectificationApplicable: shouldAutoOpenRectificationSession\(\{/); + assert.match(page, /rectificationSettled: activeSession\?\.id === rectificationSessionId\s*\|\| rectificationError !== ""\s*\|\| !profileComplete,/); // One deadline constant for hydration and the home reveal. assert.match(read("../src/lib/rectification-surface-state.ts"), /export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = BOOTSTRAP_PREPARE_TIMEOUT_MS;/); // History navigation goes through selectSession, which defers the switch (locked above). diff --git a/frontend/tests/session-conversation-layout.test.ts b/frontend/tests/session-conversation-layout.test.ts index 6426f60b..8f24c584 100644 --- a/frontend/tests/session-conversation-layout.test.ts +++ b/frontend/tests/session-conversation-layout.test.ts @@ -22,6 +22,16 @@ test("keeps onboarding transcript and intake card on the same session column", ( assert.match(globalStyles, /\.conversation\.is-onboarding-form \.welcome \{[\s\S]*padding:\s*var\(--space-6\) var\(--session-column-gutter\) var\(--space-8\)/); }); +test("centers the starter home even when the conversation grid is not is-empty", () => { + const pageSource = readProjectFile("src/app/page.tsx"); + assert.match(globalStyles, /\.welcome \{\s*width:\s*min\(1040px, 100%\);\s*margin-inline:\s*auto;/); + assert.match(globalStyles, /\.starter-list \{\s*width:\s*min\(1040px, 100%\);\s*margin-inline:\s*auto;/); + assert.match( + pageSource, + /!onboardingFormActive && !activeSession\?\.messages\.length && \(Boolean\(activeSession\?\.messagesHydrated\) \|\| activeSession\?\.sessionType !== "consultation"\) \? " is-empty"/, + ); +}); + test("keeps message motion restrained and honors reduced-motion preferences", () => { assert.match(messageRowSource, /gsap\.matchMedia\(\)/); assert.match(messageRowSource, /prefers-reduced-motion:\s*no-preference/);