diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 9b5510df3..64bfda7d0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -43,13 +43,14 @@ import { type EditMessageDialogChoice, } from "@/components/assistant-ui/edit-message-dialog"; import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator"; -import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps"; import { Thread } from "@/components/assistant-ui/thread"; import { createTokenUsageStore, type TokenUsageData, TokenUsageProvider, } from "@/components/assistant-ui/token-usage-context"; +import { type BundleSubmit, HitlBundleProvider } from "@/features/chat-messages/hitl"; +import { TimelineDataUI } from "@/features/chat-messages/timeline"; import { applyActionLogSse, applyActionLogUpdatedSse, @@ -63,7 +64,7 @@ import { documentsApiService } from "@/lib/apis/documents-api.service"; import { getBearerToken } from "@/lib/auth-utils"; import { type ChatFlow, classifyChatError } from "@/lib/chat/chat-error-classifier"; import { tagPreAcceptSendFailure, toHttpResponseError } from "@/lib/chat/chat-request-errors"; -import { convertToThreadMessage } from "@/lib/chat/message-utils"; +import { convertToThreadMessage, filterSupersededAbortedMessages } from "@/lib/chat/message-utils"; import { isPodcastGenerating, looksLikePodcastRequest, @@ -107,7 +108,6 @@ import { type NewChatUserImagePayload, } from "@/lib/chat/user-turn-api-parts"; import { NotFoundError } from "@/lib/error"; -import { type BundleSubmit, HitlBundleProvider } from "@/lib/hitl"; import { trackChatBlocked, trackChatCreated, @@ -126,7 +126,7 @@ const MobileEditorPanel = dynamic( ); const MobileHitlEditPanel = dynamic( () => - import("@/components/hitl-edit-panel/hitl-edit-panel").then((m) => ({ + import("@/features/chat-messages/hitl").then((m) => ({ default: m.MobileHitlEditPanel, })), { ssr: false } @@ -395,7 +395,7 @@ export default function NewChatPage() { const memberById = new Map(membersData?.map((m) => [m.user_id, m]) ?? []); const prevById = new Map(prev.map((m) => [m.id, m])); - return syncedMessages.map((msg) => { + return filterSupersededAbortedMessages(syncedMessages).map((msg) => { const member = msg.author_id ? (memberById.get(msg.author_id) ?? null) : null; // Preserve existing author info if member lookup fails (e.g., cloned chats) @@ -622,7 +622,9 @@ export default function NewChatPage() { setCurrentThread(threadData); if (messagesResponse.messages && messagesResponse.messages.length > 0) { - const loadedMessages = messagesResponse.messages.map(convertToThreadMessage); + const loadedMessages = filterSupersededAbortedMessages(messagesResponse.messages).map( + convertToThreadMessage + ); setMessages(loadedMessages); for (const msg of messagesResponse.messages) { @@ -1388,6 +1390,8 @@ export default function NewChatPage() { const existingMsg = messages.find((m) => m.id === assistantMsgId); if (existingMsg && Array.isArray(existingMsg.content)) { + // See ``ContentPartsState.suppressStepSeparators`` doc. + contentPartsState.suppressStepSeparators = true; for (const part of existingMsg.content) { if (typeof part === "object" && part !== null) { const p = part as Record; @@ -1402,15 +1406,19 @@ export default function NewChatPage() { toolName: String(p.toolName), args: (p.args as Record) ?? {}, result: p.result as unknown, - // Restore argsText so persisted pretty-printed - // JSON survives reloads (assistant-ui prefers - // supplied argsText over JSON.stringify(args)). - // langchainToolCallId restoration also fixes a - // pre-existing dropped-id bug on resume. + // argsText: assistant-ui prefers it over + // JSON.stringify(args), so restoring it keeps + // pretty-printed JSON across reloads. ...(typeof p.argsText === "string" ? { argsText: p.argsText } : {}), ...(typeof p.langchainToolCallId === "string" ? { langchainToolCallId: p.langchainToolCallId } : {}), + // metadata: spanId / thinkingStepId drive the + // timeline's step↔tool join. Dropping these + // here orphans every rehydrated tool-call. + ...(p.metadata && typeof p.metadata === "object" + ? { metadata: p.metadata as Record } + : {}), }); contentPartsState.currentTextPartIndex = -1; } else if (p.type === "data-thinking-steps") { @@ -2353,7 +2361,7 @@ export default function NewChatPage() { return ( - + ( + messages: readonly T[], + idx: number +): boolean { + for (let i = idx + 1; i < messages.length; i++) { + const role = messages[i].role.toLowerCase(); + if (role === "user") return false; + if (role === "assistant") return true; + } + return false; +} + +/** + * Drop frozen interrupt-frame rows once they have a resumed + * continuation. Pure (returns a new array). Caller passes messages in + * chronological order. + * + * Never-resumed aborts are preserved (user navigated away mid-decision) + * so the user still sees what happened. + */ +export function filterSupersededAbortedMessages( + messages: readonly T[] +): T[] { + return messages.filter((msg, idx) => { + if (!isAbortedAssistantMessage(msg)) return true; + return !isSupersededByLaterAssistant(messages, idx); + }); +} + +/** + * Convert a backend ``MessageRecord`` to assistant-ui's + * ``ThreadMessageLike``. Also migrates legacy ``thinking-steps`` parts + * to ``data-thinking-steps``. */ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { let content: ThreadMessageLike["content"]; @@ -24,9 +90,10 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { "type" in part && (part as { type: string }).type === "thinking-steps" ) { + const steps = (part as unknown as { steps?: unknown[] }).steps; return { type: "data-thinking-steps", - data: { steps: (part as { steps: unknown[] }).steps ?? [] }, + data: { steps: Array.isArray(steps) ? steps : [] }, }; } return part; @@ -50,9 +117,8 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { }, }), ...(msg.token_usage && { usage: msg.token_usage }), - // Surface ``chat_turn_id`` so the assistant message - // footer can scope its "Revert turn" button to just - // this turn's actions. Null on legacy rows. + // Surfaced for the assistant footer's per-turn + // "Revert turn" button. Null on legacy rows. ...(msg.turn_id && { chatTurnId: msg.turn_id }), }, }