fix(web): 会话列表合同跟着外壳搬家,无活跃会话不再静默吞发送
四条源码合同改到 (app)/layout 与注册载荷两端对断。列表只剩校正会话时普通输入框锁定,发送会打开校正或给出提示。
This commit is contained in:
@@ -20,7 +20,7 @@ import {
|
||||
resolveRectificationEntryAction,
|
||||
} from "@/lib/rectification-entry";
|
||||
import {
|
||||
dropRectificationStoredPending,
|
||||
composerLocksAsRectification, dropRectificationStoredPending,
|
||||
ordinaryComposerPlaceholder,
|
||||
rectificationOrdinaryComposerReopen,
|
||||
} from "@/lib/rectification-session-composer-guard";
|
||||
@@ -391,7 +391,7 @@ export default function Home() {
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
||||
?? sessions.find((session) => session.sessionType !== "birth_time_rectification");
|
||||
const activeRectificationSession = activeSession?.sessionType === "birth_time_rectification";
|
||||
const activeRectificationSession = composerLocksAsRectification(activeSession, sessions);
|
||||
const rectificationSurfaceOpen = Boolean(activeRectificationSession && activeSession && activeSession.id === rectificationSessionId);
|
||||
const rectificationComposerLocked = Boolean(activeRectificationSession && !rectificationSurfaceOpen);
|
||||
const rectificationComposerReopen = rectificationOrdinaryComposerReopen({ activeRectificationSession, error: rectificationError, errorSessionId: rectificationErrorSessionId, sessionId: activeSession?.id });
|
||||
@@ -1745,7 +1745,7 @@ export default function Home() {
|
||||
{jumpToLatestVisible && (
|
||||
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
|
||||
)}
|
||||
{rectificationComposerReopen && activeSession ? <RectificationComposerReopen message={rectificationErrorMessage} onReopen={() => void reopenRectificationSession(activeSession.id)} /> : null}
|
||||
{rectificationComposerReopen ? <RectificationComposerReopen message={rectificationErrorMessage} onReopen={() => void reopenRectificationSession(activeSession?.id || rectificationErrorSessionId || "")} /> : null}
|
||||
<ChatComposer
|
||||
inputRef={composerInput}
|
||||
inputLabel={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
|
||||
|
||||
@@ -64,6 +64,7 @@ import { completedOnboardingTranscript, isProfileComplete, selectedBirthPlace }
|
||||
import { pendingConsultationStorageKey, timestamp } from "@/lib/home-types";
|
||||
import {
|
||||
consultSendBlockedByRectificationSession,
|
||||
missingActiveSessionSendAction,
|
||||
SESSION_NOT_CONSULTATION_CODE,
|
||||
} from "@/lib/rectification-session-composer-guard";
|
||||
import type {
|
||||
@@ -514,7 +515,19 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
? sessions.find((session) => session.id === targetSessionId)
|
||||
: activeSession;
|
||||
const currentSession = options.sessionOverride ?? liveSession;
|
||||
if (!question || !currentSession || !modelCatalog || !account) return false;
|
||||
if (!question) return false;
|
||||
if (!currentSession) {
|
||||
const action = missingActiveSessionSendAction(sessions);
|
||||
if ("openSessionId" in action) {
|
||||
setDraft(originalQuestion);
|
||||
await openRectificationSession(action.openSessionId);
|
||||
setDraft(originalQuestion);
|
||||
return false;
|
||||
}
|
||||
setComposerNotice(action.notice);
|
||||
return false;
|
||||
}
|
||||
if (!modelCatalog || !account) return false;
|
||||
if (consultSendBlockedByRectificationSession(currentSession)) {
|
||||
if (resuming) {
|
||||
pendingConsultation.current = null;
|
||||
|
||||
@@ -23,6 +23,24 @@ export function consultSendBlockedByRectificationSession(
|
||||
return isRectificationChatSession(session);
|
||||
}
|
||||
|
||||
/** Empty active session with only rectification rows still occupies the ordinary composer. */
|
||||
export function composerLocksAsRectification(
|
||||
activeSession: { sessionType?: string } | null | undefined,
|
||||
sessions: readonly { sessionType: string }[],
|
||||
): boolean {
|
||||
if (isRectificationChatSession(activeSession)) return true;
|
||||
if (activeSession) return false;
|
||||
return sessions.some((session) => session.sessionType === "birth_time_rectification");
|
||||
}
|
||||
|
||||
export function missingActiveSessionSendAction(
|
||||
sessions: readonly { id: string; sessionType: string }[],
|
||||
): { openSessionId: string } | { notice: string } {
|
||||
const rectification = sessions.find((session) => session.sessionType === "birth_time_rectification");
|
||||
if (rectification) return { openSessionId: rectification.id };
|
||||
return { notice: "请先选一条对话,或点上方「新建对话」。" };
|
||||
}
|
||||
|
||||
export function dropRectificationStoredPending<
|
||||
S extends { sessionId: string },
|
||||
T extends { id: string; sessionType: string },
|
||||
@@ -63,5 +81,6 @@ export function rectificationOrdinaryComposerReopen(input: {
|
||||
if (!input.activeRectificationSession || !input.error || input.error === "profile_incomplete") {
|
||||
return false;
|
||||
}
|
||||
if (!input.sessionId) return true;
|
||||
return !input.errorSessionId || input.errorSessionId === input.sessionId;
|
||||
}
|
||||
|
||||
@@ -108,10 +108,16 @@ test("bootstrap failure retries with a hard reload because refresh cannot rerun
|
||||
});
|
||||
|
||||
test("the streaming reply announces state transitions, never streamed deltas", () => {
|
||||
// Given: one page-level status region outside every aria-busy subtree.
|
||||
// 原值:page.tsx 里 `<main className="chat-app">` 紧跟 replyAnnouncement 的 sr-only 播报
|
||||
// 新值:`<main className="chat-app">` 只在 (app)/layout.tsx 出现一次;播报仍在 page.tsx,是 SidebarInset 内容的第一个子节点
|
||||
// 原因:T2 外壳搬家,播报仍在对话页、仍在 aria-busy 列表之外
|
||||
const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
|
||||
const pageFile = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
assert.equal(layout.match(/<main className="chat-app">/g)?.length, 1);
|
||||
assert.doesNotMatch(pageFile, /<main className="chat-app">/);
|
||||
assert.match(
|
||||
pageSource,
|
||||
/<main className="chat-app">\n\s*<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{replyAnnouncement\}<\/span>/,
|
||||
pageFile,
|
||||
/return \(\s*<>\s*<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{replyAnnouncement\}<\/span>/,
|
||||
);
|
||||
|
||||
// Then: the region is fed a phase-derived string, not the streaming text.
|
||||
|
||||
@@ -213,12 +213,19 @@ test("home stays a client-read query on a static route", () => {
|
||||
assert.match(page, /window\.history\.replaceState/);
|
||||
assert.match(lib, /history\.pushState\(null, "", next\)/);
|
||||
assert.match(lib, /history\.replaceState\(null, "", next\)/);
|
||||
// 原值:首页自己并发拉账户/目录/列表,`const [nextAccount, modelCatalogResult, sessionsPayload]`
|
||||
// 新值:`await sessionListReady` 后读 `sessionListBoot()`,page.tsx 不再 `fetchSessions(`
|
||||
// 原因:T2 列表由 layout provider 拉一次;本条仍守「`/` 是静态路由上的客户端读」
|
||||
const pageFile = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const preview = sourceBetween(
|
||||
page,
|
||||
pageFile,
|
||||
"if (previewMode) {",
|
||||
"const [nextAccount, modelCatalogResult, sessionsPayload]",
|
||||
"await sessionListReady;",
|
||||
);
|
||||
assert.doesNotMatch(preview, /writeSessionUrl|persistLoginSessionReturn|readLoginSessionReturn/);
|
||||
assert.match(pageFile, /await sessionListReady;/);
|
||||
assert.match(pageFile, /sessionListBoot\(\)/);
|
||||
assert.doesNotMatch(pageFile, /fetchSessions\(/);
|
||||
assert.match(page, /SESSION_MISSING_NOTICE/);
|
||||
assert.equal(SESSION_MISSING_NOTICE, "该对话不存在或已被删除");
|
||||
});
|
||||
|
||||
@@ -55,11 +55,15 @@ test("a history-list open failure shows under the clicked row, not on the starte
|
||||
pageSource,
|
||||
/rectificationError=\{rectificationErrorSessionId \? "" : rectificationError\}/,
|
||||
);
|
||||
assert.match(pageSource, /openErrorSessionId=\{rectificationErrorSessionId\}/);
|
||||
assert.match(
|
||||
pageSource,
|
||||
/openErrorMessage=\{rectificationErrorSessionId \? rectificationErrorMessage : ""\}/,
|
||||
);
|
||||
// 原值:page.tsx 把 openErrorSessionId / openErrorMessage 直接传给 AppSidebar
|
||||
// 新值:layout 从 registration 传给 AppSidebar,首页 registerShellControls 载荷带这两个字段
|
||||
// 原因:T2 外壳搬家,两端都断才等于侧栏行仍拿得到打开失败
|
||||
const layout = read("../src/app/(app)/layout.tsx");
|
||||
const shell = read("../src/hooks/use-home-shell-registration.ts");
|
||||
assert.match(layout, /openErrorSessionId=\{registration\?\.openErrorSessionId \?\? null\}/);
|
||||
assert.match(layout, /openErrorMessage=\{registration\?\.openErrorMessage \?\? ""\}/);
|
||||
assert.match(shell, /openErrorSessionId: rectificationErrorSessionId,/);
|
||||
assert.match(shell, /openErrorMessage: rectificationErrorSessionId \? rectificationErrorMessage : "",/);
|
||||
assert.match(starter, /rectificationError && !rectificationSurfaceOpen && \(/);
|
||||
assert.match(sidebar, /openErrorSessionId/);
|
||||
assert.match(sidebarRow, /error \? \(/);
|
||||
@@ -70,5 +74,5 @@ test("a history-list open failure shows under the clicked row, not on the starte
|
||||
const notice = cssDeclarations(".session-open-error", styles);
|
||||
assert.match(notice, /color:\s*var\(--color-danger\)/);
|
||||
assert.match(notice, /font-size:\s*var\(--type-overline\)/);
|
||||
assert.match(page, /openErrorSessionId=\{rectificationErrorSessionId\}/);
|
||||
assert.match(layout, /openErrorSessionId=\{registration\?\.openErrorSessionId \?\? null\}/);
|
||||
});
|
||||
|
||||
@@ -4,9 +4,11 @@ import test from "node:test";
|
||||
|
||||
import { homeSurface as page } from "./home-surface.ts";
|
||||
import {
|
||||
composerLocksAsRectification,
|
||||
consultSendBlockedByRectificationSession,
|
||||
dropRectificationStoredPending,
|
||||
fallbackSessionId,
|
||||
missingActiveSessionSendAction,
|
||||
RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER,
|
||||
RECTIFICATION_REOPEN_LABEL,
|
||||
rectificationOrdinaryComposerReopen,
|
||||
@@ -104,3 +106,34 @@ test("delete and popstate fallbacks do not setActiveSessionId onto a rectificati
|
||||
assert.match(sessions, /const fallbackId = fallbackSessionId\(listed\);/);
|
||||
assert.match(page, /sessions.find\(\(session\) => session.sessionType !== "birth_time_rectification"\)/);
|
||||
});
|
||||
|
||||
test("an empty active session still locks the composer when the list is only rectification", () => {
|
||||
assert.equal(
|
||||
composerLocksAsRectification(undefined, [{ sessionType: "birth_time_rectification" }]),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
composerLocksAsRectification(undefined, [{ sessionType: "consultation" }]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
composerLocksAsRectification({ sessionType: "consultation" }, [{ sessionType: "birth_time_rectification" }]),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
missingActiveSessionSendAction([{ id: "r1", sessionType: "birth_time_rectification" }]),
|
||||
{ openSessionId: "r1" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
missingActiveSessionSendAction([]),
|
||||
{ notice: "请先选一条对话,或点上方「新建对话」。" },
|
||||
);
|
||||
assert.match(pageSource, /composerLocksAsRectification\(activeSession, sessions\)/);
|
||||
assert.match(sendFn, /missingActiveSessionSendAction\(sessions\)/);
|
||||
assert.match(sendFn, /openRectificationSession\(action\.openSessionId\)/);
|
||||
assert.match(sendFn, /setComposerNotice\(action\.notice\)/);
|
||||
assert.doesNotMatch(
|
||||
sendFn,
|
||||
/if \(!question \|\| !currentSession \|\| !modelCatalog \|\| !account\) return false;/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -82,7 +82,13 @@ test("entry feedback is static: card and sidebar row say 正在打开, and nothi
|
||||
assert.doesNotMatch(starter, /InlineSpinner/);
|
||||
assert.match(sidebarRow, /aria-busy=\{opening \? true : undefined\}/);
|
||||
assert.match(sidebarRow, /RECTIFICATION_SIDEBAR_OPENING_NOTE/);
|
||||
assert.match(page, /openingSessionId=\{rectificationOpeningSessionId\}/);
|
||||
// 原值:page.tsx 把 openingSessionId 直接传给 AppSidebar
|
||||
// 新值:layout 从 registration 传入,首页 registerShellControls 载荷带 openingSessionId
|
||||
// 原因:T2 外壳搬家,两端都断才等于侧栏行仍拿得到正在打开
|
||||
const layout = read("../src/app/(app)/layout.tsx");
|
||||
const shell = read("../src/hooks/use-home-shell-registration.ts");
|
||||
assert.match(layout, /openingSessionId=\{registration\?\.openingSessionId \?\? null\}/);
|
||||
assert.match(shell, /openingSessionId: rectificationOpeningSessionId,/);
|
||||
// 原值 `.product-entrypoint-card[data-opening="true"]` / 新值 `.starter-entry[data-opening="true"]`
|
||||
// / 原因:入口从卡片降级成 pill。「打开 Case 时只换指针、不出 spinner」这条不变。
|
||||
assert.match(styles, /\.starter-entry\[data-opening="true"\][^{]*\{ cursor: progress; \}/);
|
||||
|
||||
Reference in New Issue
Block a user