Skip to content

Commit e321a85

Browse files
authored
connect: put live and notice lines on panels, mark one row per block, log /stop in the chat (#88)
The live activity line ("Working…", "Generating…") and ✦ notices sat bare on the canvas while every message around them was a lifted, padded block, so a turn in flight read as less substantial than the conversation it belonged to. Both take a panel now. The ▶ selection marker replaced the gutter glyph on every row of the highlighted block, so a message with nested tool activity showed it twice (on the ● call and on its ⎿ result) and read as two selections. It lands on the block's first glyph row only. /stop reported through the notice bar above the composer, which is for transient status and scrolls away leaving nothing to show you asked. It now appends a ✦ line to the transcript, where the rest of the conversation's events live.
1 parent 5269425 commit e321a85

2 files changed

Lines changed: 53 additions & 16 deletions

File tree

src/ui/ConnectApp.tsx

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
261261
// past the newest line (or esc) returns to the composer. The highlighted
262262
// line renders the cyan selection glyph in its gutter.
263263
const [navKey, setNavKey] = useState<string | null>(null)
264+
// Local ✦ lines in the transcript, for things the CLIENT did that the record
265+
// log will never carry: so far, /stop. It belongs in the conversation because
266+
// it is an event in the conversation — the notice bar above the composer is
267+
// for transient status, and scrolls away with nothing to show you asked.
268+
const [chatNotes, setChatNotes] = useState<readonly { key: string; text: string }[]>([])
264269
// Lines opened in place with → while highlighted: a grp:* fold expands into
265270
// its tool calls, a clamped long body un-clamps. ← closes them again.
266271
const [openedKeys, setOpenedKeys] = useState<ReadonlySet<string>>(new Set())
@@ -307,10 +312,15 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
307312
// one-line progress block up top (sandboxProgress), not as transcript rows.
308313
// Each turn's closing duration/cost summary is dropped too (see
309314
// reshapeTranscript) — the footer carries the session's spend.
310-
const { items } = useMemo(
311-
() => reshapeTranscript(snapshot.records, props.minRenderFeedSeq),
312-
[snapshot.records, props.minRenderFeedSeq],
313-
)
315+
const { items } = useMemo(() => {
316+
const shaped = reshapeTranscript(snapshot.records, props.minRenderFeedSeq)
317+
// Client-side notes land at the end: they describe what you just did, so
318+
// they belong under everything the server has sent so far.
319+
for (const note of chatNotes) {
320+
shaped.items.push({ key: note.key, kind: 'notice', text: note.text, spaceBefore: true })
321+
}
322+
return shaped
323+
}, [snapshot.records, props.minRenderFeedSeq, chatNotes])
314324

315325
// Footer spend: the server's ledger total (the session frame's four cost
316326
// columns — the billing authority, resent on every cost tick) with the
@@ -564,7 +574,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
564574
try {
565575
if (text === '/stop') {
566576
const s = await api.stopAgentSession(sessionId)
567-
setNotice(`stop requested (${s.status}) — the conversation survives`)
577+
setNotice(null)
578+
setChatNotes((prev) => [
579+
...prev,
580+
{
581+
key: `note${prev.length}`,
582+
text: `Stopped the agent (${s.status}). The conversation is saved. Send a message to pick it back up.`,
583+
},
584+
])
568585
return
569586
}
570587
// Show the message as queued, then post it. The POST returns the
@@ -899,6 +916,15 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
899916
return rowViewport(allRows.length, viewBudget, anchor)
900917
}, [allRows, viewBudget, scrollAnchor])
901918

