Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 8 additions & 14 deletions src/ui/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import {
padPanelBlocks,
pendingMessageRows,
rowViewport,
snapToEntry,
snapAnchorForEntry,
spacerRow,
type RowSpan,
type ScrollAnchor,
Expand Down Expand Up @@ -988,18 +988,12 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
[allRows, view],
)

// Snap the window so a highlighted entry is readable: entering from above,
// its first line goes to the top of the frame; from below, it aligns to the
// bottom edge. See snapToEntry.
// Snap the window so a highlighted entry is readable, given which way the
// highlight is travelling (`dir`: 1 for ↓, -1 for ↑). See snapToEntry.
const ensureVisible = useCallback(
(key: string): void => {
const target = snapToEntry(allRows, key, view, view.capacity)
if (target === null) return
const range = entryRange(allRows, key)
// Snapping to the newest entry means following the bottom again, so new
// content keeps arriving in view.
if (range && range.last >= allRows.length - 1) setScrollAnchor(null)
else setScrollAnchor(anchorAt(allRows, target))
(key: string, dir: 1 | -1 = 1): void => {
const move = snapAnchorForEntry(allRows, key, view, view.capacity, dir)
if (move) setScrollAnchor(move.anchor)
},
[allRows, view],
)
Expand Down Expand Up @@ -1107,7 +1101,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
const target = idx === -1 ? navKeys.length - 1 : Math.max(0, idx - 1)
if (navKeys.length > 0) {
setNavKey(navKeys[target])
ensureVisible(navKeys[target])
ensureVisible(navKeys[target], -1)
}
return
}
Expand All @@ -1118,7 +1112,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
setScrollAnchor(null)
} else {
setNavKey(navKeys[idx + 1])
ensureVisible(navKeys[idx + 1])
ensureVisible(navKeys[idx + 1], 1)
}
return
}
Expand Down
53 changes: 42 additions & 11 deletions src/ui/transcriptRows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,27 +519,58 @@ export function entryRange(
}

// Where the window must sit for `entryKey` to be readable, given where it sits
// now — the ↑/↓ snap.
// now and which way the highlight is travelling (`dir`: 1 for ↓, -1 for ↑) —
// the ↑/↓ snap.
//
// An entry coming into frame from ABOVE puts its first line at the TOP of the
// window: you read a message from its beginning, with as much of it in front
// of you as fits. So does one too tall to fit whole. An entry arriving from
// BELOW aligns to the bottom edge, the direction it was already travelling.
// One already fully in frame doesn't move the window at all. Returns the flat
// row index to pin to the top, or null to leave the window alone. Pure, for
// tests.
// An entry already fully in frame doesn't move the window at all. One that
// FITS but sits off-frame comes in from the side it is on: from above to the
// top of the window, from below to the bottom edge. One TOO TALL to fit lands
// on the edge you are heading towards, so the walk keeps its direction of
// travel: ↓ lands on its FIRST line (you read a long message from its
// beginning) and ↑ on its LAST (you back into the end of it). From there
// ↑/↓ scroll THROUGH the rest of it a row at a time — see revealMore — so the
// two together read the entry continuously in whichever direction you started.
//
// Returns the flat row index to pin to the top, or null to leave the window
// alone. Pure, for tests.
export function snapToEntry(
rows: readonly TranscriptRow[],
entryKey: string,
view: { start: number; end: number },
capacity: number,
dir: 1 | -1 = 1,
): number | null {
const range = entryRange(rows, entryKey)
if (!range) return null
// Already readable whole: don't jostle the window.
if (range.first >= view.start && range.last < view.end) return null
const bottomAligned = Math.max(0, range.last - capacity + 1)
const height = range.last - range.first + 1
if (range.first < view.start || height >= capacity) return range.first
if (range.last >= view.end) return Math.max(0, range.last - capacity + 1)
return null
if (height >= capacity) return dir < 0 ? bottomAligned : range.first
return range.first < view.start ? range.first : bottomAligned
}

