From 5a690b276f7e14e65927fab4545119f25972f0d6 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 2 May 2026 18:25:20 -0700 Subject: [PATCH] web: transcript improvements --- web/src/App.tsx | 56 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 2e6e907..051d7df 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -98,12 +98,25 @@ const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = { xai: null, }; +const TRANSCRIPT_BOTTOM_GAP = 20; +const REPLY_SCROLL_BUFFER_MIN = 288; +const REPLY_SCROLL_BUFFER_MAX = 576; +const REPLY_SCROLL_BUFFER_VIEWPORT_RATIO = 0.52; + function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) { const providerModels = catalog[provider]?.models ?? []; if (providerModels.length) return providerModels; return PROVIDER_FALLBACK_MODELS[provider]; } +function getReplyScrollBufferHeight() { + if (typeof window === "undefined") return REPLY_SCROLL_BUFFER_MIN; + return Math.min( + REPLY_SCROLL_BUFFER_MAX, + Math.max(REPLY_SCROLL_BUFFER_MIN, Math.round(window.innerHeight * REPLY_SCROLL_BUFFER_VIEWPORT_RATIO)) + ); +} + function loadStoredModelPreferences() { if (typeof window === "undefined") return EMPTY_MODEL_PREFERENCES; try { @@ -443,6 +456,7 @@ export default function App() { return stored.openai ?? PROVIDER_FALLBACK_MODELS.openai[0]; }); const [error, setError] = useState(null); + const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP); const transcriptContainerRef = useRef(null); const transcriptEndRef = useRef(null); const contextMenuRef = useRef(null); @@ -453,12 +467,41 @@ export default function App() { const shouldAutoScrollRef = useRef(true); const wasSendingRef = useRef(false); const pendingReplyScrollRef = useRef(false); + const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP); const [contextMenu, setContextMenu] = useState(null); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [sidebarQuery, setSidebarQuery] = useState(""); const initialRouteSelectionRef = useRef(readSidebarSelectionFromUrl()); const hasSyncedSelectionHistoryRef = useRef(false); + const setTranscriptTailSpacer = (height: number) => { + const nextHeight = Math.max(TRANSCRIPT_BOTTOM_GAP, Math.ceil(height)); + transcriptTailSpacerHeightRef.current = nextHeight; + setTranscriptTailSpacerHeight(nextHeight); + }; + + const expandTranscriptTailSpacer = (height: number) => { + const targetHeight = Math.max(TRANSCRIPT_BOTTOM_GAP, Math.ceil(height)); + setTranscriptTailSpacerHeight((currentHeight) => { + const nextHeight = Math.max(currentHeight, targetHeight); + transcriptTailSpacerHeightRef.current = nextHeight; + return nextHeight; + }); + }; + + const settleTranscriptTailSpacer = () => { + const container = transcriptContainerRef.current; + const currentSpacerHeight = transcriptTailSpacerHeightRef.current; + if (!container) { + setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP); + return; + } + + const scrollHeightWithoutSpacer = container.scrollHeight - currentSpacerHeight; + const requiredSpacerHeight = container.scrollTop + container.clientHeight - scrollHeightWithoutSpacer; + setTranscriptTailSpacer(requiredSpacerHeight); + }; + const focusComposer = () => { if (typeof window === "undefined") return; window.requestAnimationFrame(() => { @@ -653,6 +696,9 @@ export default function App() { useEffect(() => { shouldAutoScrollRef.current = true; + if (!isSending || !isChatReplyStreamingInView) { + setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP); + } }, [draftKind, selectedItem?.kind, selectedKey]); useEffect(() => { @@ -855,6 +901,7 @@ export default function App() { const handleSendChat = async (content: string) => { pendingReplyScrollRef.current = true; + expandTranscriptTailSpacer(getReplyScrollBufferHeight()); const optimisticUserMessage: Message = { id: `temp-user-${Date.now()}`, @@ -1043,6 +1090,7 @@ export default function App() { if (currentSelection?.kind === "chat" && currentSelection.id === chatId) { await refreshChat(chatId); } + settleTranscriptTailSpacer(); setPendingChatState(null); }; @@ -1462,9 +1510,11 @@ export default function App() { onStartChat={selectedSearch ? handleStartChatFromSearch : undefined} /> )} - {isChatReplyStreamingInView ? ( -