From 9a885fba31e5ae8a8a24b7f5dcf8ea19dedddf5c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 14:32:29 -0500 Subject: [PATCH] fix(ui-tui): hide stale sticky prompt when newer prompt is visible Sticky prompt selection only considered the top edge of the viewport, so it could keep showing an older user prompt even when a newer one was already visible lower down. Suppress sticky output whenever a user message is visible in the viewport and cover it with a regression test. --- ui-tui/src/__tests__/viewport.test.ts | 31 +++++++++++++++++++++++++++ ui-tui/src/components/appChrome.tsx | 2 +- ui-tui/src/domain/viewport.ts | 10 ++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 ui-tui/src/__tests__/viewport.test.ts diff --git a/ui-tui/src/__tests__/viewport.test.ts b/ui-tui/src/__tests__/viewport.test.ts new file mode 100644 index 000000000..0a949e44c --- /dev/null +++ b/ui-tui/src/__tests__/viewport.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { stickyPromptFromViewport } from '../domain/viewport.js' + +describe('stickyPromptFromViewport', () => { + it('hides the sticky prompt when a newer user message is already visible', () => { + const messages = [ + { role: 'user' as const, text: 'older prompt' }, + { role: 'assistant' as const, text: 'older answer' }, + { role: 'user' as const, text: 'current prompt' }, + { role: 'assistant' as const, text: 'current answer' } + ] + + const offsets = [0, 2, 10, 12, 20] + + expect(stickyPromptFromViewport(messages, offsets, 16, 8, false)).toBe('') + }) + + it('shows the latest user message above the viewport when no user message is visible', () => { + const messages = [ + { role: 'user' as const, text: 'older prompt' }, + { role: 'assistant' as const, text: 'older answer' }, + { role: 'user' as const, text: 'current prompt' }, + { role: 'assistant' as const, text: 'current answer' } + ] + + const offsets = [0, 2, 10, 12, 20] + + expect(stickyPromptFromViewport(messages, offsets, 20, 16, false)).toBe('current prompt') + }) +}) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 8b1f816ce..d7974d533 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -270,7 +270,7 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: const vp = Math.max(0, s?.getViewportHeight() ?? 0) const total = Math.max(vp, s?.getScrollHeight() ?? vp) const atBottom = (s?.isSticky() ?? true) || top + vp >= total - 2 - const text = stickyPromptFromViewport(messages, offsets, top, atBottom) + const text = stickyPromptFromViewport(messages, offsets, top + vp, top, atBottom) useEffect(() => onChange(text), [onChange, text]) diff --git a/ui-tui/src/domain/viewport.ts b/ui-tui/src/domain/viewport.ts index 788f94269..3a358eb6f 100644 --- a/ui-tui/src/domain/viewport.ts +++ b/ui-tui/src/domain/viewport.ts @@ -18,6 +18,7 @@ const upperBound = (offsets: ArrayLike, target: number) => { export const stickyPromptFromViewport = ( messages: readonly Msg[], offsets: ArrayLike, + bottom: number, top: number, sticky: boolean ) => { @@ -26,8 +27,15 @@ export const stickyPromptFromViewport = ( } const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1)) + const last = Math.max(first, Math.min(messages.length - 1, upperBound(offsets, bottom) - 1)) - for (let i = first; i >= 0; i--) { + for (let i = first; i <= last; i++) { + if (messages[i]?.role === 'user') { + return '' + } + } + + for (let i = first - 1; i >= 0; i--) { if (messages[i]?.role !== 'user') { continue }