Skip to content

Commit 96b6463

Browse files
committed
Fix four regressions from the density pass
Separating "which message does this run BELONG to" from "does it indent under that message" is the root fix for the first three: I had collapsed both onto one variable, so refusing to indent under your message also took away the run's owner. - a turn-opening run belongs to your message again, so → opens it and ← steps back out; it just doesn't branch off it visually - opening a long user message no longer unfolds the run below it as a side effect of un-clamping the text - a flat fold still wears ⎿, not the ✦ infrastructure mark - the ▶ falls to the topmost VISIBLE row of the selected block: with no highlight bar left, a block taller than the window showed no cursor
1 parent f30a92a commit 96b6463

3 files changed

Lines changed: 66 additions & 21 deletions

File tree

src/ui/ConnectApp.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -979,14 +979,21 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
979979
return rowViewport(allRows.length, viewBudget, anchor)
980980
}, [allRows, viewBudget, scrollAnchor])
981981

982-
// The one row that wears the ▶ marker: the highlighted block's FIRST row with
983-
// a gutter glyph. The whole block tints, but the marker points at a single
984-
// line — a block with nested tool activity has a glyph on the call and on its
985-
// ⎿ result, and marking both reads as two separate selections.
982+
// The one row that wears the ▶ marker: the selected block's FIRST row with a
983+
// gutter glyph, since the marker replaces that glyph in place. Only one row
984+
// takes it — a block with nested tool activity has a glyph on the call and on
985+
// its ⎿ result, and marking both reads as two separate selections.
986+
//
987+
// Restricted to rows ON SCREEN, because the marker is now the ONLY thing that
988+
// says "you are here" (there is no highlight bar any more). A block taller
989+
// than the window is bottom-aligned by the ↑ snap, which puts its first row
990+
// above the frame — so the marker falls to the topmost visible row of the
991+
// block, and the selection stays legible instead of vanishing.
986992
const markerRowId = useMemo(() => {
987993
if (navKey === null) return null
988-
return allRows.find((r) => navKeyOf(r) === navKey && r.gutter)?.id ?? null
989-
}, [allRows, navKey])
994+
const onScreen = allRows.slice(view.start, view.end).filter((r) => navKeyOf(r) === navKey)
995+
return (onScreen.find((r) => r.gutter) ?? onScreen.find((r) => !r.spacer))?.id ?? null
996+
}, [allRows, navKey, view.start, view.end])
990997

991998
// Move the window by `delta` ROWS. Reaching the last row re-pins it to the
992999
// bottom, so streamed content follows again.