919+
// The one row that wears the ▶ marker: the highlighted block's FIRST row with
920+
// a gutter glyph. The whole block tints, but the marker points at a single
921+
// line — a block with nested tool activity has a glyph on the call and on its
922+
// ⎿ result, and marking both reads as two separate selections.
923+
const markerRowId = useMemo(() => {
924+
if (navKey === null) return null
925+
return allRows.find((r) => navKeyOf(r) === navKey && r.gutter)?.id ?? null
926+
}, [allRows, navKey])
927+
902928
// Move the window by `delta` ROWS. Reaching the last row re-pins it to the
903929
// bottom, so streamed content follows again.
904930
const scrollByRows = useCallback(
@@ -1233,6 +1259,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
12331259
selected={
12341260
navKey !== null && navKeyOf(row) === navKey && (!row.spacer || row.panel === true)
12351261
}
1262+
marker={row.id === markerRowId}
12361263
// Both ticking values are passed as constants to rows that don't
12371264
// use them, so React.memo skips those rows entirely: the
12381265
// once-a-second clock and the pulse repaint the live lines, not
@@ -1967,12 +1994,17 @@ const RowLine = React.memo(function RowLine({
19671994
row,
19681995
cols,
19691996
selected,
1997+
marker,
19701998
seconds,
19711999
pulseOn,
19722000
}: {
19732001
row: TranscriptRow
19742002
cols: number
19752003
selected: boolean
2004+
// Whether THIS row carries the ▶ selection marker in its gutter. Every row of
2005+
// the highlighted block is `selected` (they all tint), but only one is the
2006+
// marker row — see markerRowId.
2007+
marker: boolean
19762008
// The row's ticking duration, resolved here so the once-a-second tick
19772009
// repaints this line instead of rebuilding the transcript's rows.
19782010
seconds: number
@@ -2015,8 +2047,9 @@ const RowLine = React.memo(function RowLine({
20152047
{row.panel && <Box width={MESSAGE_PAD} flexShrink={0} />}
20162048
{row.indent ? <Box width={row.indent} flexShrink={0} /> : null}
20172049
<Box width={GUTTER_COLS} flexShrink={0}>
2018-
{/* The gutter glyph, or the selection marker in its place — same
2019-
1-char slot, so the text never shifts when the highlight lands.
2050+
{/* The gutter glyph, or the selection marker in its place on the one
2051+
marker row — same 1-char slot, so text never shifts when the
2052+
highlight lands.
20202053
A live row's mark pulses by DIMMING on the off beat: the glyph
20212054
itself never changes, so the column holds still and the eye reads
20222055
a heartbeat rather than a character swapping in and out. */}
@@ -2025,7 +2058,7 @@ const RowLine = React.memo(function RowLine({
20252058
dimColor={!selected && !row.gutter?.pulse && (row.gutter?.dim ?? false)}
20262059
wrap="truncate"
20272060
>
2028-
{selected && row.gutter ? SELECTION_GLYPH : (row.gutter?.text ?? '')}
2061+
{marker ? SELECTION_GLYPH : (row.gutter?.text ?? '')}
20292062
</Text>
20302063
</Box>
20312064
<Box flexGrow={1} flexShrink={1} overflow="hidden">

src/ui/transcriptRows.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,10 @@ export function itemRows(
174174
): TranscriptRow[] {
175175
// Nested tool activity sits ON the parent message's panel: the call and its
176176
// result are work done while writing that message, so they live inside the
177-
// same lifted, padded block rather than on the canvas beside it.
178-
const panel = isMessage(item) || opts.nested === true
177+
// same lifted, padded block rather than on the canvas beside it. A ✦ notice
178+
// ("Session asleep", "Stopped the agent") is an event in the conversation, so
179+
// it takes a panel of its own rather than sitting bare on the canvas.
180+
const panel = isMessage(item) || opts.nested === true || item.kind === 'notice'
179181
const indent = opts.indent ?? 0
180182
const width = contentWidth(cols, { panel, indent })
181183
const shown = withRenderedMarkdown(item, width)
@@ -325,10 +327,12 @@ export function isToolActivity(item: TranscriptItem): boolean {
325327
}
326328

327329
// A live status line — "Generating…", "Running Bash(pytest…)…" — with its
328-
// ticking readout in the right-hand metadata column. `hug` drops the spacer
329-
// above so the line reads as part of the tool burst it belongs to. The
330-
// duration is a `tick` marker rather than text: it changes every second, and
331-
// baking it in here would re-wrap the transcript once a second.
330+
// ticking readout in the right-hand metadata column. It sits on a panel like
331+
// any other block, so a turn in flight is as legible as the messages around
332+
// it. `hug` drops the spacer above so the line reads as part of the tool burst
333+
// it belongs to. The duration is a `tick` marker rather than text: it changes
334+
// every second, and baking it in here would re-wrap the transcript once a
335+
// second.
332336
export function activityRows(
333337
key: string,
334338
label: string,
@@ -345,7 +349,7 @@ export function activityRows(
345349
const indent = nested ? NEST_INDENT : 0
346350
// Reserve the widest the readout gets ("(1h 3m 30s · ↓ 12.3k tokens)") so
347351
// the label doesn't reflow as the clock ticks.
348-
const width = Math.max(8, contentWidth(cols, { indent, panel: nested }) - visibleWidth(suffix) - 16)
352+
const width = Math.max(8, contentWidth(cols, { indent, panel: true }) - visibleWidth(suffix) - 16)
349353
const rows: TranscriptRow[] = hug || nested ? [] : [spacerRow(key, `${key}:sp`)]
350354
rows.push({
351355
id: `${key}:r`,
@@ -354,7 +358,7 @@ export function activityRows(
354358
indent,
355359
spans: [{ text: fitLines(label, width)[0] ?? '', dim: true }],
356360
right: { text: suffix, dim: true },
357-
panel: nested,
361+
panel: true,
358362
tick,
359363
pulse: true,
360364
})

0 commit comments

Comments
 (0)