fix(home): stop silent rectification open after login (BUG-599)
Bare `/` was treating the latest history session as active, so a returning account could auto-open a failed rectification Case and left-align the starter home. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
+48
-10
@@ -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() {
|
||||
</header>
|
||||
|
||||
{!rectificationSurfaceOpen && (
|
||||
<div ref={conversation} className={`conversation${!activeSession?.messages.length && Boolean(activeSession?.messagesHydrated) && !onboardingFormActive ? " is-empty" : ""}${!profileComplete ? " is-onboarding" : ""}${onboardingFormActive ? " is-onboarding-form" : ""}`}>
|
||||
<div ref={conversation} className={`conversation${!onboardingFormActive && !activeSession?.messages.length && (Boolean(activeSession?.messagesHydrated) || activeSession?.sessionType !== "consultation") ? " is-empty" : ""}${!profileComplete ? " is-onboarding" : ""}${onboardingFormActive ? " is-onboarding-form" : ""}`}>
|
||||
{!activeSession?.messages.length ? (
|
||||
<div className="welcome">
|
||||
{!profileComplete ? (
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
|
||||
@@ -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"\)/);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user