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).
This commit is contained in:
Brooklyn Nicholson
2026-04-26 20:07:29 -05:00
parent 25767513f2
commit b115ea62da

View File

@@ -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<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
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 (
<>
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
@@ -58,13 +69,13 @@ const TranscriptPane = memo(function TranscriptPane({
t={ui.theme}
/>
)}
{row.index === lastUserIdx && <LiveTodoPanel />}
</Box>
))}
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
<LiveTodoPanel />
<StreamingAssistant
cols={composer.cols}
compact={ui.compact}