diff --git a/web/src/App.tsx b/web/src/App.tsx index 43455d6..9dac246 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -729,6 +729,8 @@ export default function App() { const wasSendingRef = useRef(false); const pendingReplyScrollRef = useRef(false); const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP); + const transcriptTailSpacerSettleFrameRef = useRef(null); + const transcriptViewKeyRef = useRef(null); const [contextMenu, setContextMenu] = useState(null); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [sidebarQuery, setSidebarQuery] = useState(""); @@ -753,6 +755,7 @@ export default function App() { const settleTranscriptTailSpacer = () => { const container = transcriptContainerRef.current; const currentSpacerHeight = transcriptTailSpacerHeightRef.current; + if (currentSpacerHeight <= TRANSCRIPT_BOTTOM_GAP) return; if (!container) { setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP); return; @@ -760,7 +763,22 @@ export default function App() { const scrollHeightWithoutSpacer = container.scrollHeight - currentSpacerHeight; const requiredSpacerHeight = container.scrollTop + container.clientHeight - scrollHeightWithoutSpacer; - setTranscriptTailSpacer(requiredSpacerHeight); + setTranscriptTailSpacer(Math.min(currentSpacerHeight, requiredSpacerHeight)); + }; + + const requestSettleTranscriptTailSpacer = () => { + if (transcriptTailSpacerHeightRef.current <= TRANSCRIPT_BOTTOM_GAP) return; + if (typeof window === "undefined") { + settleTranscriptTailSpacer(); + return; + } + if (transcriptTailSpacerSettleFrameRef.current !== null) { + window.cancelAnimationFrame(transcriptTailSpacerSettleFrameRef.current); + } + transcriptTailSpacerSettleFrameRef.current = window.requestAnimationFrame(() => { + transcriptTailSpacerSettleFrameRef.current = null; + settleTranscriptTailSpacer(); + }); }; const focusComposer = () => { @@ -1032,6 +1050,7 @@ export default function App() { }, [quickPrompt, isQuickQuestionOpen]); const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null; + const transcriptViewKey = draftKind ? `draft:${draftKind}` : selectedKey ?? "empty"; const selectedChatPendingState = selectedItem?.kind === "chat" ? pendingChatStates[selectedItem.id] ?? null : null; const selectedSearchRunState = selectedItem?.kind === "search" ? runningSearchStates[selectedItem.id] ?? null : null; const selectedChatIsActive = selectedItem?.kind === "chat" && (!!selectedChatPendingState || !!activeRuns.chats[selectedItem.id]); @@ -1053,11 +1072,13 @@ export default function App() { }; useEffect(() => { + const didViewChange = transcriptViewKeyRef.current !== transcriptViewKey; + transcriptViewKeyRef.current = transcriptViewKey; shouldAutoScrollRef.current = true; - if (!isSendingActiveChat) { + if (didViewChange && !pendingReplyScrollRef.current) { setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP); } - }, [isSendingActiveChat, selectedKey]); + }, [transcriptViewKey]); useEffect(() => { selectedItemRef.current = selectedItem; @@ -1116,6 +1137,10 @@ export default function App() { useEffect(() => { return () => { + if (transcriptTailSpacerSettleFrameRef.current !== null) { + window.cancelAnimationFrame(transcriptTailSpacerSettleFrameRef.current); + transcriptTailSpacerSettleFrameRef.current = null; + } for (const controller of chatStreamAbortRefs.current.values()) { controller.abort(); } @@ -1144,6 +1169,16 @@ export default function App() { if (selectedChatPendingState) return selectedChatPendingState.messages.filter(isDisplayableMessage); return messages.filter(isDisplayableMessage); }, [messages, selectedChatPendingState]); + const displayMessagesLayoutKey = useMemo( + () => displayMessages.map((message) => `${message.id}:${message.content.length}`).join("|"), + [displayMessages] + ); + + useEffect(() => { + if (isSearchMode || isSendingActiveChat) return; + requestSettleTranscriptTailSpacer(); + }, [displayMessagesLayoutKey, isSearchMode, isSendingActiveChat, selectedKey]); + const quickAnswerText = useMemo(() => { for (let index = quickQuestionMessages.length - 1; index >= 0; index -= 1) { const message = quickQuestionMessages[index]; @@ -1480,6 +1515,11 @@ export default function App() { }; const handleSendChat = async (content: string, attachments: ChatAttachment[]): Promise => { + const selectedModel = model.trim(); + if (!selectedModel) { + throw new Error("No model available for selected provider"); + } + pendingReplyScrollRef.current = true; expandTranscriptTailSpacer(getReplyScrollBufferHeight()); @@ -1563,11 +1603,6 @@ export default function App() { }, ]; - const selectedModel = model.trim(); - if (!selectedModel) { - throw new Error("No model available for selected provider"); - } - const chatSummary = chats.find((chat) => chat.id === chatId); const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim()); if (!hasExistingTitle && !pendingTitleGenerationRef.current.has(chatId)) { @@ -1687,13 +1722,17 @@ export default function App() { if (currentSelection?.kind === "chat" && currentSelection.id === chatId) { await refreshChat(chatId); } - settleTranscriptTailSpacer(); removePendingChatState(chatId); removeActiveRun("chat", chatId); + if (currentSelection?.kind === "chat" && currentSelection.id === chatId) { + requestSettleTranscriptTailSpacer(); + } return { kind: "chat", id: chatId }; } catch (err) { removePendingChatState(chatId); removeActiveRun("chat", chatId); + pendingReplyScrollRef.current = false; + setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP); throw err; } }; @@ -1954,6 +1993,9 @@ export default function App() { chatStreamAbortRefs.current.delete(chatId); removePendingChatState(chatId); removeActiveRun("chat", chatId); + if (isCurrentSelection(target)) { + requestSettleTranscriptTailSpacer(); + } } }; @@ -2303,6 +2345,10 @@ export default function App() { sentTarget = await handleSendChat(content, attachments); } } catch (err) { + if (!sentAsSearch) { + pendingReplyScrollRef.current = false; + setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP); + } const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); @@ -2564,6 +2610,9 @@ export default function App() { if (!container) return; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; shouldAutoScrollRef.current = distanceFromBottom < 96; + if (!isSearchMode && !isSendingActiveChat && distanceFromBottom > 0) { + settleTranscriptTailSpacer(); + } }} > {!isSearchMode ? (