From b115ea62da2c8bb5c1dc627bbc2d6e426bbc9dda Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Apr 2026 20:07:29 -0500 Subject: [PATCH] feat(tui): anchor LiveTodoPanel to latest user message row TodoPanel now renders as a child of the most recent user message's virtualized row container, so it visually belongs to that prompt and follows it during scroll. Falls back gracefully when no user message exists yet (panel just doesn't render). --- ui-tui/src/components/appLayout.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d0fe73de35b..2e594f8caee 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -1,6 +1,6 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' -import { Fragment, memo } from 'react' +import { Fragment, memo, useMemo } from 'react' import { useGateway } from '../app/gatewayContext.js' import type { AppLayoutProps } from '../app/interfaces.js' @@ -30,6 +30,17 @@ const TranscriptPane = memo(function TranscriptPane({ }: Pick) { const ui = useStore($uiState) + // Index of the latest user message — LiveTodoPanel is rendered as a child + // of that row so it visually belongs to the user's prompt and follows it + // during scroll. Falls back to -1 when no user message exists yet (empty + // session); LiveTodoPanel then doesn't render at all. + const lastUserIdx = useMemo(() => { + for (let i = transcript.historyItems.length - 1; i >= 0; i--) { + if (transcript.historyItems[i].role === 'user') return i + } + return -1 + }, [transcript.historyItems]) + return ( <> @@ -58,13 +69,13 @@ const TranscriptPane = memo(function TranscriptPane({ t={ui.theme} /> )} + + {row.index === lastUserIdx && } ))} {transcript.virtualHistory.bottomSpacer > 0 ? : null} - -