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
+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) {