// snapToEntry's row index as the scroll move to apply: null to leave the window
// where it is, or the anchor to park on — itself null when the snap lands on the
// last screenful, which means following the bottom again so streamed content
// keeps arriving in view.
//
// That bottom-follow is keyed on WHERE THE SNAP LANDS, not on the entry being
// the newest one: an entry taller than the window is snapped to an interior row
// (its first line, when walking down into it), and pinning to the bottom there
// would throw the snap away and show the entry's end instead of its beginning.
// Pure, for tests.
export function snapAnchorForEntry(
rows: readonly TranscriptRow[],
entryKey: string,
view: { start: number; end: number },
capacity: number,
dir: 1 | -1 = 1,
): { anchor: ScrollAnchor | null } | null {
const target = snapToEntry(rows, entryKey, view, capacity, dir)
if (target === null) return null
if (target >= rows.length - capacity) return { anchor: null }
return { anchor: anchorAt(rows, target) }
}

// Agent and user prose rendered as markdown (bold, headings, bullets, tables,
Expand Down
50 changes: 50 additions & 0 deletions test/connect-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
itemRows,
layOutItems,
rowViewport,
snapAnchorForEntry,
snapToEntry,
withRenderedMarkdown,
type TranscriptRow,
Expand Down Expand Up @@ -902,6 +903,55 @@ describe('snapToEntry', () => {
]
expect(snapToEntry(short, 'b', { start: 0, end: 3 }, 3)).toBeNull()
})

it('backs ↑ into a too-tall entry at its LAST line, keeping the walk going up', () => {
// 'b' spans rows 1..10. Walking UP out of 'c' lands on 'b's bottom edge
// (rows 7..10 on a 4-row window), not its far-away first line.
expect(snapToEntry(rows, 'b', { start: 8, end: 12 }, 4, -1)).toBe(7)
})

it('walks ↓ into a too-tall entry at its FIRST line', () => {
expect(snapToEntry(rows, 'b', { start: 0, end: 4 }, 4, 1)).toBe(1)
})
})

describe('snapAnchorForEntry', () => {
// A short entry, then one 10 rows tall — taller than the 4-row window.
const shortThenLong: TranscriptRow[] = [
{ id: 'a', entryKey: 'a', spans: [] },
...Array.from({ length: 10 }, (_, i) => ({ id: `b${i}`, entryKey: 'b', spans: [] })),
]

it('parks on the TOP of a trailing too-tall entry walked into from above', () => {
// ↓ off the short entry onto the long last one: its first line, NOT the
// bottom-follow that being the newest entry used to force.
expect(snapAnchorForEntry(shortThenLong, 'b', { start: 0, end: 4 }, 4, 1)).toEqual({
anchor: { entryKey: 'b', rowOffset: 0 },
})
})

it('follows the bottom when the snap really does land on the last screenful', () => {
// The same trailing entry, but short enough to fit: bottom-aligning it IS
// the bottom of the log, so keep streaming content in view.
const shortTail: TranscriptRow[] = [
...Array.from({ length: 6 }, (_, i) => ({ id: `a${i}`, entryKey: 'a', spans: [] })),
{ id: 'b0', entryKey: 'b', spans: [] },
]
expect(snapAnchorForEntry(shortTail, 'b', { start: 0, end: 4 }, 4, 1)).toEqual({ anchor: null })
})

it('parks on the BOTTOM of a too-tall entry walked into from below', () => {
// (short) (long) (short) with the highlight on the trailing short one: ↑
// lands on the long entry's END — rows 7..10 of an 11-row block.
const withTail: TranscriptRow[] = [...shortThenLong, { id: 'c', entryKey: 'c', spans: [] }]
expect(snapAnchorForEntry(withTail, 'b', { start: 8, end: 12 }, 4, -1)).toEqual({
anchor: { entryKey: 'b', rowOffset: 6 },
})
})

it('reports no move for an entry already fully in frame', () => {
expect(snapAnchorForEntry(shortThenLong, 'a', { start: 0, end: 4 }, 4, -1)).toBeNull()
})
})

describe('withRenderedMarkdown', () => {
Expand Down