From bdb81c8c86e3a7f79d9a205bd557e4c09fe12501 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 01:50:11 +0000 Subject: [PATCH] refactor(home): let useSessionManagement own six session states 5.4: sessionsCursor / showArchivedSessions / sessionMenuId / pendingSessionDeletion / sessionDetailLoadingId / sessionFullPrompt move into use-session-management.ts, which now also computes visibleSessions from the archived flag it owns. Home reads back only what its own JSX renders. sessions / activeSessionId / creatingSession / pendingSessionId stay in the shell: each is read by the bootstrap effect or the consultation run before the session hook is called. Reasons are listed one by one in the progress record. useSessionManagement params 41 -> 34, useProfileOnboarding 38 -> 26. The growth contract tightens to useState 36 / useRef 37 / lines 1846 and gains an assertion that useSessionManagement still runs before useRectificationSurface, with the opener handed over through rectificationSessionOpenerRef. Zero behavior change, zero copy change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- frontend/src/app/page.tsx | 24 +++++----- frontend/src/hooks/use-session-management.ts | 40 ++++++++++------- .../tests/home-shell-growth-contract.test.ts | 45 +++++++++++++++---- 3 files changed, 72 insertions(+), 37 deletions(-) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index e95043f0..c930b8a3 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -77,7 +77,6 @@ import { } from "@/hooks/use-rectification-surface"; import { useSynastry } from "@/hooks/use-synastry"; import { useSessionManagement } from "@/hooks/use-session-management"; -import { sortSessions } from "@/lib/session-groups"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { chatReplyAnnouncement, type ChatReplyPhase } from "@/lib/chat-reply-announcement"; import { @@ -252,10 +251,6 @@ export default function Home() { const [accountError, setAccountError] = useState(""); const [signingOut, setSigningOut] = useState(false); const [sessions, setSessions] = useState([]); - const [showArchivedSessions, setShowArchivedSessions] = useState(false); - const [sessionsCursor, setSessionsCursor] = useState(null); - const [sessionMenuId, setSessionMenuId] = useState(null); - const [pendingSessionDeletion, setPendingSessionDeletion] = useState(null); const [modelCatalog, setModelCatalog] = useState(null); const [activeSessionId, setActiveSessionId] = useState(""); const draftTheme = useRef(null); @@ -295,8 +290,6 @@ export default function Home() { const prepareStartedAt = useRef(null); const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false); const [creatingSession, setCreatingSession] = useState(false); - const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState(null); - const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null); const [onboardingStep, setOnboardingStep] = useState("name"); const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false); const [onboardingPaywallOpen, setOnboardingPaywallOpen] = useState(false); @@ -390,11 +383,6 @@ export default function Home() { const activeRectificationSession = activeSession?.sessionType === "birth_time_rectification"; const rectificationSurfaceOpen = activeRectificationSession && activeSession.id === rectificationSessionId; - const visibleSessions = sortSessions(sessions.filter((session) => (showArchivedSessions ? session.archivedAt : !session.archivedAt) - && (session.sessionType === "birth_time_rectification" - || session.messages.length > 0 - || !session.messagesHydrated - || session.id === activeSessionId))); const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : ""; const isLoading = pendingSessionId === activeSession?.id; const productEntrypointsDisabled = !hydrated @@ -439,6 +427,15 @@ export default function Home() { ? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。" : rectificationError; const { + visibleSessions, + sessionsCursor, + setSessionsCursor, + showArchivedSessions, + sessionMenuId, + setSessionMenuId, + pendingSessionDeletion, + setPendingSessionDeletion, + setSessionFullPrompt, updateSession, persistSession, ensureSessionMessages, @@ -457,8 +454,7 @@ export default function Home() { rectificationSessionId, sessionDetailInFlight, sessionSelectionSource, sessions, sessionsRef, setActiveChartId, setActiveSessionId, setBirthTimeConsultationConsent, setCreatingSession, setDraft, setDraftEntrypoint, setDraftTheme, setRectificationError, setRectificationErrorSessionId, setRequestError, - setSessionDetailLoadingId, setSessionFullPrompt, setSessions, sessionsCursor, setSessionsCursor, - showArchivedSessions, setShowArchivedSessions, uiPreview, visibleSessions, + setSessions, uiPreview, openRectificationSession: (exactSessionId) => rectificationSessionOpenerRef.current(exactSessionId), }); diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index 43a9de62..af027c98 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -1,7 +1,7 @@ "use client"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; -import { useRef } from "react"; +import { useRef, useState } from "react"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { writeChatSession } from "@/lib/chat-session-write-contract"; @@ -12,6 +12,7 @@ import { writeSessionUrl, } from "@/lib/chat-session-url"; import { consultationReportMarkdown } from "@/lib/consultation-report-export"; +import { sortSessions } from "@/lib/session-groups"; import { clearBirthTimeConsultationConsent, type BirthTimeConsultationConsentState, @@ -76,15 +77,8 @@ export type SessionManagementParams = { setRectificationError: Dispatch>; setRectificationErrorSessionId: Dispatch>; setRequestError: Dispatch>; - setSessionDetailLoadingId: Dispatch>; - setSessionFullPrompt: Dispatch>; setSessions: Dispatch>; - sessionsCursor: string | null; - setSessionsCursor: Dispatch>; - showArchivedSessions: boolean; - setShowArchivedSessions: Dispatch>; uiPreview: MutableRefObject; - visibleSessions: ChatSession[]; openRectificationSession: (exactSessionId: string) => Promise | void; }; @@ -121,20 +115,27 @@ export function useSessionManagement(params: SessionManagementParams) { setRectificationError, setRectificationErrorSessionId, setRequestError, - setSessionDetailLoadingId, - setSessionFullPrompt, setSessions, - sessionsCursor, - setSessionsCursor, - showArchivedSessions, - setShowArchivedSessions, uiPreview, - visibleSessions, openRectificationSession, } = params; + // Owned here since 2026-09-16 (state lowering batch 2). Home reads back only + // what its own JSX renders; the rest never leaves this hook. + const [sessionsCursor, setSessionsCursor] = useState(null); + const [showArchivedSessions, setShowArchivedSessions] = useState(false); + const [sessionMenuId, setSessionMenuId] = useState(null); + const [pendingSessionDeletion, setPendingSessionDeletion] = useState(null); + const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState(null); + const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null); sessionsRef.current = sessions; const loadMoreInFlight = useRef(false); + const visibleSessions = sortSessions(sessions.filter((session) => (showArchivedSessions ? session.archivedAt : !session.archivedAt) + && (session.sessionType === "birth_time_rectification" + || session.messages.length > 0 + || !session.messagesHydrated + || session.id === activeSessionId))); + function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) { setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); } @@ -520,6 +521,15 @@ export function useSessionManagement(params: SessionManagementParams) { } } return { + visibleSessions, + sessionsCursor, + setSessionsCursor, + showArchivedSessions, + sessionMenuId, + setSessionMenuId, + pendingSessionDeletion, + setPendingSessionDeletion, + setSessionFullPrompt, updateSession, persistSession, ensureSessionMessages, diff --git a/frontend/tests/home-shell-growth-contract.test.ts b/frontend/tests/home-shell-growth-contract.test.ts index a63b9b1e..e390e2dc 100644 --- a/frontend/tests/home-shell-growth-contract.test.ts +++ b/frontend/tests/home-shell-growth-contract.test.ts @@ -4,16 +4,19 @@ import test from "node:test"; const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); -// Measured 2026-09-16 after home-state-lowering on origin/staging @ 3b17c1b2. -// Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1931. +// Measured 2026-09-16 after home-state-lowering batch 2 on origin/staging @ 37e6c519. +// Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1846. // Hook counts use \buseState[<(] / \buseRef[<(] so `useState(` is not missed. -// Former caps were useState 66 / useRef 41 / lines 1951 on freeze-metric @ 51a65d92. -// This round moved 14 rectification useStates out of Home (15 → 1 shell object) -// and 2 rectification refs into useRectificationSurface. -const PAGE_LINE_COUNT_BASELINE = 1931; +// Former caps were useState 52 / useRef 39 / lines 1931 after batch 1 @ f8e607c2, +// and useState 66 / useRef 41 / lines 1951 on freeze-metric @ 51a65d92. +// Batch 2 moved 4 synastry useStates into useSynastry, 6 profile useStates into +// useProfileOnboarding and 6 session useStates into useSessionManagement, and +// dropped the two guided-birth-time callback refs that the profile hook no longer +// needs now that it runs before useBirthTimeGuidedJourney. +const PAGE_LINE_COUNT_BASELINE = 1846; const PAGE_LINE_COUNT_CAP = PAGE_LINE_COUNT_BASELINE + 150; -const HOME_USE_STATE_CAP = 52; -const HOME_USE_REF_CAP = 39; +const HOME_USE_STATE_CAP = 36; +const HOME_USE_REF_CAP = 37; const USE_STATE_RE = /\buseState[<(]/g; const USE_REF_RE = /\buseRef[<(]/g; @@ -52,3 +55,29 @@ test("page.tsx line count stays within the coarse guardrail", () => { `page.tsx has ${n} lines; cap is ${PAGE_LINE_COUNT_CAP} (${PAGE_LINE_COUNT_BASELINE} baseline + 150).`, ); }); + +test("useSessionManagement still runs before useRectificationSurface", () => { + // Batch 1 established the order: useSessionManagement needs an opener for + // history rectification sessions, and useRectificationSurface is what supplies + // it, so the opener is handed over through rectificationSessionOpenerRef + // instead of by calling the surface hook first. Batch 2 moved hook calls + // around; this pins the constraint so a later reorder cannot silently break + // opening a rectification session from the sidebar. + const home = homeSource(pageSource); + const sessionCall = home.indexOf("= useSessionManagement({"); + const surfaceCall = home.indexOf("= useRectificationSurface({"); + const openerRef = home.indexOf("const rectificationSessionOpenerRef = useRef("); + assert.notEqual(sessionCall, -1, "Home must call useSessionManagement"); + assert.notEqual(surfaceCall, -1, "Home must call useRectificationSurface"); + assert.notEqual(openerRef, -1, "Home must declare rectificationSessionOpenerRef"); + assert.ok(openerRef < sessionCall, "the opener ref must exist before useSessionManagement reads it"); + assert.ok( + sessionCall < surfaceCall, + "useSessionManagement must run before useRectificationSurface; it receives the opener through rectificationSessionOpenerRef", + ); + assert.match( + home.slice(sessionCall, surfaceCall), + /openRectificationSession: \(exactSessionId\) => rectificationSessionOpenerRef\.current\(exactSessionId\)/, + ); + assert.match(home.slice(surfaceCall), /rectificationSessionOpenerRef,/); +});