fix(home): send starter cards instead of filling the composer
Independent Staging Quality Gate / validate (push) Successful in 11m21s
Independent Staging Quality Gate / publish (push) Successful in 17m56s

Clicking today's reading or a topic card should open a consultation and wait for the model, not leave the question in the input box.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-21 22:02:40 +08:00
parent cf168a54c0
commit 649ba32034
7 changed files with 70 additions and 18 deletions
@@ -6,7 +6,7 @@ Make the “今日星语” and “生时校正” cards behave like concise pro
## Approved interaction
- Clicking anywhere on the 今日星语 card places `深入看今日` in the composer.
- Clicking anywhere on the 今日星语 card starts a consultation session with `深入看今日` (or the no-minute public daily question) and waits for the model. It does not place the question in the composer.
- Clicking the 生时校正 card places `生时校正` before a result exists and `再次校正` after a candidate or confirmed time exists.
- Each card remains a semantic `article`; one stretched native button covers the card, and a quiet action label with an arrow sits at the lower-right edge.
- The cards contain no nested visible button, so mobile layouts do not create a large control in the middle of the content.
@@ -28,7 +28,8 @@ Normal typed questions omit `entrypoint` and retain their current behavior.
## Composer and transcript behavior
- Card selection stores the short visible question plus an internal entrypoint enum in React state.
- Homepage daily and topic cards send immediately on an empty consultation session (or a newly created one). They do not store the question in the composer first.
- The send path still attaches the closed entrypoint enum for the daily card so the server can expand the public label.
- Any manual edit to the composer clears the entrypoint enum, so edited text is treated as an ordinary user question.
- Sending clears both fields.
- Undo/cancel restores both fields, preserving the same behavior when the user retries.
@@ -54,4 +55,4 @@ Normal typed questions omit `entrypoint` and retain their current behavior.
- Unit tests prove each entrypoint selects a server expansion without pinning natural-language prompt prose.
- Route contract tests prove the enum is optional, invalid values fail, and the expanded question reaches both Agent and tool input.
- Client tests prove the browser source no longer contains the internal daily or rectification prompt builders, manual editing clears intent, and cancellation restores it.
- Browser QA verifies whole-card click, short composer text, keyboard focus, and desktop/390px mobile layout.
- Browser QA verifies whole-card click starts the consultation, public transcript labels stay short, keyboard focus, and desktop/390px mobile layout.
+2 -2
View File
@@ -172,12 +172,12 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
- **Structure:** topic label, question, directional icon. Categories are not numbered because they have no required order. At tablet widths, the cards stack into one column so Chinese questions keep natural phrase boundaries beside the persistent sidebar.
- **Surface:** warm light cards; the lead card uses the pale brown emphasis surface and border instead of a dark block.
- **States:** default, hover, active, focus, disabled, loading, fallback notice.
- **Visibility:** the initial cards, one per consultation domain, remain visible while the user types or chooses a question. They leave only after the question is submitted and the session receives its first user message.
- **Visibility:** the initial cards, one per consultation domain, remain visible while the user types a custom question. Clicking a card starts that consultation immediately instead of filling the composer; the cards leave once the session receives its first user message.
### Product entrypoint card
- **Structure:** the homepage daily-reading and birth-time cards are single native-button targets stretched across their article surface. Content remains semantic card copy; a compact action label and arrow sit at the lower right.
- **Copy:** the composer and chat history show only the public labels “深入看今日”, “生时校正”, or “再次校正”. Private model instructions are selected by a closed entrypoint identifier and expanded only on the server.
- **Copy:** chat history shows only the public labels “深入看今日”, “生时校正”, or “再次校正”. Clicking the daily card starts a consultation session with that public label and waits for the model; it does not place the question in the composer. Private model instructions are selected by a closed entrypoint identifier and expanded only on the server.
- **States:** default, whole-card hover, pressed, focus-visible, and disabled. The card surface—not an inner promotional button—carries the interaction feedback.
- **Responsive:** cards stack below 768px without introducing a large nested button; the footer keeps supporting copy flexible and the action label on one line.
- **Accessibility:** each card exposes exactly one native button with a descriptive accessible name, preserves a visible focus ring, and meets the full-card touch target.
+38 -7
View File
@@ -2067,8 +2067,8 @@ export default function Home() {
}
}
async function startNewChat() {
if (!account || !modelCatalog || creatingSession) return;
async function startNewChat(): Promise<ChatSession | null> {
if (!account || !modelCatalog || creatingSession) return null;
const nextSession = createSession(modelCatalog.defaultModelId);
const previousSessionId = activeSession?.id ?? "";
setCreatingSession(true);
@@ -2081,6 +2081,7 @@ export default function Home() {
setRequestError(null);
try {
await persistSession(nextSession, "create");
return nextSession;
} catch (caught) {
setSessions((current) => current.filter((session) => session.id !== nextSession.id));
setActiveSessionId(previousSessionId);
@@ -2088,6 +2089,7 @@ export default function Home() {
sessionId: previousSessionId,
message: caught instanceof Error ? caught.message : "新对话未能保存到云端。",
});
return null;
} finally {
setCreatingSession(false);
}
@@ -2498,8 +2500,37 @@ export default function Home() {
window.requestAnimationFrame(() => composerInput.current?.focus());
}
function draftDailyStarlanguageQuestion() {
chooseSuggestedQuestion(
async function startSuggestedConsultation(
question: string,
theme?: Theme,
entrypoint: ConsultationEntrypoint | null = null,
) {
if (
pendingSessionId
|| cancellationInFlight.current
|| pendingConsultation.current
|| creatingSession
) return;
const trimmed = question.trim();
if (!trimmed || !account || !modelCatalog) return;
let targetSession = activeSession
&& activeSession.sessionType === "consultation"
&& activeSession.messages.length === 0
? activeSession
: null;
if (!targetSession) {
targetSession = await startNewChat();
if (!targetSession) return;
}
await send(trimmed, theme, entrypoint, null, targetSession.id, {
sessionOverride: targetSession,
});
}
function startDailyStarlanguageConsultation() {
void startSuggestedConsultation(
dailyStarlanguageQuestion,
"timing",
"daily_starlanguage",
@@ -3749,7 +3780,7 @@ export default function Home() {
type="button"
aria-label={natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"}
disabled={productEntrypointsDisabled}
onClick={draftDailyStarlanguageQuestion}
onClick={startDailyStarlanguageConsultation}
/>
<div className="product-entrypoint-copy">
<h2 id="daily-starlanguage-title">{natalMinuteAvailable ? "今日星语" : "每日运势"}</h2>
@@ -3806,8 +3837,8 @@ export default function Home() {
key={`${item.theme}-${item.text}`}
type="button"
aria-label={`${theme?.label || "开始"}${item.text}`}
disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog}
onClick={() => chooseSuggestedQuestion(item.text, item.theme)}
disabled={productEntrypointsDisabled}
onClick={() => void startSuggestedConsultation(item.text, item.theme)}
>
<span className="starter-content">
<b>{theme?.label || "开始"}</b>
@@ -81,7 +81,7 @@ test("every external draft writer keeps working through the page-owned setters",
assert.match(pageSource, /onChange=\{\(event\) => \{\n\s*setDraft\(event\.target\.value\);\n\s*setDraftTheme\(null\);\n\s*setDraftEntrypoint\(null\);\n\s*setComposerNotice\(""\);\n\s*\}\}/);
// When: each existing write path is inspected.
const chooseSuggested = sourceBetween(pageSource, "function chooseSuggestedQuestion(", "function draftDailyStarlanguageQuestion");
const chooseSuggested = sourceBetween(pageSource, "function chooseSuggestedQuestion(", "async function startSuggestedConsultation");
const startNewChat = sourceBetween(pageSource, "async function startNewChat()", "function selectSession(");
const selectSession = sourceBetween(pageSource, "function selectSession(sessionId: string)", "async function selectSessionModel");
const saveOnboardingName = sourceBetween(pageSource, "async function saveOnboardingName()", "async function saveOnboardingBirth");
@@ -93,8 +93,11 @@ test("every external draft writer keeps working through the page-owned setters",
"setRequestError({",
);
// Then: suggestions fill, session switches clear, stop restores and send clears.
// Then: synastry still fills the composer; session switches clear, stop restores and send clears.
assert.match(chooseSuggested, /setDraft\(question\);\n\s*setDraftTheme\(theme \?\? null\);\n\s*setDraftEntrypoint\(entrypoint\);/);
const startSuggested = sourceBetween(pageSource, "async function startSuggestedConsultation(", "function startDailyStarlanguageConsultation");
assert.match(startSuggested, /await send\(trimmed, theme, entrypoint, null, targetSession\.id/);
assert.doesNotMatch(startSuggested, /setDraft\(/);
assert.match(startNewChat, /setDraft\(""\)/);
assert.match(selectSession, /setDraft\(""\)/);
assert.match(saveOnboardingName, /composerDraftSnapshot\(\)\.replace\(/);
@@ -129,7 +129,7 @@ test("ordinary product drafts keep the public question and clear hidden routing
assert.match(source, /dailyStarlanguageQuestion/);
assert.match(source, /从今日问起/);
assert.match(source, /深入看今日/);
assert.match(source, /chooseSuggestedQuestion\(\s*dailyStarlanguageQuestion,\s*"timing",\s*"daily_starlanguage"/);
assert.match(source, /startSuggestedConsultation\(\s*dailyStarlanguageQuestion,\s*"timing",\s*"daily_starlanguage"/);
assert.match(source, /consultEntrypoint === "birth_time_rectification"/);
assert.match(source, /isRectificationHandoffQuestion\(question\)/);
assert.match(source, /openRectificationFromHomepage\(pendingQuestion\)/);
+19 -2
View File
@@ -46,7 +46,7 @@ test("default starter questions derive every canonical domain with evidence and
assert.match(pageSource, /defaultGuidedJyotishTopics/);
assert.match(pageSource, /starterSuggestions\.map/);
assert.match(pageSource, /starterThemes\.find\(\(candidate\) => candidate\.id === item\.theme\)/);
assert.match(pageSource, /chooseSuggestedQuestion\(item\.text, item\.theme\)/);
assert.match(pageSource, /void startSuggestedConsultation\(item\.text, item\.theme\)/);
assert.equal(consultationDomainIds.length, 10);
assert.deepEqual(consultationDomainRegistry.map((domain) => domain.id), [...consultationDomainIds]);
assert.match(guidedTopicsSource, /consultationDomainRegistry\.map/);
@@ -106,10 +106,27 @@ test("profiles without a usable birth minute receive user-centered starter promp
assert.match(themeSectionHeading, /natalMinuteAvailable/);
assert.match(themeSectionHeading, /出生时间不足以支持的部分,我会明确说明,不会补造具体分钟/);
assert.match(pageSource, /GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION/);
assert.match(pageSource, /chooseSuggestedQuestion\(\s*dailyStarlanguageQuestion,\s*"timing",\s*"daily_starlanguage"/);
assert.match(pageSource, /startSuggestedConsultation\(\s*dailyStarlanguageQuestion,\s*"timing",\s*"daily_starlanguage"/);
assert.doesNotMatch(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/);
});
test("homepage daily and topic cards start a consultation instead of filling the composer", () => {
const starterSend = sourceBetween(
pageSource,
"async function startSuggestedConsultation(",
"function startDailyStarlanguageConsultation",
);
assert.match(pageSource, /onClick=\{startDailyStarlanguageConsultation\}/);
assert.match(pageSource, /onClick=\{\(\) => void startSuggestedConsultation\(item\.text, item\.theme\)\}/);
assert.doesNotMatch(pageSource, /draftDailyStarlanguageQuestion/);
assert.doesNotMatch(pageSource, /onClick=\{\(\) => chooseSuggestedQuestion\(item\.text, item\.theme\)\}/);
assert.match(starterSend, /await startNewChat\(\)/);
assert.match(starterSend, /await send\(trimmed, theme, entrypoint, null, targetSession\.id/);
assert.doesNotMatch(starterSend, /setDraft\(/);
assert.doesNotMatch(starterSend, /composerInput\.current\?\.focus/);
});
test("keeps session history clickable while another session is answering", () => {
// Given: the page-owned selection callback and the app sidebar session action.
@@ -11,7 +11,7 @@ def test_daily_starlanguage_entrypoint_is_productized() -> None:
assert "今日星语" in source
assert "fetchDailyStarlanguage" in source
assert "daily-starlanguage-card" in source
assert "draftDailyStarlanguageQuestion" in source
assert "startDailyStarlanguageConsultation" in source
assert "从今日问起" in source
assert "今天的星语还没写出来。" in source
assert "buildDailyStarlanguageCard" not in source