Skip to content

Commit 5269425

Browse files
authored
connect: nest tool calls inside the message that made them, and lift each block onto its own panel (#87)
Pressing up in the chat used to land on a tool call, which reads as a turn in the conversation when it is really work the agent did mid-message. Now a tool call belongs to its message's block: up/down walk messages, right on a message reveals its calls as stops of their own, right again opens one call's output, and left walks back out a level. Each lifted block also gets a blank tinted row above and below, shared between a message and the tool run attached under it rather than one pad each.
1 parent aa2d34a commit 5269425

3 files changed

Lines changed: 277 additions & 78 deletions

File tree

src/ui/ConnectApp.tsx

Lines changed: 100 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)