diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 723ad58..db0d400 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -261,6 +261,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // past the newest line (or esc) returns to the composer. The highlighted // line renders the cyan selection glyph in its gutter. const [navKey, setNavKey] = useState(null) + // Local ✦ lines in the transcript, for things the CLIENT did that the record + // log will never carry: so far, /stop. It belongs in the conversation because + // it is an event in the conversation — the notice bar above the composer is + // for transient status, and scrolls away with nothing to show you asked. + const [chatNotes, setChatNotes] = useState([]) // Lines opened in place with → while highlighted: a grp:* fold expands into // its tool calls, a clamped long body un-clamps. ← closes them again. const [openedKeys, setOpenedKeys] = useState>(new Set()) @@ -307,10 +312,15 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // one-line progress block up top (sandboxProgress), not as transcript rows. // Each turn's closing duration/cost summary is dropped too (see // reshapeTranscript) — the footer carries the session's spend. - const { items } = useMemo( - () => reshapeTranscript(snapshot.records, props.minRenderFeedSeq), - [snapshot.records, props.minRenderFeedSeq], - ) + const { items } = useMemo(() => { + const shaped = reshapeTranscript(snapshot.records, props.minRenderFeedSeq) + // Client-side notes land at the end: they describe what you just did, so + // they belong under everything the server has sent so far. + for (const note of chatNotes) { + shaped.items.push({ key: note.key, kind: 'notice', text: note.text, spaceBefore: true }) + } + return shaped + }, [snapshot.records, props.minRenderFeedSeq, chatNotes]) // Footer spend: the server's ledger total (the session frame's four cost // columns — the billing authority, resent on every cost tick) with the @@ -564,7 +574,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { try { if (text === '/stop') { const s = await api.stopAgentSession(sessionId) - setNotice(`stop requested (${s.status}) — the conversation survives`) + setNotice(null) + setChatNotes((prev) => [ + ...prev, + { + key: `note${prev.length}`, + text: `Stopped the agent (${s.status}). The conversation is saved. Send a message to pick it back up.`, + }, + ]) return } // Show the message as queued, then post it. The POST returns the @@ -899,6 +916,15 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { return rowViewport(allRows.length, viewBudget, anchor) }, [allRows, viewBudget, scrollAnchor]) + // The one row that wears the ▶ marker: the highlighted block's FIRST row with + // a gutter glyph. The whole block tints, but the marker points at a single + // line — a block with nested tool activity has a glyph on the call and on its + // ⎿ result, and marking both reads as two separate selections. + const markerRowId = useMemo(() => { + if (navKey === null) return null + return allRows.find((r) => navKeyOf(r) === navKey && r.gutter)?.id ?? null + }, [allRows, navKey]) + // Move the window by `delta` ROWS. Reaching the last row re-pins it to the // bottom, so streamed content follows again. const scrollByRows = useCallback( @@ -1233,6 +1259,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { selected={ navKey !== null && navKeyOf(row) === navKey && (!row.spacer || row.panel === true) } + marker={row.id === markerRowId} // Both ticking values are passed as constants to rows that don't // use them, so React.memo skips those rows entirely: the // once-a-second clock and the pulse repaint the live lines, not @@ -1967,12 +1994,17 @@ const RowLine = React.memo(function RowLine({ row, cols, selected, + marker, seconds, pulseOn, }: { row: TranscriptRow cols: number selected: boolean + // Whether THIS row carries the ▶ selection marker in its gutter. Every row of + // the highlighted block is `selected` (they all tint), but only one is the + // marker row — see markerRowId. + marker: boolean // The row's ticking duration, resolved here so the once-a-second tick // repaints this line instead of rebuilding the transcript's rows. seconds: number @@ -2015,8 +2047,9 @@ const RowLine = React.memo(function RowLine({ {row.panel && } {row.indent ? : null} - {/* The gutter glyph, or the selection marker in its place — same - 1-char slot, so the text never shifts when the highlight lands. + {/* The gutter glyph, or the selection marker in its place on the one + marker row — same 1-char slot, so text never shifts when the + highlight lands. A live row's mark pulses by DIMMING on the off beat: the glyph itself never changes, so the column holds still and the eye reads a heartbeat rather than a character swapping in and out. */} @@ -2025,7 +2058,7 @@ const RowLine = React.memo(function RowLine({ dimColor={!selected && !row.gutter?.pulse && (row.gutter?.dim ?? false)} wrap="truncate" > - {selected && row.gutter ? SELECTION_GLYPH : (row.gutter?.text ?? '')} + {marker ? SELECTION_GLYPH : (row.gutter?.text ?? '')} diff --git a/src/ui/transcriptRows.ts b/src/ui/transcriptRows.ts index e9e6f09..1e2b013 100644 --- a/src/ui/transcriptRows.ts +++ b/src/ui/transcriptRows.ts @@ -174,8 +174,10 @@ export function itemRows( ): TranscriptRow[] { // Nested tool activity sits ON the parent message's panel: the call and its // result are work done while writing that message, so they live inside the - // same lifted, padded block rather than on the canvas beside it. - const panel = isMessage(item) || opts.nested === true + // same lifted, padded block rather than on the canvas beside it. A ✦ notice + // ("Session asleep", "Stopped the agent") is an event in the conversation, so + // it takes a panel of its own rather than sitting bare on the canvas. + const panel = isMessage(item) || opts.nested === true || item.kind === 'notice' const indent = opts.indent ?? 0 const width = contentWidth(cols, { panel, indent }) const shown = withRenderedMarkdown(item, width) @@ -325,10 +327,12 @@ export function isToolActivity(item: TranscriptItem): boolean { } // A live status line — "Generating…", "Running Bash(pytest…)…" — with its -// ticking readout in the right-hand metadata column. `hug` drops the spacer -// above so the line reads as part of the tool burst it belongs to. The -// duration is a `tick` marker rather than text: it changes every second, and -// baking it in here would re-wrap the transcript once a second. +// ticking readout in the right-hand metadata column. It sits on a panel like +// any other block, so a turn in flight is as legible as the messages around +// it. `hug` drops the spacer above so the line reads as part of the tool burst +// it belongs to. The duration is a `tick` marker rather than text: it changes +// every second, and baking it in here would re-wrap the transcript once a +// second. export function activityRows( key: string, label: string, @@ -345,7 +349,7 @@ export function activityRows( const indent = nested ? NEST_INDENT : 0 // Reserve the widest the readout gets ("(1h 3m 30s · ↓ 12.3k tokens)") so // the label doesn't reflow as the clock ticks. - const width = Math.max(8, contentWidth(cols, { indent, panel: nested }) - visibleWidth(suffix) - 16) + const width = Math.max(8, contentWidth(cols, { indent, panel: true }) - visibleWidth(suffix) - 16) const rows: TranscriptRow[] = hug || nested ? [] : [spacerRow(key, `${key}:sp`)] rows.push({ id: `${key}:r`, @@ -354,7 +358,7 @@ export function activityRows( indent, spans: [{ text: fitLines(label, width)[0] ?? '', dim: true }], right: { text: suffix, dim: true }, - panel: nested, + panel: true, tick, pulse: true, })