人物目录没到时不再当成没建完档(U11)。

目录还没回来时不闪「看谁的盘」,热启动也不因此走冷路径。已知是空的,仍然算没建完档。月亮星座和天空副句的上升,首页没有现成资料,不新发请求,也不编星座。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-27 01:23:18 +08:00
co-authored by Grok (agent loop)
parent b2a561b34c
commit d7e8cc46c2
15 changed files with 168 additions and 50 deletions
@@ -85,3 +85,21 @@
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 这六处加上登录动作 | `node node_modules/tsx/dist/cli.mjs --test` 盘型、导出、星盘页、账户、外观、登录、登录动作 | 62 通过,0 失败,0 取消,退出码 0 |
## U11 · 人物目录没到时不当成没建完档
没有新的产品故障,所以没有新的 BUG 编号。这三处里,目录这一处已经按「未知」处理。月亮星座和天空副句的星座,首页手里没有现成资料,不能新发请求,也不能编一个星座。
人物目录还没回来时,不再把它当成「没有别人」。只有名字、盘在别人名下的账户,不会在揭幕后闪出「看谁的盘」,热启动也不会因此改走冷路径。目录确实是空的,仍然算没建完档。揭幕会等到目录回来再显示,所以不会先闪一屏再改口。
起步第一问的「只有月亮」那一档,句子本来就会写。星座目前只从今日星语记住。没有出生分钟时,今日星语不请求。账户、档案、本命盘缓存里都没有月亮星座。星盘页那份结果要有分钟才会有,而且上升和月亮一起都有,对不上「只有月亮」。按任务书不能为了这一问再发请求,也不能临时编一个星座。所以这一档在第一次进首页、又没有分钟时,仍然走不到。有分钟时,今日星语回来后,上升和月亮两档照旧会写上。
帮别人看的天空副句,句子是「那一刻,{名字}的上升在{星座}」。名字在,上升星座不在,这一行就不显示,原来的天空句子照常播。上升同样只在已有的星座记录里,没有就空着。没有为了这一行再排一次盘。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 目录未知、已知空列表、副句,以及被改到的首页合同 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test`:`home-warm-snapshot`、`onboarding-intake-20261022`、`home-first-load-20260930`、`home-bootstrap-run`、`composer-isolation-contract`、`chat-composer-queue`、`birth-time-consultation-consent`、`home-shell-growth-contract` | 75 通过,0 失败,0 取消,退出码 0 |
断言三栏(引导页是否预载):原值是目录还没到也按空列表预载。新值是已知空列表才预载,未知不预载、步骤停在称呼。原因是未知不能当成没建完档。
断言三栏(输入框标签和禁用):原值认 `onboardingStep`。新值认 `shownOnboardingStep`,并在目录未到时不收起输入框。原因同上。称呼和发送的两句文案没改。
+1 -1
View File
@@ -40,7 +40,7 @@
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` |
| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` |
| `TASK-ui-kit-migration-20261023.md` | `PROGRESS-ui-kit-0-base-20261023.md`;`PROGRESS-ui-kit-1-shell-20261023.md`;`PROGRESS-ui-kit-2-chat-20261023.md`;`PROGRESS-ui-kit-3-chart-20261023.md`;`PROGRESS-ui-kit-4-people-20261023.md`;`PROGRESS-ui-kit-5-pages-20261023.md`;`PROGRESS-ui-kit-6-rectification-20261023.md`;`PROGRESS-ui-kit-7-cleanup-20261023.md` | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0–K7 已在测试环境,待验收 | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U6、U9、U10 已提交) | `codex/ui-kit-onboarding-fix-20261026` |
| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U6、U9、U10、U11 已提交) | `codex/ui-kit-onboarding-fix-20261026` |
| —(门禁修复,无新任务书) | `PROGRESS-birth-journey-contract-20261025.md` | **测试分支过不了门禁,网站停在完整数据版(二)**:BUG-1439 出生时间合同;BUG-1440 测试画布;BUG-1441 侧栏流式检查等第一次补画结束再数帧。页面不改 | 已合入 staging,等门禁 | `codex/gate-birth-journey-contract-20261025` |
| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md`;`PROGRESS-onboarding-4-motion-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022`。(四)T7 在 `codex/onboarding-4-motion-20261022` | (一)(二)(三)已在 staging;(四)本分支待验收 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
+29 -19
View File
@@ -43,8 +43,8 @@ import { useHomeShellRegistration } from "@/hooks/use-home-shell-registration";
import { useSessionList } from "@/hooks/use-session-list";
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { bindCurrentSubjectAccount, peekSubjectCatalog, readCurrentSubjectId, subscribeSubjectCatalog, useCurrentSubject, useCurrentSubjectProfile } from "@/lib/current-subject";
import { setOnboardingFinishedListener } from "@/lib/onboarding-intake";
import { bindCurrentSubjectAccount, peekSubjectCatalog, readCurrentSubjectId, subscribeSubjectCatalog, useCurrentSubject, useCurrentSubjectProfile, useCurrentSubjectReady } from "@/lib/current-subject";
import { readOnboardingIntake, setOnboardingFinishedListener } from "@/lib/onboarding-intake";
import { useSessionStore } from "@/stores/session-store";
import { useShallow } from "zustand/react/shallow";
import { notifyOnboardingDailyOpened } from "@/lib/onboarding-signals";
@@ -153,6 +153,7 @@ import { useChatProfileCatalog } from "@/hooks/use-chat-profile-catalog";
import {
birthQuestion,
isProfileComplete,
missingProfileStep,
placeQuestion,
readProfile,
selfChartComplete,
@@ -349,6 +350,7 @@ export default function Home() {
() => peekSubjectCatalog() ?? EMPTY_SUBJECT_CATALOG,
() => EMPTY_SUBJECT_CATALOG,
);
const subjectCatalogReady = useCurrentSubjectReady();
const currentSubjectId = useCurrentSubject();
const starterSignMap = useSyncExternalStore(subscribeStarterSigns, readStarterSignMap, readStarterSignMap);
const {
@@ -660,7 +662,15 @@ export default function Home() {
setChartLibrary((current) => upsertSelfChart(current, profileForLibrary));
}, [account, accountId, applyCloudSynastryHistory, beginChatProfileReload, clearSynastryHistory, markChatProfilesDegraded, markChatProfilesReady, profile]);
const profileComplete = isProfileComplete(profile, subjectCatalog);
// An empty stand-in is not a known empty list. Until the people list arrives,
// do not treat a name-only holder as unfinished (that flashes 「看谁的盘」).
const profileComplete = subjectCatalogReady && isProfileComplete(profile, subjectCatalog);
const catalogStep = subjectCatalogReady
? missingProfileStep(profile, subjectCatalog, accountId ? readOnboardingIntake(accountId) : null)
: null;
const shownOnboardingStep = onboardingStep === "name" && catalogStep && catalogStep !== "name"
? catalogStep
: onboardingStep;
const dailyProfile = useCurrentSubjectProfile(profile);
const birthTimeRoute = resolveBirthTimeConsultationRoute(dailyProfile ?? { ...profile, time: "", reportedTime: "", birthTimeStatus: "reported", birthTimeSource: "unknown" }, birthTimeConsultationConsent, activeSessionId);
const natalMinuteAvailable = birthTimeRoute.kind === "consult"
@@ -716,14 +726,14 @@ export default function Home() {
}, [hydrated]);
const onboardingAudience = intakeTarget === "other" ? "other" : "self";
const onboardingSubjectName = onboardingAudience === "other" ? onboardingDraft.name.trim() : profile.name.trim();
const currentOnboardingMessage = onboardingStep === "who"
const currentOnboardingMessage = shownOnboardingStep === "who"
? whoQuestion(profile.name.trim())
: onboardingStep === "birth"
: shownOnboardingStep === "birth"
? birthQuestion(onboardingSubjectName, onboardingAudience)
: onboardingStep === "place"
: shownOnboardingStep === "place"
? placeQuestion(onboardingDraft, onboardingAudience, onboardingSubjectName)
: presetOnboardingMessage;
const shouldStreamOnboarding = !profileComplete;
const shouldStreamOnboarding = subjectCatalogReady && !profileComplete;
const presetMessageFinished = !shouldStreamOnboarding || presetMessageLength >= currentOnboardingMessage.length;
const onboardingCardReady = presetMessageFinished || birthTimeAssessmentPhase !== null;
const starterHomeVisible = profileComplete
@@ -737,7 +747,7 @@ export default function Home() {
&& !activeSession.messagesHydrated
&& pendingSessionId !== activeSession.id,
);
const onboardingFormActive = !synastryChat && !profileComplete && onboardingStep !== "name";
const onboardingFormActive = !synastryChat && subjectCatalogReady && !profileComplete && shownOnboardingStep !== "name";
const starterHasPriorSessions = sessions.some((session) => session.id !== activeSession?.id);
const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection, starterHasPriorSessions);
const conversationAnchor = useConversationScrollAnchor(
@@ -941,10 +951,10 @@ export default function Home() {
}, [starterHomeVisible]);
useEffect(() => {
if (hydrated && accountId && !profileComplete && onboardingStep === "name" && presetMessageFinished && activeAccountDialog === null) {
if (hydrated && accountId && subjectCatalogReady && !profileComplete && shownOnboardingStep === "name" && presetMessageFinished && activeAccountDialog === null) {
composerInput.current?.focus({ preventScroll: true });
}
}, [accountId, activeAccountDialog, hydrated, onboardingStep, presetMessageFinished, profileComplete]);
}, [accountId, activeAccountDialog, hydrated, presetMessageFinished, profileComplete, shownOnboardingStep, subjectCatalogReady]);
useEffect(() => {
setOnboardingFinishedListener((subjectId) => {
@@ -1109,7 +1119,7 @@ export default function Home() {
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!synastryChat && !profileComplete) {
if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName();
if (shownOnboardingStep === "name" && presetMessageFinished) void saveOnboardingName();
return;
}
if (synastryClosed || subjectDeleted) return;
@@ -1159,7 +1169,7 @@ export default function Home() {
visibleSessions,
});
if (!hydrated || (!account && !accountError)) {
if (!hydrated || (!account && !accountError) || (Boolean(account) && !accountError && !subjectCatalogReady)) {
const loadingCopy = bootstrapLoadingCopy(bootstrapPhase);
return (
<main className="app-loading" aria-busy="true" aria-live="polite">
@@ -1303,7 +1313,7 @@ export default function Home() {
intakeTarget={intakeTarget}
onChooseSelf={chooseOnboardingSelf}
onChooseOther={chooseOnboardingOther}
onboardingStep={onboardingStep}
onboardingStep={shownOnboardingStep}
presetMessageLength={presetMessageLength}
currentOnboardingMessage={currentOnboardingMessage}
onboardingJustCompleted={onboardingJustCompleted}
@@ -1384,12 +1394,12 @@ export default function Home() {
{rectificationComposerReopen ? <RectificationComposerReopen message={rectificationErrorMessage} onReopen={() => void reopenRectificationSession(activeSession?.id || rectificationErrorSessionId || "")} /> : null}
<ChatComposer
inputRef={composerInput}
inputLabel={!synastryChat && !profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
placeholder={ordinaryComposerPlaceholder({ locked: rectificationComposerLocked, hasAccount: Boolean(account), profileComplete: synastryChat || profileComplete, onboardingName: onboardingStep === "name", presetMessageFinished, creditsEmpty: Boolean(account && account.credits === 0 && !account.activeSubscription && !account.firstConsultFree), subjectDeleted, synastryClosed })}
maxLength={!synastryChat && !profileComplete && onboardingStep === "name" ? 80 : 500}
inputDisabled={synastryClosed || subjectDeleted || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || (!synastryChat && !profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitLabel={!synastryChat && !profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
submitBlocked={synastryClosed || subjectDeleted || Boolean(pendingSessionId) || Boolean(queuedDraft) || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || !account || !modelCatalog || (!synastryChat && !profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
inputLabel={!synastryChat && !profileComplete && shownOnboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
placeholder={ordinaryComposerPlaceholder({ locked: rectificationComposerLocked, hasAccount: Boolean(account), profileComplete: synastryChat || profileComplete, onboardingName: shownOnboardingStep === "name", presetMessageFinished, creditsEmpty: Boolean(account && account.credits === 0 && !account.activeSubscription && !account.firstConsultFree), subjectDeleted, synastryClosed })}
maxLength={!synastryChat && !profileComplete && shownOnboardingStep === "name" ? 80 : 500}
inputDisabled={synastryClosed || subjectDeleted || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || (!synastryChat && !profileComplete && (shownOnboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitLabel={!synastryChat && !profileComplete && shownOnboardingStep === "name" ? "确认称呼" : "发送"}
submitBlocked={synastryClosed || subjectDeleted || Boolean(pendingSessionId) || Boolean(queuedDraft) || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || !account || !modelCatalog || (!synastryChat && !profileComplete && (shownOnboardingStep !== "name" || !presetMessageFinished || profileSaving))}
stopVisible={isLoading}
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
@@ -13,7 +13,7 @@ import { BIRTH_SKY_PALETTES, drawBirthSky, type BirthSkyCanvas } from "@/lib/bir
import type { BirthSkyRevealSky } from "@/lib/birth-sky/reveal";
import { birthSkyFactLine, birthSkyHeadline } from "@/lib/birth-sky/sentence";
import { peekSubjectCatalog, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject";
import { displaySign, readStarterSigns, subscribeStarterSigns } from "@/lib/starter-questions";
import { otherSkySubjectLine, readStarterSigns, subscribeStarterSigns } from "@/lib/starter-questions";
function useOtherSkySubjectLine(): string {
const subjectId = useCurrentSubject();
@@ -21,9 +21,7 @@ function useOtherSkySubjectLine(): string {
const signs = useSyncExternalStore(subscribeStarterSigns, () => readStarterSigns(subjectId), () => readStarterSigns(subjectId));
if (!subjectId || subjectId === "self") return "";
const name = catalog?.find((row) => row.id === subjectId)?.profile.name.trim() ?? "";
const sign = displaySign(signs.ascendantSign);
if (!name || !sign) return "";
return `那一刻,${name}的上升在${sign}`;
return otherSkySubjectLine(name, signs.ascendantSign);
}
/** Largest 3:4 box that fits the viewport, in CSS pixels. */
+17 -4
View File
@@ -43,7 +43,7 @@ import {
redirectToLogin,
resolveLookupBootstrap,
} from "@/lib/home-cloud-sync";
import { chartSnapshotForSession, isProfileComplete, missingProfileStep, readProfile } from "@/lib/home-profile";
import { chartSnapshotForSession, missingProfileStep, onboardingCatalogDecision, readProfile } from "@/lib/home-profile";
import { peekSubjectCatalog, readCurrentSubjectId } from "@/lib/current-subject";
import { readOnboardingIntake } from "@/lib/onboarding-intake";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
@@ -127,6 +127,8 @@ export type HomeBootstrapDeps = {
consultationStatusMissingCount: { current: number };
isDevelopment: boolean;
readSearch: () => string;
/** Defaults to the live people list. `null` is "not loaded", not "nobody else". */
readSubjectCatalog?: () => ReturnType<typeof peekSubjectCatalog>;
storage: HomeBootstrapStorage;
restoreConsultationRecovery: HomeBootstrapIo["restoreConsultationRecovery"];
setComposerNotice: (notice: string) => void;
@@ -219,6 +221,10 @@ async function resolveReservedConsultation(input: {
return { reservedConsultation, storedPending };
}
function subjectCatalogForOnboarding(deps: HomeBootstrapDeps) {
return deps.readSubjectCatalog ? deps.readSubjectCatalog() : peekSubjectCatalog();
}
export async function runAccountPhase(
deps: HomeBootstrapDeps,
signal: AbortSignal,
@@ -351,9 +357,9 @@ export async function runAccountPhase(
const nextProfile = readProfile(nextAccount.profile);
// The onboarding shell is no longer in the first-screen bundle (BUG-1130);
// someone who will see it gets it before reveal, not a blank frame after.
const onboardingOthers = peekSubjectCatalog() ?? [];
const onboardingHint = readOnboardingIntake(nextAccount.user.id);
if (!isProfileComplete(nextProfile, onboardingOthers, onboardingHint)) await io.preloadOnboardingShell();
const onboardingDecision = onboardingCatalogDecision(nextProfile, subjectCatalogForOnboarding(deps), onboardingHint);
if (onboardingDecision.known && onboardingDecision.step !== null) await io.preloadOnboardingShell();
if (signal.aborted) return { kind: "stop" };
const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog);
let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions;
@@ -444,7 +450,14 @@ export async function resolveLanding(
deps.commit.setProfile(ready.nextProfile);
deps.commit.setProfileDraft(ready.nextProfile);
deps.commit.setStartGreeting(ready.nextProfile.name.trim() ? createStartGreeting(ready.nextProfile.name) : "");
deps.commit.setOnboardingStep(missingProfileStep(ready.nextProfile, peekSubjectCatalog() ?? [], readOnboardingIntake(ready.nextAccount.user.id)) ?? "name");
const landingDecision = onboardingCatalogDecision(
ready.nextProfile,
subjectCatalogForOnboarding(deps),
readOnboardingIntake(ready.nextAccount.user.id),
);
// Unknown stays on the name placeholder. It must not commit 「看谁的盘」
// from an empty stand-in; the page shows the real step once the list arrives.
deps.commit.setOnboardingStep(landingDecision.known ? (landingDecision.step ?? "name") : "name");
deps.commit.setSessions(nextSessions);
if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace");
deps.commit.setActiveSessionId(landingSessionId);
+17
View File
@@ -401,3 +401,20 @@ export function isProfileComplete(
) {
return missingProfileStep(profile, others, hint) === null;
}
/**
* `others === null` means the people list has not arrived.
* That is not an empty list. An empty list means nobody else, so a name-only
* holder is still unfinished. Unknown must not be judged unfinished: an
* account whose only finished chart belongs to someone else would flash
* 「看谁的盘」 and would be refused a warm start.
* `step` is only meaningful when `known` is true. `null` then means finished.
*/
export function onboardingCatalogDecision(
profile: Profile,
others: readonly OnboardingOtherRecord[] | null,
hint: OnboardingIntakeHint | null = null,
): { readonly known: boolean; readonly step: OnboardingStep | null } {
if (others === null) return { known: false, step: null };
return { known: true, step: missingProfileStep(profile, others, hint) };
}
+6 -6
View File
@@ -26,9 +26,8 @@ import { resolveStarterHomeLandingSessionId, starterHomeLandingNeedsConsultation
import { createSession, readStoredDailyStarlanguage } from "@/lib/home-cloud-sync";
import {
chartSnapshotForSession,
isProfileComplete,
missingOtherProfileStep,
missingProfileStep,
onboardingCatalogDecision,
readProfile,
} from "@/lib/home-profile";
import {
@@ -160,11 +159,12 @@ export function resolveHomeWarmStart(input: HomeWarmStartInput): HomeWarmStart |
const snapshot = readHomeWarmSnapshot(account.user.id);
if (!isCompleteHomeWarmSnapshot(snapshot)) return null;
const profile = readProfile(account.profile);
const onboardingOthers = input.subjectCatalog ?? [];
const onboardingHint = readOnboardingIntake(account.user.id);
// Onboarding keeps the cold path: its streamed questions and birth-time
// assessment are not part of the snapshot.
if (!isProfileComplete(profile, onboardingOthers, onboardingHint)) return null;
// assessment are not part of the snapshot. A list that has not arrived is
// not that path — only a known unfinished profile is.
const catalogDecision = onboardingCatalogDecision(profile, input.subjectCatalog, onboardingHint);
if (catalogDecision.known && catalogDecision.step !== null) return null;
const catalog = snapshot.modelCatalog;
const landing = resolveWarmLanding({
sessions: input.sessions,
@@ -182,7 +182,7 @@ export function resolveHomeWarmStart(input: HomeWarmStartInput): HomeWarmStart |
modelCatalog: catalog,
profile,
startGreeting: profile.name.trim() ? createStartGreeting(profile.name) : "",
onboardingStep: missingProfileStep(profile, onboardingOthers, onboardingHint) ?? "name",
onboardingStep: catalogDecision.step ?? "name",
entrySummary: snapshot.entrySummary,
daily: warmDaily(account.user.id, input.subjectId, subjectProfile(input.subjectId, profile, input.subjectCatalog), input.now),
sessionsCursor: input.boot === snapshot.listBoot ? snapshot.sessionsCursor : input.boot.cursor,
+2 -1
View File
@@ -248,7 +248,8 @@ export function SessionListProvider({ children }: { children: ReactNode }) {
// no Home is mounted, fill the snapshot in the background (read-only GETs).
// Home mounting, sign-out, an account switch or a list reload cancels it.
const onboardingCatalog = useSyncExternalStore(subscribeSubjectCatalog, peekSubjectCatalog, () => null);
const prefetchAccountId = settled && !signedOut && !registration && account && isProfileComplete(readProfile(account.profile), onboardingCatalog ?? [])
const prefetchAccountId = settled && !signedOut && !registration && account && onboardingCatalog !== null
&& isProfileComplete(readProfile(account.profile), onboardingCatalog)
? account.user.id
: null;
useEffect(() => {
+8
View File
@@ -15,6 +15,14 @@ const EMPTY_SIGNS: StarterChartSigns = { ascendantSign: null, moonSign: null };
const SIGN_LABELS: Readonly<Record<string, string>> = SIGN_ZH;
/** Sky reveal subtitle for someone else's chart. Empty until both the name and the ascendant are known. */
export function otherSkySubjectLine(name: string, ascendantSign: string | null | undefined): string {
const trimmed = name.trim();
const sign = displaySign(ascendantSign);
if (!trimmed || !sign) return "";
return `那一刻,${trimmed}的上升在${sign}`;
}
/** English engine names become the short Chinese sign. An already-Chinese label is kept, without 座. */
export function displaySign(value: string | null | undefined): string | null {
const text = value?.trim() ?? "";
@@ -243,10 +243,10 @@ test("onboarding form steps hide the dead composer and keep source choices in a
const page = homeSurface;
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
// 原值: const onboardingFormActive = !profileComplete && onboardingStep !== "name"
// 新值: const onboardingFormActive = !synastryChat && !profileComplete && onboardingStep !== "name"
// 原因: 合盘追问可以跳过引导表单。普通对话仍是资料未完成、且不在称呼步时才收起输入框。
assert.match(page, /const onboardingFormActive = !synastryChat && !profileComplete && onboardingStep !== "name"/);
// 原值: const onboardingFormActive = !synastryChat && !profileComplete && onboardingStep !== "name"
// 新值: const onboardingFormActive = !synastryChat && subjectCatalogReady && !profileComplete && shownOnboardingStep !== "name"
// 原因: 人物目录未到时不收起输入框、也不闪「看谁的盘」。目录已知且资料未完成、又不在称呼步时,仍收起。合盘追问仍跳过引导表单。
assert.match(page, /const onboardingFormActive = !synastryChat && subjectCatalogReady && !profileComplete && shownOnboardingStep !== "name"/);
assert.match(page, /!rectificationSurfaceOpen && !onboardingFormActive && <div className=\{`composer-wrap/);
assert.match(page, /birthTimeDraftReadyHint\(profileDraft\)/);
assert.match(css, /\.birth-time-source-list \{ grid-template-columns: 1fr/);
+4 -4
View File
@@ -54,12 +54,12 @@ test("generating does not disable the textarea; Enter queues instead of dropping
// 原值:`inputDisabled={sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || (!profileComplete && ...)}`
// 新值:表达式前增加 `subjectDeleted ||`,其余结构性禁用条件不变。
// 原因:BUG-1000 已删除人物的历史会话只读;BUG-1014 同步合同,不改生成中仍可输入的性质。
// 原值:subjectDeleted 开头,资料不全仍禁用
// 新值:合盘已删除时禁用;合盘追问不因查看者资料不全而禁用
// 原因:#83 报告删除后只读,追问不要求查看者自己的出生资料
// 原值:合盘已删除时禁用;判断称呼步用 onboardingStep
// 新值:同一串禁用条件,称呼步改认 shownOnboardingStep
// 原因:人物目录未到时不把空目录当成称呼已过。生成中仍可输入,合盘追问仍不因查看者资料不全而禁用。
assert.match(
page,
/inputDisabled=\{synastryClosed \|\| subjectDeleted \|\| sessionMessagesLoading \|\| rectificationSurfaceOpen \|\| activeRectificationSession \|\| \(!synastryChat && !profileComplete && \(onboardingStep !== "name" \|\| !presetMessageFinished \|\| profileSaving\)\)\}/,
/inputDisabled=\{synastryClosed \|\| subjectDeleted \|\| sessionMessagesLoading \|\| rectificationSurfaceOpen \|\| activeRectificationSession \|\| \(!synastryChat && !profileComplete && \(shownOnboardingStep !== "name" \|\| !presetMessageFinished \|\| profileSaving\)\)\}/,
);
assert.doesNotMatch(page, /inputDisabled=\{isLoading \|\| sessionMessagesLoading \|\| cancellationPending/);
assert.match(page, /if \(isLoading \|\| cancellationPending\) return void enqueueQueuedDraft\(composerDraftSnapshot\(\)\);/);
@@ -100,11 +100,11 @@ test("the composer keeps its Chinese input, focus and accessibility contract", (
assert.match(composerSource, /title=\{stopTitle\}/);
assert.match(composerSource, /aria-label=\{submitLabel\} disabled=/);
assert.equal(composerSource.match(/size="icon"/g)?.length, 2);
// 原值: 资料未完成且停在称呼步时,标签是「输入你的称呼 / 确认称呼」
// 新值: 合盘追问不走这套称呼标签
// 原因: #83 追问要发出预填问题,不能把发送按钮改成确认称呼
assert.match(pageSource, /inputLabel=\{!synastryChat && !profileComplete && onboardingStep === "name" \? "输入你的称呼" : "输入你的问题"\}/);
assert.match(pageSource, /submitLabel=\{!synastryChat && !profileComplete && onboardingStep === "name" \? "确认称呼" : "发送"\}/);
// 原值: 资料未完成且停在称呼步时,标签是「输入你的称呼 / 确认称呼」,判断用 onboardingStep
// 新值: 同一对标签,判断用 shownOnboardingStep;合盘追问仍不走这套称呼标签
// 原因: 人物目录未到时不能把空目录当成「看谁的盘」。称呼步的两句文案不变,#83 的合盘追问也不改成确认称呼。
assert.match(pageSource, /inputLabel=\{!synastryChat && !profileComplete && shownOnboardingStep === "name" \? "输入你的称呼" : "输入你的问题"\}/);
assert.match(pageSource, /submitLabel=\{!synastryChat && !profileComplete && shownOnboardingStep === "name" \? "确认称呼" : "发送"\}/);
});
test("every external draft writer keeps working through the page-owned setters", () => {
@@ -413,6 +413,9 @@ test("the home first screen no longer imports the city table by value (T7)", ()
});
test("an incomplete profile waits for the onboarding shell before reveal; a complete one does not load it (T7)", async () => {
// 原值:人物目录还没到时用空数组,只有名字的账户也会预载引导页。
// 新值:目录用已知的空列表才预载;目录还没到(null)不预载,也不把步骤写成「看谁的盘」。
// 原因:目录未到是未知,不是「没有别人」。已知的空列表仍表示没建完档。
installWindow();
const restoreStorage = installStorage({});
for (const complete of [false, true]) {
@@ -433,6 +436,7 @@ test("an incomplete profile waits for the onboarding shell before reveal; a comp
consultationStatusMissingCount: { current: 0 },
isDevelopment: false,
readSearch: () => "",
readSubjectCatalog: () => [],
storage: { getItem: (key: string) => storage.get(key) ?? null, removeItem: (key: string) => { storage.delete(key); } },
restoreConsultationRecovery: noop,
setComposerNotice: noop,
@@ -462,6 +466,41 @@ test("an incomplete profile waits for the onboarding shell before reveal; a comp
await run;
assert.deepEqual(phases, ["prepare"]);
}
let unknownStep = "";
let unknownPreloads = 0;
const unknownProfile = { ...emptyProfile, name: "Synthetic" };
const unknownBoot: SessionListBoot = { sessions: [], rawRows: [], cursor: null, account: { ...account, profile: unknownProfile }, signedOut: false };
const unknownDeps: HomeBootstrapDeps = {
sessionListReady: Promise.resolve(),
sessionListBoot: () => unknownBoot,
uiPreview: { current: false }, uiPreviewMode: { current: null },
consultationStatusMissingCount: { current: 0 },
isDevelopment: false,
readSearch: () => "",
readSubjectCatalog: () => null,
storage: { getItem: () => null, removeItem: () => undefined },
restoreConsultationRecovery: () => undefined,
setComposerNotice: () => undefined,
commit: {
setAccount: () => undefined, setAccountError: () => undefined, setActiveSessionId: () => undefined,
setBirthTimeAssessmentPhase: () => undefined, setBirthTimeJourney: () => undefined,
setBootstrapPhase: () => undefined, setGuidedJourneyPreview: () => undefined,
setHydrated: () => undefined, setModelCatalog: () => undefined,
setOnboardingStep: (step) => { unknownStep = step; },
setProfile: () => undefined, setProfileDraft: () => undefined,
setSessions: () => undefined, setSessionsCursor: () => undefined, setStartGreeting: () => undefined,
},
io: {
fetchModelCatalog: async () => previewModelCatalog,
fetchActiveConsultationStatus: async () => null,
startEntrySummaryRead: () => undefined,
preloadOnboardingShell: () => { unknownPreloads += 1; return Promise.resolve(); },
resolveLookupBootstrap: async (input) => ({ selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false }, sessions: input.sessions, notice: null }),
},
};
await runHomeBootstrap(unknownDeps, new AbortController().signal);
assert.equal(unknownPreloads, 0, "an unknown people list does not preload the who-step shell");
assert.equal(unknownStep, "name", "an unknown people list does not commit 看谁的盘");
restoreStorage();
});
@@ -201,6 +201,9 @@ test("warm start is refused whenever a piece is missing", () => {
assert.equal(resolveHomeWarmStart(input({ boot: { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false, error: "session_list_unavailable" } })), null, "list error");
assert.equal(resolveHomeWarmStart(input({ previewRequested: true })), null, "development preview");
assert.equal(resolveHomeWarmStart(input({ account: account(USER, { name: "林遥" }) })), null, "onboarding profile");
const unknownCatalog = resolveHomeWarmStart(input({ account: account(USER, { name: "林遥" }), subjectCatalog: null }));
assert.ok(unknownCatalog, "a people list that has not arrived is not an unfinished profile");
assert.equal(unknownCatalog.onboardingStep, "name", "unknown does not commit 看谁的盘");
assert.equal(resolveHomeWarmStart(input({ search: "?c=99999999-9999-4999-8999-999999999999" })), null, "a ?c= not in memory needs the cold lookup");
writeHomeWarmSnapshot(snapshotFor(USER, { entrySummarySettled: false }));
assert.equal(resolveHomeWarmStart(input()), null, "summary never settled");
@@ -3,9 +3,9 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import { readOnboardingIntake } from "../src/lib/onboarding-intake.ts";
import { missingProfileStep } from "../src/lib/home-profile.ts";
import { missingProfileStep, onboardingCatalogDecision } from "../src/lib/home-profile.ts";
import { emptyProfile, type Profile } from "../src/lib/home-types.ts";
import { starterQuestions, type StarterChartSigns } from "../src/lib/starter-questions.ts";
import { otherSkySubjectLine, starterQuestions, type StarterChartSigns } from "../src/lib/starter-questions.ts";
/** Fictional chart. 1990-01-02 and 杭州 are not a real person's birth data. */
function chart(name: string, ready: boolean): Profile {
@@ -49,6 +49,17 @@ test("missingProfileStep: self complete, only someone else complete, neither, an
), "place");
assert.equal(missingProfileStep(selfName, [otherPlace], { target: "other", otherId: "other-1" }), "place");
assert.equal(missingProfileStep({ ...emptyProfile }, []), "name");
assert.equal(onboardingCatalogDecision(selfName, null).known, false);
assert.equal(onboardingCatalogDecision(selfName, []).known, true);
assert.equal(onboardingCatalogDecision(selfName, []).step, "who");
assert.equal(onboardingCatalogDecision(selfName, [otherDone]).step, null);
});
test("someone else's sky subtitle names them and their ascendant, and stays blank without either", () => {
assert.equal(otherSkySubjectLine("小舟", "Taurus"), "那一刻,小舟的上升在金牛");
assert.equal(otherSkySubjectLine("小舟", "金牛座"), "那一刻,小舟的上升在金牛");
assert.equal(otherSkySubjectLine("小舟", null), "");
assert.equal(otherSkySubjectLine(" ", "Taurus"), "");
});
test("starter questions use the chart signs already on hand, for both people", () => {