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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-16 02:02:22 +00:00
co-authored by Claude Opus 5
parent e6c6839742
commit bdb81c8c86
3 changed files with 72 additions and 37 deletions
+10 -14
View File
@@ -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<ChatSession[]>([]);
const [showArchivedSessions, setShowArchivedSessions] = useState(false);
const [sessionsCursor, setSessionsCursor] = useState<string | null>(null);
const [sessionMenuId, setSessionMenuId] = useState<string | null>(null);
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(null);
const [activeSessionId, setActiveSessionId] = useState("");
const draftTheme = useRef<Theme | null>(null);
@@ -295,8 +290,6 @@ export default function Home() {
const prepareStartedAt = useRef<number | null>(null);
const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState<string | null>(null);
const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null);
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep>("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),
});
+25 -15
View File
@@ -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<SetStateAction<string>>;
setRectificationErrorSessionId: Dispatch<SetStateAction<string | null>>;
setRequestError: Dispatch<SetStateAction<RequestError | null>>;
setSessionDetailLoadingId: Dispatch<SetStateAction<string | null>>;
setSessionFullPrompt: Dispatch<SetStateAction<{ question: string; theme: Theme } | null>>;
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
sessionsCursor: string | null;
setSessionsCursor: Dispatch<SetStateAction<string | null>>;
showArchivedSessions: boolean;
setShowArchivedSessions: Dispatch<SetStateAction<boolean>>;
uiPreview: MutableRefObject<boolean>;
visibleSessions: ChatSession[];
openRectificationSession: (exactSessionId: string) => Promise<void> | 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<string | null>(null);
const [showArchivedSessions, setShowArchivedSessions] = useState(false);
const [sessionMenuId, setSessionMenuId] = useState<string | null>(null);
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState<string | null>(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,
@@ -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<Type>(` 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,/);
});