fix(web): keep rectification sessions listed and persist chats on first send
Independent Staging Quality Gate / validate (push) Failing after 16m52s
Independent Staging Quality Gate / publish (push) Skipped

Empty-consultation filtering is now session_type scoped so birth-time rows stay in the sidebar. Subtitles use updatedAt with sort/group/cursor. New chats stay local until the first send.
This commit is contained in:
jesse-ux
2026-09-21 16:39:32 +08:00
parent f8d65e484d
commit e71e4f9200
21 changed files with 461 additions and 186 deletions
+16 -51
View File
@@ -127,7 +127,6 @@ import {
CONSULTATION_EVIDENCE_VALIDATION_LABEL,
CONSULTATION_LOADING_METHOD_LABEL,
} from "@/lib/consultation-activity-labels";
import { writeChatSession } from "@/lib/chat-session-write-contract";
import {
SESSION_MISSING_NOTICE,
clearLoginSessionReturn,
@@ -210,7 +209,6 @@ import {
resolveLookupBootstrap,
payloadCode,
payloadMessage,
readDraftConsultation,
readSessions,
readStoredDailyStarlanguage,
readStoredPendingConsultation,
@@ -228,7 +226,7 @@ import {
starterHomeLandingNeedsConsultation,
type BootstrapPhase,
} from "@/lib/home-bootstrap";
import { findReusableEmptyConsultation } from "@/lib/session-list-filter";
import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter";
const BirthTimeRectification = dynamic(
() => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification),
@@ -931,31 +929,12 @@ export default function Home() {
const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog);
setSessionsCursor(listBoot.cursor);
let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions;
const draftSession = readDraftConsultation(listBoot.draftRow, nextModelCatalog);
if (draftSession && !nextSessions.some((session) => session.id === draftSession.id)) {
nextSessions = [...nextSessions, draftSession];
}
if (nextSessions.length === 0) {
if (controller.signal.aborted) return;
const initialSession = createSession(
nextSessions = [createSession(
nextModelCatalog?.defaultModelId ?? "",
"consultation",
chartSnapshotForSession("self", [], nextProfile),
);
if (nextModelCatalog) {
await writeChatSession(initialSession.id, {
title: initialSession.title,
theme: initialSession.theme,
model_id: initialSession.modelId,
messages: [],
session_type: initialSession.sessionType,
rectification_case_id: initialSession.rectificationCaseId,
chart_profile_id: initialSession.chartProfileId,
chart_profile_name: initialSession.chartProfileName,
chart_profile_role: initialSession.chartProfileRole,
}, "create");
}
nextSessions = [initialSession];
)];
}
nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions);
@@ -1010,32 +989,13 @@ export default function Home() {
bootstrapSelection.urlAction,
);
if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) {
const reusable = findReusableEmptyConsultation(nextSessions);
if (reusable) {
landingSessionId = reusable.id;
} else {
if (controller.signal.aborted) return;
const homeSession = createSession(
nextModelCatalog?.defaultModelId ?? "",
"consultation",
chartSnapshotForSession("self", [], nextProfile),
);
if (nextModelCatalog) {
await writeChatSession(homeSession.id, {
title: homeSession.title,
theme: homeSession.theme,
model_id: homeSession.modelId,
messages: [],
session_type: homeSession.sessionType,
rectification_case_id: homeSession.rectificationCaseId,
chart_profile_id: homeSession.chartProfileId,
chart_profile_name: homeSession.chartProfileName,
chart_profile_role: homeSession.chartProfileRole,
}, "create");
}
nextSessions = [homeSession, ...nextSessions];
landingSessionId = homeSession.id;
}
const homeSession = createSession(
nextModelCatalog?.defaultModelId ?? "",
"consultation",
chartSnapshotForSession("self", [], nextProfile),
);
nextSessions = [homeSession, ...nextSessions];
landingSessionId = homeSession.id;
}
const activeListed = nextSessions.find((session) => session.id === landingSessionId)
?? nextSessions[0];
@@ -1062,7 +1022,12 @@ export default function Home() {
setActiveSessionId(landingSessionId);
if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn();
if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace");
if (bootstrapSelection.urlAction === "replace-selected") writeSessionUrl(bootstrapSelection.sessionId, "replace");
if (bootstrapSelection.urlAction === "replace-selected") {
const selected = nextSessions.find((session) => session.id === bootstrapSelection.sessionId);
if (!selected || !isUnsavedEmptyConsultation(selected)) {
writeSessionUrl(bootstrapSelection.sessionId, "replace");
}
}
if (reservedConsultation?.status === "reserved") {
const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId);
if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending);
+3 -31
View File
@@ -16,22 +16,9 @@ import {
parseSessionCursor,
sessionCursorFilter,
} from "@/lib/session-cursor";
const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at";
import { applyArchiveFilter, excludeEmptyConsultations } from "@/lib/session-list-filter";
function excludeEmptyConsultations<Query extends {
not: (column: string, operator: string, value: unknown) => Query;
}>(query: Query): Query {
// Empty consultations are `messages = []`. Rectification rows keep an opening
// turn, so excluding empty arrays leaves them in the list (BUG-928).
return query.not("messages", "eq", []);
}
function applyArchiveFilter<Query extends {
is: (column: string, value: null) => Query;
not: (column: string, operator: string, value: null) => Query;
}>(query: Query, archived: boolean): Query {
return archived ? query.not("archived_at", "is", null) : query.is("archived_at", null);
}
const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at";
export async function GET(request: Request) {
try {
@@ -67,7 +54,6 @@ export async function GET(request: Request) {
if (pageError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
let pinnedRows: typeof pageRows = [];
let draft: NonNullable<typeof pageRows>[number] | null = null;
if (!cursor) {
const { data: pinnedData, error: pinnedError } = await excludeEmptyConsultations(
applyArchiveFilter(
@@ -83,26 +69,12 @@ export async function GET(request: Request) {
.order("id", { ascending: false });
if (pinnedError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
pinnedRows = pinnedData ?? [];
if (!archived) {
const { data: draftData, error: draftError } = await supabase
.from("chat_sessions")
.select(SESSION_LIST_COLUMNS)
.eq("user_id", user.id)
.eq("session_type", "consultation")
.eq("messages", [])
.is("archived_at", null)
.order("updated_at", { ascending: false })
.order("id", { ascending: false })
.limit(1);
if (draftError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
draft = draftData?.[0] ?? null;
}
}
const nextCursor = nextSessionCursor(pageRows ?? [], limit);
const page = (pageRows ?? []).slice(0, limit);
const sessions = cursor ? page : [...(pinnedRows ?? []), ...page];
return NextResponse.json({ sessions, nextCursor, draft });
return NextResponse.json({ sessions, nextCursor });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
@@ -64,9 +64,11 @@ import { completedOnboardingTranscript, isProfileComplete, selectedBirthPlace }
import { pendingConsultationStorageKey, timestamp } from "@/lib/home-types";
import {
consultSendBlockedByRectificationSession,
fallbackSessionId,
missingActiveSessionSendAction,
SESSION_NOT_CONSULTATION_CODE,
} from "@/lib/rectification-session-composer-guard";
import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter";
import type {
Account,
AccountDialog,
@@ -606,6 +608,22 @@ export function useConsultationRun(params: ConsultationRunParams) {
return false;
}
if (isUnsavedEmptyConsultation(currentSession)) {
try {
await persistSession(currentSession, "create");
} catch (caught) {
const remaining = sessions.filter((session) => session.id !== currentSession.id);
setSessions(remaining);
const fallbackId = fallbackSessionId(remaining);
setActiveSessionId(fallbackId);
setRequestError({
sessionId: fallbackId,
message: caught instanceof Error ? caught.message : "新对话未能保存到云端。",
});
return false;
}
}
const [year, month, day] = profile.date.split("-").map(Number);
const [hour, minute] = consultationRoute.time?.split(":").map(Number) ?? [];
+40 -48
View File
@@ -29,7 +29,11 @@ import {
patchSessionModel,
readSessions,
} from "@/lib/home-cloud-sync";
import { findReusableEmptyConsultation } from "@/lib/session-list-filter";
import {
isListedSidebarSession,
isUnsavedEmptyConsultation,
replaceUnsavedEmptyConsultations,
} from "@/lib/session-list-filter";
import { chartSnapshotForSession } from "@/lib/home-profile";
import { beginSessionPageLoad, mergeSessionPage } from "@/lib/session-groups";
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
@@ -110,7 +114,6 @@ export function useSessionManagement(params: SessionManagementParams) {
setActiveChartId,
setActiveSessionId,
setBirthTimeConsultationConsent,
setCreatingSession,
setDraft,
setDraftEntrypoint,
setDraftTheme,
@@ -131,12 +134,21 @@ export function useSessionManagement(params: SessionManagementParams) {
const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null);
sessionsRef.current = sessions;
const loadMoreInFlight = useRef(false);
const pendingCreateById = useRef(new Map<string, { continuedFromSessionId?: string }>());
const cloudCreatedIds = useRef(new Set<string>());
for (const session of sessions) {
if (!isUnsavedEmptyConsultation(session)) cloudCreatedIds.current.add(session.id);
}
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 visibleSessions = sortSessions(sessions.filter((session) => {
if (showArchivedSessions) {
return Boolean(session.archivedAt)
&& (session.sessionType === "birth_time_rectification"
|| session.messages.length > 0
|| !session.messagesHydrated);
}
return isListedSidebarSession(session);
}));
function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) {
setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session)));
@@ -149,6 +161,9 @@ export function useSessionManagement(params: SessionManagementParams) {
) {
if (!account) throw new Error("账户尚未加载完成");
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
if (mode === "create" && cloudCreatedIds.current.has(session.id)) return;
const continuedFromSessionId = options?.continuedFromSessionId
?? pendingCreateById.current.get(session.id)?.continuedFromSessionId;
const values = mode === "create"
? {
title: session.title,
@@ -160,8 +175,8 @@ export function useSessionManagement(params: SessionManagementParams) {
chart_profile_id: session.chartProfileId,
chart_profile_name: session.chartProfileName,
chart_profile_role: session.chartProfileRole,
...(options?.continuedFromSessionId
? { continued_from_session_id: options.continuedFromSessionId }
...(continuedFromSessionId
? { continued_from_session_id: continuedFromSessionId }
: {}),
}
: {
@@ -173,6 +188,11 @@ export function useSessionManagement(params: SessionManagementParams) {
chart_profile_role: session.chartProfileRole,
};
await writeChatSession(session.id, values, mode);
if (mode === "create") {
pendingCreateById.current.delete(session.id);
cloudCreatedIds.current.add(session.id);
if (!uiPreview.current) writeSessionUrl(session.id, "push");
}
}
async function ensureSessionMessages(sessionId: string) {
@@ -305,55 +325,24 @@ export function useSessionManagement(params: SessionManagementParams) {
async function startNewChat(options?: { continuedFromSessionId?: string }): Promise<ChatSession | null> {
if (!account || !modelCatalog || creatingSession) return null;
if (!options?.continuedFromSessionId) {
const reusable = findReusableEmptyConsultation(sessions);
if (reusable) {
setActiveSessionId(reusable.id);
if (!uiPreview.current) writeSessionUrl(reusable.id, "push");
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setComposerNotice("");
setRequestError(null);
return reusable;
}
}
const nextSession = {
...createSession(modelCatalog.defaultModelId),
...chartSnapshotForSession(activeChartId, chartLibrary, profile),
};
const previousSessionId = activeSession?.id ?? "";
const previousHref = `${window.location.pathname}${window.location.search}`;
setCreatingSession(true);
setSessions((current) => [nextSession, ...current]);
for (const session of sessions) {
if (isUnsavedEmptyConsultation(session)) pendingCreateById.current.delete(session.id);
}
pendingCreateById.current.set(nextSession.id, {
continuedFromSessionId: options?.continuedFromSessionId,
});
setSessions((current) => replaceUnsavedEmptyConsultations(current, nextSession));
setActiveSessionId(nextSession.id);
if (!uiPreview.current) writeSessionUrl(nextSession.id, "push");
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setComposerNotice("");
setRequestError(null);
try {
await persistSession(
nextSession,
"create",
options?.continuedFromSessionId
? { continuedFromSessionId: options.continuedFromSessionId }
: undefined,
);
return nextSession;
} catch (caught) {
setSessions((current) => current.filter((session) => session.id !== nextSession.id));
setActiveSessionId(previousSessionId);
if (!uiPreview.current) window.history.replaceState(null, "", previousHref);
setRequestError({
sessionId: previousSessionId,
message: caught instanceof Error ? caught.message : "新对话未能保存到云端。",
});
return null;
} finally {
setCreatingSession(false);
}
return nextSession;
}
function selectSession(sessionId: string) {
@@ -418,6 +407,7 @@ export function useSessionManagement(params: SessionManagementParams) {
archived: showArchivedSessions,
});
const incoming = readSessions(page.sessions, modelCatalog).sessions;
for (const session of incoming) cloudCreatedIds.current.add(session.id);
setSessions((current) => mergeSessionPage(current, incoming));
setSessionsCursor(page.nextCursor);
} catch (caught) {
@@ -434,6 +424,7 @@ export function useSessionManagement(params: SessionManagementParams) {
try {
const page = await fetchSessions(undefined, { archived: nextArchived });
const parsed = readSessions(page.sessions, modelCatalog).sessions;
for (const session of parsed) cloudCreatedIds.current.add(session.id);
const active = sessionsRef.current.find((session) => session.id === activeSessionId);
setSessions(active && !parsed.some((session) => session.id === active.id)
? mergeHydratedSession(parsed, active)
@@ -462,6 +453,7 @@ export function useSessionManagement(params: SessionManagementParams) {
if (query.present && requestedId && !listed.some((session) => session.id === requestedId)) {
void lookupSessionById(requestedId, modelCatalog).then((looked) => {
if (looked.status === "found") {
cloudCreatedIds.current.add(looked.session.id);
setSessions((current) => mergeHydratedSession(current, looked.session));
sessionSelectionSource.current = "history";
selectSession(looked.session.id);
+3 -15
View File
@@ -248,30 +248,18 @@ export async function fetchDailyStarlanguage(signal: AbortSignal): Promise<Daily
export type SessionListPage = {
readonly sessions: unknown;
readonly nextCursor: string | null;
readonly draft: unknown;
};
export function readSessionListPage(value: unknown): SessionListPage {
if (Array.isArray(value)) return { sessions: value, nextCursor: null, draft: null };
if (!value || typeof value !== "object") return { sessions: [], nextCursor: null, draft: null };
const page = value as { sessions?: unknown; nextCursor?: unknown; draft?: unknown };
if (Array.isArray(value)) return { sessions: value, nextCursor: null };
if (!value || typeof value !== "object") return { sessions: [], nextCursor: null };
const page = value as { sessions?: unknown; nextCursor?: unknown };
return {
sessions: page.sessions,
nextCursor: typeof page.nextCursor === "string" && page.nextCursor ? page.nextCursor : null,
draft: page.draft ?? null,
};
}
export function readDraftConsultation(
value: unknown,
catalog: PublicLanguageModelCatalog | null,
): ChatSession | null {
if (!value || typeof value !== "object") return null;
const parsed = readSessions([{ ...(value as object), messages: [] }], catalog).sessions[0];
if (!parsed || parsed.sessionType !== "consultation") return null;
return { ...parsed, messages: [], messagesHydrated: true };
}
export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult {
if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] };
const fallbackSessionIds: string[] = [];
+3 -6
View File
@@ -24,7 +24,6 @@ import { toSidebarSessionRow } from "@/lib/session-sidebar-row";
export type SessionListBoot = {
readonly sessions: ChatSession[];
readonly rawRows: unknown;
readonly draftRow: unknown;
readonly cursor: string | null;
readonly account: Account | null;
readonly signedOut: boolean;
@@ -96,16 +95,15 @@ async function loadSessionList(signal: AbortSignal): Promise<SessionListBoot> {
fetch("/api/account", { signal, cache: "no-store" }),
]);
if (sessionResponse.status === 401 || accountResponse.status === 401) {
return { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: true };
return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true };
}
const sessionPayload = await sessionResponse.json().catch(() => null) as {
sessions?: unknown;
nextCursor?: unknown;
draft?: unknown;
} | null;
const accountPayload = await accountResponse.json().catch(() => null);
if (!sessionResponse.ok || !accountResponse.ok) {
return { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: false };
return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false };
}
const rawRows = Array.isArray(sessionPayload?.sessions) ? sessionPayload.sessions : [];
const parsed = readSessions(rawRows, null);
@@ -113,7 +111,6 @@ async function loadSessionList(signal: AbortSignal): Promise<SessionListBoot> {
return {
sessions: parsed.sessions,
rawRows,
draftRow: sessionPayload?.draft ?? null,
cursor,
account: accountPayload as Account,
signedOut: false,
@@ -145,7 +142,7 @@ export function SessionListProvider({ children }: { children: ReactNode }) {
})
.catch(() => {
if (controller.signal.aborted) return;
bootRef.current = { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: false };
bootRef.current = { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false };
setSettled(true);
readyPack.resolve();
});
+38 -7
View File
@@ -1,14 +1,35 @@
import type { ChatSession } from "@/lib/home-types";
export function findReusableEmptyConsultation(
sessions: readonly ChatSession[],
): ChatSession | undefined {
return sessions.find((session) => (
session.sessionType === "consultation"
/** PostgREST `or()` that keeps rectification rows and non-empty consultations. */
export const EMPTY_CONSULTATION_LIST_FILTER = "session_type.neq.consultation,messages.neq.[]";
export function excludeEmptyConsultations<Query extends {
or: (expression: string) => Query;
}>(query: Query): Query {
// Rectification content lives on the case table; chat_sessions.messages is
// always []. Only unpublished consultation drafts are `messages = []`.
return query.or(EMPTY_CONSULTATION_LIST_FILTER);
}
export function applyArchiveFilter<Query extends {
is: (column: string, value: null) => Query;
not: (column: string, operator: string, value: null) => Query;
}>(query: Query, archived: boolean): Query {
return archived ? query.not("archived_at", "is", null) : query.is("archived_at", null);
}
export function isUnsavedEmptyConsultation(session: ChatSession): boolean {
return session.sessionType === "consultation"
&& !session.archivedAt
&& session.messagesHydrated
&& session.messages.length === 0
));
&& session.messages.length === 0;
}
export function replaceUnsavedEmptyConsultations(
sessions: readonly ChatSession[],
nextSession: ChatSession,
): ChatSession[] {
return [nextSession, ...sessions.filter((session) => !isUnsavedEmptyConsultation(session))];
}
export function isListedSidebarSession(session: ChatSession): boolean {
@@ -17,3 +38,13 @@ export function isListedSidebarSession(session: ChatSession): boolean {
if (!session.messagesHydrated) return true;
return session.messages.length > 0;
}
export function cloudListIncludesSession(input: {
sessionType: ChatSession["sessionType"];
messagesEmpty: boolean;
archived: boolean;
archivedView: boolean;
}): boolean {
if (input.sessionType === "consultation" && input.messagesEmpty) return false;
return input.archivedView ? input.archived : !input.archived;
}
+1 -2
View File
@@ -11,8 +11,7 @@ export function sessionSidebarSubtitle(
session: ChatSession,
library: readonly ChartLibraryRecord[] = [],
): string {
const created = session.createdAt || session.updatedAt;
const clock = Number.isFinite(created) ? shanghaiDateTimeLabel(new Date(created)) : "";
const clock = Number.isFinite(session.updatedAt) ? shanghaiDateTimeLabel(new Date(session.updatedAt)) : "";
const parts: string[] = [];
if (clock) parts.push(clock);
if (session.chartProfileRole && session.chartProfileRole !== "self" && session.chartProfileId) {