fix(chat): follow active response at bottom
Staging Backend Quality Gate / validate (push) Successful in 11m13s
Staging Backend Quality Gate / publish (push) Has been cancelled

This commit is contained in:
Jesse_Chen
2026-08-09 01:19:39 +08:00
parent 17430a1f21
commit 34395e902c
6 changed files with 33 additions and 14 deletions
+2 -2
View File
@@ -557,7 +557,7 @@ button:disabled { cursor: default; opacity: .45; }
@media (hover: hover) {
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); transform: translateY(-1px); }
}
.product-entrypoint-card:has(.product-entrypoint-hitarea:disabled) { opacity: .65; }
.product-entrypoint-card:has(.product-entrypoint-hitarea:disabled) { opacity: 1; }
.product-entrypoint-hitarea:disabled { cursor: not-allowed; }
.daily-starlanguage-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
.daily-starlanguage-heading > span { color: var(--color-action); font-size: var(--type-overline); font-weight: 600; letter-spacing: 1.5px; }
@@ -1167,7 +1167,7 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
}
.product-entrypoint-card:has(.product-entrypoint-hitarea:disabled) {
opacity: .65;
opacity: 1;
}
.product-entrypoint-hitarea:disabled {
+5 -5
View File
@@ -1032,7 +1032,7 @@ export default function Home() {
const [birthTimeAssessmentPhase, setBirthTimeAssessmentPhase] = useState<BirthTimeAssessmentPhase | null>(null);
const [startGreeting, setStartGreeting] = useState("");
const [presetMessageLength, setPresetMessageLength] = useState(0);
const conversationEnd = useRef<HTMLDivElement>(null);
const conversation = useRef<HTMLDivElement>(null);
const accountTrigger = useRef<HTMLButtonElement>(null);
const accountDialog = useRef<HTMLElement>(null);
const dialogReturnTarget = useRef<HTMLButtonElement | null>(null);
@@ -1697,8 +1697,10 @@ export default function Home() {
useEffect(() => {
if (starterHomeVisible) return;
const container = conversation.current;
if (!container) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
conversationEnd.current?.scrollIntoView({ behavior: isLoading || reduceMotion ? "auto" : "smooth", block: "end" });
container.scrollTo({ top: container.scrollHeight, behavior: isLoading || reduceMotion ? "auto" : "smooth" });
}, [activeSessionId, activeSession?.messages.length, activeStreamingText, isLoading, onboardingPending, onboardingStep, presetMessageFinished, profileComplete, starterHomeVisible]);
useEffect(() => {
@@ -3127,7 +3129,7 @@ export default function Home() {
</header>
{!rectificationSurfaceOpen && (
<div className={`conversation ${activeSession?.messages.length ? "" : "is-empty"}`}>
<div ref={conversation} className={`conversation ${activeSession?.messages.length ? "" : "is-empty"}`}>
{!activeSession?.messages.length ? (
<div className="welcome">
{!profileComplete ? (
@@ -3299,7 +3301,6 @@ export default function Home() {
{onboardingError && <p className="starter-note"></p>}
</div>
))}
<div ref={conversationEnd} />
</div>
) : (
<div className="message-list" aria-busy={isLoading}>
@@ -3308,7 +3309,6 @@ export default function Home() {
<ChatMessageRow key={message.renderKey} message={message} />
))}
{activeError && <p className="error-message" role="alert">{activeError}</p>}
<div ref={conversationEnd} />
</div>
)}
</div>
@@ -83,7 +83,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const [acceptingTime, setAcceptingTime] = useState<string | null>(null);
const [suggestions, setSuggestions] = useState<string[]>([]);
const composer = useRef<HTMLTextAreaElement>(null);
const conversationEnd = useRef<HTMLDivElement>(null);
const conversation = useRef<HTMLElement>(null);
const keyCounter = useRef(0);
const openingStarted = useRef(false);
@@ -93,12 +93,14 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
}, [onPendingChange]);
useEffect(() => {
const container = conversation.current;
if (!container) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
conversationEnd.current?.scrollIntoView({
container.scrollTo({
top: container.scrollHeight,
behavior: busy || reduceMotion ? "auto" : "smooth",
block: "end",
});
}, [busy, error, messages.length, savedTime]);
}, [busy, error, messages, savedTime]);
const send = useCallback(async (request: AgenticRectificationRequest, showUserMessage = true) => {
const trimmed = request.action === "message" ? request.message.trim() : "";
@@ -301,7 +303,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
return (
<>
<section className="conversation" aria-label="生时校正对话" aria-busy={busy}>
<section ref={conversation} className="conversation" aria-label="生时校正对话" aria-busy={busy}>
<div className="message-list" aria-live="polite">
{messages.map((message) => <ChatMessageRow key={message.renderKey} message={message} />)}
{candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && (
@@ -344,7 +346,6 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
</p>
)}
{error && <p className="error-message" role="alert">{error}</p>}
<div ref={conversationEnd} />
</div>
</section>
@@ -270,6 +270,12 @@ test("starter cards use transient pressed feedback instead of sticky hover shadi
assert.equal(starterHoverSurfaceRules.length, 0);
assert.match(styles, /\.product-entrypoint-card:has\(\.product-entrypoint-hitarea:not\(:disabled\):active\)\s*\{[^}]*background\s*:/);
assert.match(styles, /\.starter-theme-card:not\(:disabled\):active\s*\{[^}]*background\s*:/);
const disabledProductRules = [...styles.matchAll(
/\.product-entrypoint-card:has\(\.product-entrypoint-hitarea:disabled\)\s*\{([^}]*)\}/g,
)];
assert.equal(disabledProductRules.length, 2);
for (const rule of disabledProductRules) assert.match(rule[1], /opacity:\s*1\s*;/);
});
test("starter homepage stays editorial and hides technical chart parameters", () => {
@@ -156,6 +156,15 @@ test("Agentic rectification uses one case-level entitlement and the session-pinn
assert.doesNotMatch(route, /loadLanguageModelCatalog|resolveLanguageModelFromCatalog|\bresolveLanguageModel\(|\bdefaultLanguageModel\(/);
});
test("Agentic rectification scrolls the conversation container as streamed messages grow", () => {
assert.match(chat, /const conversation = useRef<HTMLElement>\(null\)/);
assert.match(chat, /<section ref=\{conversation\} className="conversation"/);
assert.match(chat, /const container = conversation\.current/);
assert.match(chat, /top: container\.scrollHeight/);
assert.match(chat, /\}, \[busy, error, messages, savedTime\]\);/);
assert.doesNotMatch(chat, /conversationEnd|scrollIntoView/);
});
test("candidate state streams before done and renders reusable multi-column choices", () => {
assert.match(route, /send\(\{ type: "candidates", result: candidateResult \}\)[\s\S]*send\(\{ type: "done", emitted: true \}\)/);
assert.match(chat, /fetch\(`\/api\/rectification\/agent\?sessionId=/);
+4 -1
View File
@@ -165,7 +165,10 @@ test("keeps the empty starter home at the top instead of auto-scrolling", () =>
);
assert.match(autoScrollEffect, /if \(starterHomeVisible\) return/);
assert.match(autoScrollEffect, /conversationEnd\.current\?\.scrollIntoView/);
assert.match(autoScrollEffect, /const container = conversation\.current/);
assert.match(autoScrollEffect, /container\.scrollTo\(\{ top: container\.scrollHeight/);
assert.match(pageSource, /ref=\{conversation\} className=\{`conversation/);
assert.doesNotMatch(pageSource, /conversationEnd|scrollIntoView/);
});
test("does not submit the composer while an IME composition is active", () => {