@@ -44,10 +44,13 @@ import {
4444 entryRange ,
4545 GUTTER_COLS ,
4646 isCollapsible ,
47+ isToolActivity ,
4748 itemRows ,
4849 layOutItems ,
4950 LIVE_GLYPH ,
5051 MESSAGE_PAD ,
52+ navKeyOf ,
53+ padPanelBlocks ,
5154 pendingMessageRows ,
5255 rowViewport ,
5356 snapToEntry ,
@@ -618,29 +621,33 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
618621 } , [ inputActive , mouseCapture , stdout ] )
619622
620623 // The rendered transcript lines, in order: collapsed (the default) folds
621- // consecutive tool activity into "Ran N …" notices, except folds opened in
622- // place with → (openedKeys), which render their tool calls right below the
623- // fold line — indented one level (2 columns), so the expansion reads as
624- // the fold's children — and ← closes them again. Expanded (ctrl+r) shows
625- // everything, flat. `indented` carries the keys of fold-child lines.
626- const { visible, indented } = useMemo ( ( ) => {
624+ // consecutive tool activity into "Ran N …" notices, except the runs under a
625+ // MESSAGE opened in place with → (openedKeys), which render their tool calls
626+ // right below the fold line — indented one level (2 columns), so the
627+ // expansion reads as the fold's children — and ← closes them again. The
628+ // message is what opens, not the fold: a run of tool calls is work that
629+ // message did, so it is reached by opening the message (see layOutItems).
630+ // Expanded (ctrl+r) shows everything, flat.
631+ const visible = useMemo ( ( ) => {
627632 const pendingKeys = new Set ( pendingTools . map ( ( t ) => t . key ) )
628633 const base = pendingKeys . size ? items . filter ( ( i ) => ! pendingKeys . has ( i . key ) ) : items
629- if ( expanded ) return { visible : items , indented : new Set < string > ( ) }
634+ if ( expanded ) return items
630635 const folded = collapseToolRuns ( base )
631- if ( openedKeys . size === 0 ) return { visible : folded , indented : new Set < string > ( ) }
636+ if ( openedKeys . size === 0 ) return folded
632637 const out : TranscriptItem [ ] = [ ]
633- const indentedKeys = new Set < string > ( )
638+ // The message a fold hangs off: opening THAT is what reveals the run.
639+ let parent : string | null = null
634640 for ( const item of folded ) {
635641 out . push ( item )
636- if ( item . key . startsWith ( 'grp:' ) && openedKeys . has ( item . key ) ) {
637- for ( const child of foldRun ( item . key , base ) ) {
638- out . push ( child )
639- indentedKeys . add ( child . key )
640- }
642+ if ( ! isToolActivity ( item ) ) {
643+ parent = item . key
644+ continue
645+ }
646+ if ( item . key . startsWith ( 'grp:' ) && parent !== null && openedKeys . has ( parent ) ) {
647+ out . push ( ...foldRun ( item . key , base ) )
641648 }
642649 }
643- return { visible : out , indented : indentedKeys }
650+ return out
644651 } , [ items , expanded , pendingTools , openedKeys ] )
645652
646653 const infraActivity = statusActivityText ( statusWord )
@@ -794,16 +801,28 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
794801 )
795802 }
796803 // Tool activity is nested under the message that produced it (layOutItems
797- // decides what hangs off what), so a call and its result read as work the
798- // agent did mid-message rather than as turns of their own.
799- for ( const placed of layOutItems ( visible , { indentedKeys : indented } ) ) {
804+ // decides what hangs off what, and what ↑/↓ can land on), so a call and its
805+ // result read as work the agent did mid-message rather than as turns of
806+ // their own.
807+ for ( const placed of layOutItems ( visible , { openedKeys, revealAll : expanded } ) ) {
808+ const rows = itemRows ( placed . item , cols , {
809+ indent : placed . indent ,
810+ nested : placed . nested ,
811+ attach : placed . attach ,
812+ // Opening a block un-clamps what it owns as well as itself: → on a
813+ // revealed tool call shows the full output of the ⎿ result under it,
814+ // which is the line that actually carries the body.
815+ clamp :
816+ ! expanded &&
817+ ! openedKeys . has ( placed . item . key ) &&
818+ ! ( placed . navKey !== undefined && openedKeys . has ( placed . navKey ) ) ,
819+ } )
820+ // A line inside another's block carries that block's nav key, so ↑/↓
821+ // land on the block and this line travels with it.
800822 out . push (
801- ...itemRows ( placed . item , cols , {
802- indent : placed . indent ,
803- nested : placed . nested ,
804- attach : placed . attach ,
805- clamp : ! expanded && ! openedKeys . has ( placed . item . key ) ,
806- } ) ,
823+ ...( placed . navKey || placed . parentKey
824+ ? rows . map ( ( r ) => ( { ...r , navKey : placed . navKey , parentKey : placed . parentKey } ) )
825+ : rows ) ,
807826 )
808827 }
809828 // Sends the agent has TAKEN (delivered, echo record still in flight):
@@ -837,34 +856,38 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
837856 } ) ,
838857 )
839858 }
840- return out
859+ // Every lifted block gets its blank tinted row above and below, here so a
860+ // message and the tool run attached under it share one pad.
861+ return padPanelBlocks ( out )
841862 } , [
842863 infraActivity ,
843864 sandbox ,
844865 sandboxSettled ,
845866 sandboxLogOpen ,
846867 visible ,
847- indented ,
848868 expanded ,
849869 openedKeys ,
850870 inFlightSends ,
851871 liveTail ,
852872 cols ,
853873 ] )
854874
855- // Everything ↑/↓ can land on, top to bottom: the entries with rows on the
856- // list, minus turn summaries ("turn complete · 3s · $0.03") — informational
857- // trailers, not content, so the walk skips them (they still render and
858- // scroll) — and minus the live tail, which moves under you as it streams.
875+ // Everything ↑/↓ can land on, top to bottom: the BLOCKS with rows on the list
876+ // (a nested tool line is part of its message's block, not a stop of its own —
877+ // see layOutItems), minus turn summaries ("turn complete · 3s · $0.03") —
878+ // informational trailers, not content, so the walk skips them (they still
879+ // render and scroll) — and minus the live tail, which moves under you as it
880+ // streams.
859881 const navKeys = useMemo ( ( ) => {
860882 const skip = new Set ( visible . filter ( ( i ) => i . kind === 'summary' ) . map ( ( i ) => i . key ) )
861883 const seen = new Set < string > ( )
862884 const out : string [ ] = [ ]
863885 for ( const row of allRows ) {
864- if ( skip . has ( row . entryKey ) || row . entryKey . startsWith ( 'live' ) ) continue
865- if ( seen . has ( row . entryKey ) ) continue
866- seen . add ( row . entryKey )
867- out . push ( row . entryKey )
886+ const key = navKeyOf ( row )
887+ if ( skip . has ( key ) || key . startsWith ( 'live' ) ) continue
888+ if ( seen . has ( key ) ) continue
889+ seen . add ( key )
890+ out . push ( key )
868891 }
869892 return out
870893 } , [ allRows , visible ] )
@@ -921,6 +944,21 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
921944 [ allRows , view , scrollByRows ] ,
922945 )
923946
947+ // Whether a block has tool activity nested under it that → can reveal: rows
948+ // that name it as their block but aren't its own (see layOutItems).
949+ const hasToolRun = useCallback (
950+ ( key : string ) : boolean => allRows . some ( ( r ) => r . navKey === key ) ,
951+ [ allRows ] ,
952+ )
953+
954+ // The block a stop sits inside, for ← to step out to: a tool call revealed
955+ // under an opened message names that message.
956+ const parentOf = useCallback (
957+ ( key : string ) : string | null =>
958+ allRows . find ( ( r ) => navKeyOf ( r ) === key && r . parentKey ) ?. parentKey ?? null ,
959+ [ allRows ] ,
960+ )
961+
924962 const insertAtCursor = useCallback ( ( ch : string ) : void => {
925963 setComposer ( ( { text, cursor } ) => ( {
926964 text : text . slice ( 0 , cursor ) + ch + text . slice ( cursor ) ,
@@ -1014,16 +1052,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
10141052 // settled block (a live one is already showing it).
10151053 setSandboxLogOpen ( true )
10161054 } else {
1055+ // → opens the highlighted block one level: a message reveals the
1056+ // tool calls it made (which ↑/↓ then step through one at a time), a
1057+ // call reveals its full output, a clamped body un-clamps.
10171058 const item = visible . find ( ( i ) => i . key === navKey )
1018- if ( item && ( navKey . startsWith ( 'grp:' ) || isCollapsible ( item ) ) ) {
1059+ if ( item && ( hasToolRun ( navKey ) || isCollapsible ( item ) ) ) {
10191060 setOpenedKeys ( ( prev ) => new Set ( prev ) . add ( navKey ) )
10201061 }
10211062 }
10221063 return
10231064 }
10241065 if ( key . leftArrow ) {
1025- // ← closes the thing opened in place; with nothing open it's inert
1026- // (the session nav lives BELOW the composer — ↓ walks to it).
1066+ // ← closes the highlighted block, or — with nothing of its own open —
1067+ // steps back OUT to the block it sits inside, closing that (a
1068+ // revealed tool call returns the highlight to its message). Inert at
1069+ // the top level with nothing open; the session nav lives BELOW the
1070+ // composer, so ↓ is what walks to it.
10271071 if ( navKey === 'sandbox' ) {
10281072 setSandboxLogOpen ( false )
10291073 } else if ( openedKeys . has ( navKey ) ) {
@@ -1032,6 +1076,17 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
10321076 next . delete ( navKey )
10331077 return next
10341078 } )
1079+ } else {
1080+ const parent = parentOf ( navKey )
1081+ if ( parent ) {
1082+ setOpenedKeys ( ( prev ) => {
1083+ const next = new Set ( prev )
1084+ next . delete ( parent )
1085+ return next
1086+ } )
1087+ setNavKey ( parent )
1088+ ensureVisible ( parent )
1089+ }
10351090 }
10361091 return
10371092 }
@@ -1171,7 +1226,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
11711226 key = { row . id }
11721227 row = { row }
11731228 cols = { cols }
1174- selected = { navKey !== null && row . entryKey === navKey && ! row . spacer }
1229+ // The whole block lifts, tool rows included — the highlight is what
1230+ // says "this message and the work it did". A panel's pad rows
1231+ // (spacer + panel) lift with it; canvas spacers between blocks
1232+ // never highlight.
1233+ selected = {
1234+ navKey !== null && navKeyOf ( row ) === navKey && ( ! row . spacer || row . panel === true )
1235+ }
11751236 // Both ticking values are passed as constants to rows that don't
11761237 // use them, so React.memo skips those rows entirely: the
11771238 // once-a-second clock and the pulse repaint the live lines, not
@@ -1338,6 +1399,7 @@ function sandboxRows(o: {
13381399 entryKey : key ,
13391400 spans : [ { text : '✦ Connected to ellipsis.dev' , bold : true } ] ,
13401401 } )
1402+ rows . push ( spacerRow ( key , `${ key } :hdr-sp` ) )
13411403 const ready = ( sandbox ?. done ?? false ) && ! infraActivity
13421404 // A live status word overrides a stale done-headline: on a wake the status
13431405 // flips before the new session_starting record lands, and "Session ready!"
0 commit comments