src/ui/transcriptRows.ts

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,11 @@ export function itemRows(
210210
const indent = opts.indent ?? 0
211211
// Nested lines are marked by their INDENT, so each keeps the glyph that says
212212
// what it is: ● the call, ⎿ the result that came back. Only a collapsed fold
213-
// ("Ran 2 tool calls") takes the branch glyph — as a notice it would
214-
// otherwise wear ✦, the mark for the infrastructure speaking, which is not
215-
// what a fold is.
216-
const gutter = opts.nested && item.kind === 'notice' ? BRANCH_GLYPH : gutterFor(item)
213+
// (key grp:*, "Ran 2 tool calls") takes the branch glyph — as a notice it
214+
// would otherwise wear ✦, the mark for the infrastructure speaking, which is
215+
// not what a fold is. Keyed on the fold itself, not on `nested`: a
216+
// turn-opening run is flat, and it is still a fold.
217+
const gutter = item.key.startsWith('grp:') ? BRANCH_GLYPH : gutterFor(item)
217218
const textPad = gutter === BRANCH_GLYPH ? BRANCH_TEXT_PAD : 0
218219
const width = contentWidth(cols, { indent, textPad })
219220
const shown = withRenderedMarkdown(item, width)
@@ -270,14 +271,18 @@ export function itemRows(
270271
// glyph, attached with no blank row between. Prose, user messages and notices
271272
// keep their own gutter mark and their spacing.
272273
//
273-
// The parent is always an ASSISTANT message. A run with none before it (the
274-
// agent opened its turn with a tool call) stays FLAT rather than hanging off
275-
// the user message that prompted it: your message is a lifted box, and a ⎿
276-
// branch under it would read as work YOU did.
274+
// INDENT and OWNERSHIP are decided separately, because they answer different
275+
// questions. A run belongs to (is opened by, travels with) whatever message
276+
// came last, YOURS INCLUDED — the agent often opens a turn with a tool call,
277+
// and a run that belonged to nothing could not be reached with →. But it only
278+
// INDENTS under assistant prose: your message is a lifted box, and a ⎿ branch
279+
// under it would read as work YOU did, so a turn-opening run stays flat and
280+
// separated by its own blank row. Only a run at the very top of the transcript,
281+
// with no message above it at all, belongs to nothing.
277282
//
278-
// Nesting also decides what ↑/↓ can LAND on, because a tool call is not a stop
279-
// of its own — it belongs to the message that made it. Three levels, each
280-
// opened by → on the level above:
283+
// Ownership is what ↑/↓ can LAND on, because a tool call is not a stop of its
284+
// own — it belongs to the message that made it. Three levels, each opened by →
285+
// on the level above:
281286
//
282287
// ● the message a stop; ↑/↓ walk these
283288
// ⎿ Ran 3 tool calls part of the message's block (navKey → the message)
@@ -309,19 +314,23 @@ export function layOutItems(
309314
opts: { openedKeys?: ReadonlySet<string>; revealAll?: boolean } = {},
310315
): PlacedItem[] {
311316
const out: PlacedItem[] = []
312-
// The ASSISTANT message the current run hangs offnull until the agent has
313-
// said something, where a run has nothing to hang off and stays flat.
317+
// The message the current run BELONGS towhat → opens and ↑/↓ land on.
318+
// Either sender's; null only at the head of the transcript.
314319
let parent: string | null = null
320+
// Whether that message was the agent's, which is what decides the visual
321+
// nesting: only the agent's prose gets a ⎿ branch under it.
322+
let parentIsAgent = false
315323
// The call a ⎿ result belongs to, so a result travels with its own call.
316324
let call: string | null = null
317325
for (const item of items) {
318326
if (!isToolActivity(item)) {
319327
out.push({ item, indent: 0, nested: false, attach: false })
320-
parent = item.kind === 'assistant' ? item.key : null
328+
parent = item.key
329+
parentIsAgent = item.kind === 'assistant'
321330
call = null
322331
continue
323332
}
324-
const nested = parent !== null
333+
const nested = parent !== null && parentIsAgent
325334
const revealed =
326335
opts.revealAll === true || (parent !== null && opts.openedKeys?.has(parent) === true)
327336
if (item.kind === 'tool') call = item.key

test/connect-app.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,14 @@ describe('layOutItems', () => {
561561
])
562562
})
563563

564+
it('still lets that flat run BELONG to your message, so → can open it', () => {
565+
// Indent and ownership are separate: the run doesn't branch off your
566+
// message visually, but it is reached by opening it. Owning nothing would
567+
// make the run unreachable without ctrl+r.
568+
const out = layOutItems([user('u'), fold('t1')])
569+
expect(out[1].navKey).toBe('u')
570+
})
571+
564572
it('leaves a run with no parent above it flat', () => {
565573
// Replayed history can start mid-burst; there is nothing to hang off.
566574
const out = layOutItems([call('t1'), res('r1'), prose('a')])
@@ -832,6 +840,27 @@ describe('itemRows', () => {
832840
expect(withGutter[0].gutter?.text).toBe('◆')
833841
})
834842

843+
it('marks a fold ⎿ whether or not it nests, since a flat one is still a fold', () => {
844+
const foldItem: TranscriptItem = { key: 'grp:t1', kind: 'notice', text: 'Ran 1 shell command' }
845+
for (const nested of [true, false]) {
846+
const rows = itemRows(foldItem, 40, { clamp: false, nested })
847+
expect(rows[0].gutter?.text, `nested=${nested}`).toBe('⎿')
848+
}
849+
// A real ✦ notice keeps its own mark either way.
850+
const notice: TranscriptItem = { key: 'n', kind: 'notice', text: 'Session asleep' }
851+
expect(itemRows(notice, 40, { clamp: false, nested: true })[0].gutter?.text).toBe('✦')
852+
})
853+
854+
it('pads every row of a ⎿ item, so a wrapped body stays aligned', () => {
855+
const rows = itemRows({ key: 'r', kind: 'tool_result', text: 'a\nb', gutter: '⎿' }, 40, {
856+
clamp: false,
857+
})
858+
expect(rows.map((r) => r.textPad)).toEqual([1, 1])
859+
// And nothing else gets it.
860+
expect(itemRows({ key: 'a', kind: 'assistant', text: 'hi' }, 40, { clamp: false })[0].textPad)
861+
.toBe(0)
862+
})
863+
835864
it('leads with a spacer row when the item wants space before it', () => {
836865
const rows = itemRows({ key: 'a', kind: 'notice', text: 'note', spaceBefore: true }, 40, {
837866
clamp: false,

0 commit comments

Comments
 (0)