Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@

- Fixed in-process child sessions losing their admission-issued nesting depth and delegation limit. `SubagentChildPolicy` now carries both the admitted `depth` and the effective `maxSubagentDepth`, so the subagent executor can enforce the configured and inherited limits without relying on the removed process-environment bridge ([#2220](https://github.com/bastani-inc/atomic/pull/2220), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)).
- Release archives no longer ship packages built for a different architecture. Every archive is produced on one Linux x64 runner, and that runner's `node_modules` was copied verbatim into all of them, so 0.9.12's `atomic-linux-arm64.tar.gz` and `atomic-darwin-arm64.tar.gz` both contained `@esbuild/linux-x64`. The build now prunes the `@embedded-postgres` leaves that cannot run on the archive being staged and then fails the build if any staged package's own `os`/`cpu`/`libc` declaration is incompatible with that archive ([#2208](https://github.com/bastani-inc/atomic/issues/2208)).
- Reduced redundant repaints of a Ctrl+O-expanded live subagent widget, which were scrolling the chat window to the bottom and clearing terminal scrollback during a run. The widget was republished on every child session event, including assistant streaming deltas, so it repainted continuously instead of when its contents changed; each repaint of a row above the terminal fold costs a pi-tui full redraw that clears scrollback. Progress is now published only at milestones that change what the widget shows. A genuine above-fold milestone change can still require that redraw when the editor/footer region plus the live widget exceed the terminal height — that path is upstream (earendil-works/pi#4785, #7194) and is not fixed here ([#2213](https://github.com/bastani-inc/atomic/pull/2213), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)).
- Fixed the scroll anchor in `ScrollableComponentViewport` drifting when its content changes height. The offset is stored as a distance from the bottom and was compensated only when content grew, so a component losing rows — such as a live subagent widget dropping its current-tool row at the end of each tool call — slid the rows a scrolled-up viewer was reading. The offset is now re-derived from the anchored row, which also keeps the anchor still when rows change *above* it; a plain height-delta adjustment moved the viewer in that case. Because the chat host renders the entire transcript as a single component, the anchored row is normally *inside* one component, where per-component row counts cannot say whether rows changed above or below it; a windowed component can now report a row map (`rowSegments`) identifying each run of rows by the entry that produced it, so a compaction that deletes entries above the viewer — renumbering every positional cache key — still leaves the viewer on the entry they were reading. If the anchored entry itself was compacted away, the viewer lands on the nearest surviving entry above it. A viewer already at the bottom still sticks to the bottom. This affects the workflow stage chat view and any extension surface built on `ChatSessionHost`; it is not the path the normal interactive chat uses, so it does not by itself preserve main-chat terminal scrollback ([#2214](https://github.com/bastani-inc/atomic/pull/2214), regression source [#2205](https://github.com/bastani-inc/atomic/pull/2205)).

### Removed

- Removed the `tsx` dependency from the shipped runtime. The Intercom broker's Node path now runs on Atomic's bundled `jiti` loader, which is dependency-free pure JavaScript, instead of resolving `tsx` first. `tsx` was the only thing pulling `esbuild`, so this removes `esbuild`, its 26 platform packages, and `fsevents` from `npm-shrinkwrap.json` and from every release archive — about 11.5 MB across 72 files. The `npx --no-install tsx` config pair remains a recognized compatibility sentinel, and explicit custom broker commands are unaffected ([#2208](https://github.com/bastani-inc/atomic/issues/2208)).
- Behavior change worth noting for extension authors: TypeScript reached through the Node broker path is now transpiled by jiti (Babel-based) rather than esbuild. Both erase types without type-checking, but they are not identical — jiti defaults JSX support and `tsconfig` path aliases to off, and enables legacy decorators. A third-party extension that resolved `tsx` from the bundled `node_modules` was relying on an undocumented dependency and will no longer find it ([#2208](https://github.com/bastani-inc/atomic/issues/2208)).
- Reduced redundant repaints of a Ctrl+O-expanded live subagent widget, which were scrolling the chat window to the bottom and clearing terminal scrollback during a run. The widget was republished on every child session event, including assistant streaming deltas, so it repainted continuously instead of when its contents changed; each repaint of a row above the terminal fold costs a pi-tui full redraw that clears scrollback. Progress is now published only at milestones that change what the widget shows. A genuine above-fold milestone change can still require that redraw when the editor/footer region plus the live widget exceed the terminal height — that path is upstream (earendil-works/pi#4785, #7194) and is not fixed here ([#2213](https://github.com/bastani-inc/atomic/pull/2213), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)).

## [0.9.13-alpha.1] - 2026-08-05

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,49 @@ interface CachedChatTranscriptBlock<TEntry extends ChatTranscriptEntryLike> {

type DisposableComponent = Component & { dispose?: () => void };

/**
* A run of rows inside a windowed component, tagged with the identity of the
* thing that produced them.
*
* `id` is compared with `===` only, and is never rendered. Entry *objects* are
* the natural id for a transcript: cache keys carry the entry's index and so
* change for every survivor of a splice, while the entry objects themselves are
* moved, not rebuilt.
*/
export interface RowWindowSegment {
readonly id: unknown;
readonly rows: number;
}

interface RowWindowComponent extends Component {
readonly supportsRowWindow: true;
rowCount(width: number): number;
renderRows(width: number, startRow: number, endRow: number): string[];
/**
* Optional row map, letting the viewport keep a scroll anchor that sits
* *inside* this component when its interior changes height.
*/
rowSegments?(width: number): readonly RowWindowSegment[];
}

interface WindowedComponentRows {
readonly kind: "windowed";
readonly component: RowWindowComponent;
readonly rowCount: number;
readonly segments: readonly RowWindowSegment[] | undefined;
}

interface StaticComponentRows {
readonly kind: "static";
readonly lines: readonly string[];
readonly rowCount: number;
/**
* Present when a static component can still identify its own rows. A
* transcript built without a cache key renders every row each frame rather
* than windowing, but it knows just as well which entry produced which rows,
* and the anchor needs that whenever it is the component spanning the anchor.
*/
readonly segments?: readonly RowWindowSegment[] | undefined;
}

type ComponentRows = WindowedComponentRows | StaticComponentRows;
Expand Down Expand Up @@ -81,6 +108,10 @@ export class ChatTranscriptComponent<TEntry extends ChatTranscriptEntryLike> imp

private readonly cacheKey: ChatTranscriptCacheKey<TEntry> | undefined;
private blockCache: Array<CachedChatTranscriptBlock<TEntry> | undefined> = [];
/** Per-entry heights recorded by the most recent `renderAllRows`. */
private staticSegments: readonly RowWindowSegment[] = [];
/** Width those heights were measured at; they mean nothing at another width. */
private staticSegmentsWidth: number | undefined;

constructor(
entries: readonly TEntry[],
Expand Down Expand Up @@ -108,6 +139,27 @@ export class ChatTranscriptComponent<TEntry extends ChatTranscriptEntryLike> imp
return count;
}

/**
* One segment per cached entry block, identified by the entry object itself.
*/
rowSegments(width: number): readonly RowWindowSegment[] {
if (!this.supportsRowWindow) {
// Recorded by the last renderAllRows. The viewport measures a static
// component by rendering it and only then reads its segments, so this
// is populated for the frame being measured. A width mismatch means the
// heights describe a different layout, and reporting them would move the
// anchor by a stale delta; the caller falls back to whole-component
// accounting instead.
return this.staticSegmentsWidth === width ? this.staticSegments : [];
}
this.ensureBlockCache(width);
const segments: RowWindowSegment[] = [];
for (const block of this.blockCache) {
if (block !== undefined) segments.push({ id: block.entry, rows: block.lines.length });
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Compaction replaces the logical anchor identity

rowSegments identifies each rendered segment by its ChatMessageEntry wrapper object. Production compaction calls replaceMessages, which rebuilds those wrappers even for surviving logical messages. The next row map therefore has no matching segment for a reader anchored on a surviving message, leaving the old absolute row in place after older messages are removed. A reader positioned on entry-10 can consequently be moved ahead to entry-16 instead of remaining on entry-10. Use a stable logical message identity for row segments, or preserve wrappers for surviving messages.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/coding-agent/src/modes/interactive/components/chat-transcript.ts
Line: 158

Comment:
**Compaction replaces the logical anchor identity**

`rowSegments` identifies each rendered segment by its `ChatMessageEntry` wrapper object. Production compaction calls `replaceMessages`, which rebuilds those wrappers even for surviving logical messages. The next row map therefore has no matching segment for a reader anchored on a surviving message, leaving the old absolute row in place after older messages are removed. A reader positioned on `entry-10` can consequently be moved ahead to `entry-16` instead of remaining on `entry-10`. Use a stable logical message identity for row segments, or preserve wrappers for surviving messages.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}
return segments;
}

renderRows(width: number, startRow: number, endRow: number): string[] {
const start = Math.max(0, Math.floor(startRow));
const end = Math.max(start, Math.floor(endRow));
Expand Down Expand Up @@ -165,12 +217,27 @@ export class ChatTranscriptComponent<TEntry extends ChatTranscriptEntryLike> imp
}
}

/**
* Render every entry, recording each one's height as it goes.
*
* The heights are what `rowSegments` reports on this path. They are a
* by-product of work this method already does, so identifying rows costs
* nothing extra and, unlike a cache key, cannot miss an entry mutated in
* place -- which is the behaviour a transcript without a cache key exists to
* provide.
*/
private renderAllRows(width: number): string[] {
const lines: string[] = [];
const segments: RowWindowSegment[] = [];
for (let index = 0; index < this.entries.length; index += 1) {
const entry = this.entries[index];
if (entry !== undefined) lines.push(...this.renderEntryBlock(this.renderEntry(entry), entry, index, width));
if (entry === undefined) continue;
const block = this.renderEntryBlock(this.renderEntry(entry), entry, index, width);
segments.push({ id: entry, rows: block.length });
lines.push(...block);
}
this.staticSegments = segments;
this.staticSegmentsWidth = width;
return lines;
}

Expand Down Expand Up @@ -200,6 +267,8 @@ export class ScrollableComponentViewport implements Component {
private visibleRows = 1;
private scrollFromBottom = 0;
private lastLineCount = 0;
private lastComponentSegments: readonly (readonly RowWindowSegment[] | undefined)[] = [];
private lastComponentRowCounts: readonly number[] = [];
private lastWidth = 0;
private maxScroll = 0;

Expand Down Expand Up @@ -264,12 +333,46 @@ export class ScrollableComponentViewport implements Component {

render(width: number): string[] {
const componentRows = this.measureComponentRows(width);
const lineCount = componentRows.reduce((sum, rows) => sum + rows.rowCount, 0);
const rowCounts = componentRows.map((rows) => rows.rowCount);
const lineCount = rowCounts.reduce((sum, count) => sum + count, 0);
const maxScroll = Math.max(0, lineCount - this.visibleRows);
if (this.scrollFromBottom > 0 && this.lastWidth === width && lineCount > this.lastLineCount) {
this.scrollFromBottom += lineCount - this.lastLineCount;
// The offset is a distance from the bottom, so the first visible row is
// `maxScroll - scrollFromBottom`. Rows appearing or disappearing move the
// content a scrolled-up viewer is reading unless the offset moves with
// them -- a live subagent widget does exactly that every time it gains or
// drops its current-tool row.
//
// Which way the offset must move depends on where the rows changed, so a
// plain `scrollFromBottom += lineCount - lastLineCount` is wrong half the
// time. Rows changing *below* the anchor need the offset adjusted by that
// delta; rows changing *above* it need the offset left alone, because the
// bottom-relative distance to the anchored content did not change. Anchor
// on the row the viewer is actually reading and re-derive the offset from
// it. A viewer already at the bottom keeps scrollFromBottom === 0 and is
// skipped entirely, so sticky-bottom following is untouched.
//
// Per-component row counts alone cannot place a change that happens
// inside the component holding the anchor -- and in the chat host the
// whole transcript is one component, so that is the normal case. A
// component that can hand over a row map turns the "where" question into
// "where did the anchored segment go". Both kinds may supply one: a
// transcript built without a cache key is not windowed, but it is still
// one component spanning the anchor and still knows its own rows.
const segments = componentRows.map((rows) => rows.segments);
if (this.scrollFromBottom > 0 && this.lastWidth === width) {
const previousMaxScroll = Math.max(0, this.lastLineCount - this.visibleRows);
const anchorRow = Math.max(0, previousMaxScroll - this.scrollFromBottom);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
const shift = rowsShiftedAboveAnchor(
{ rowCounts: this.lastComponentRowCounts, segments: this.lastComponentSegments },
{ rowCounts, segments },
anchorRow,
);
const nextAnchorRow = Math.max(0, Math.min(maxScroll, anchorRow + shift));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
this.scrollFromBottom = maxScroll - nextAnchorRow;
}
this.lastLineCount = lineCount;
this.lastComponentRowCounts = rowCounts;
this.lastComponentSegments = segments;
this.lastWidth = width;
this.maxScroll = maxScroll;
this.clampScroll();
Expand All @@ -291,13 +394,19 @@ export class ScrollableComponentViewport implements Component {
kind: "windowed",
component,
rowCount: component.rowCount(width),
segments: component.rowSegments?.(width),
};
}
const lines = component.render(width);
// Read segments only after rendering: a static transcript records its
// per-entry heights as a by-product of that render, so asking first
// would return the previous frame's layout.
const segments = segmentReporter(component)?.rowSegments(width);
return {
kind: "static",
lines,
rowCount: lines.length,
segments: segments !== undefined && segments.length > 0 ? segments : undefined,
};
});
}
Expand Down Expand Up @@ -337,6 +446,89 @@ export class ScrollableComponentViewport implements Component {
}
}

/** One rendered frame's row layout, as the viewport measured it. */
interface ComponentRowLayout {
readonly rowCounts: readonly number[];
readonly segments: readonly (readonly RowWindowSegment[] | undefined)[];
}

/**
* Rows gained or lost above `anchorRow`, measured in the previous frame's rows.
*
* The anchored row moves down by exactly this many rows, so adding it to the
* anchor keeps the same content under the viewer.
*
* Components entirely above the anchor contribute their whole height delta.
* The component that *spans* the anchor contributes only what changed above the
* anchored row, which needs its row map (`rowSegments`); without one it
* contributes nothing, which is the old behaviour and is right for the
* append-and-mutate-at-the-tail widgets that have no interior.
*
* Row counts are compared positionally over the shared prefix, which is what the
* chat stacks this viewport drives actually do — they append and mutate at the
* tail. A component inserted or removed *ahead* of the anchor would be
* misattributed; the anchor then lands one component off rather than drifting on
* every frame, and the next user scroll re-establishes it.
*/
function rowsShiftedAboveAnchor(previous: ComponentRowLayout, next: ComponentRowLayout, anchorRow: number): number {
let cursor = 0;
let shift = 0;
const shared = Math.min(previous.rowCounts.length, next.rowCounts.length);
for (let index = 0; index < shared; index += 1) {
const previousRows = previous.rowCounts[index] ?? 0;
const componentEnd = cursor + previousRows;
if (componentEnd > anchorRow) {
return shift + rowsShiftedInsideComponent(previous.segments[index], next.segments[index], anchorRow - cursor);
}
shift += (next.rowCounts[index] ?? 0) - previousRows;
cursor = componentEnd;
}
return shift;
}

/**
* Rows gained or lost above `anchorRow` *within* one windowed component.
*
* Rather than diffing heights, this finds the segment the viewer is parked on
* and reports how far that same segment moved. Segments are matched by `===` on
* their id, so a transcript splice that renumbers every cache key still lines
* up: the entry objects survive it. If the anchored segment itself is gone
* (the viewer was reading rows that were compacted away) the nearest surviving
* neighbour above it — then below it — stands in, so the viewer lands on the
* closest content that still exists instead of drifting by the whole delta.
*/
function rowsShiftedInsideComponent(
previousSegments: readonly RowWindowSegment[] | undefined,
nextSegments: readonly RowWindowSegment[] | undefined,
anchorRow: number,
): number {
if (previousSegments === undefined || nextSegments === undefined) return 0;
const nextStarts = new Map<unknown, number>();
let cursor = 0;
for (const segment of nextSegments) {
if (!nextStarts.has(segment.id)) nextStarts.set(segment.id, cursor);
cursor += segment.rows;
}
const previousStarts: number[] = [];
let anchorIndex = -1;
cursor = 0;
for (let index = 0; index < previousSegments.length; index += 1) {
previousStarts.push(cursor);
cursor += previousSegments[index]?.rows ?? 0;
if (anchorIndex < 0 && cursor > anchorRow) anchorIndex = index;
}
if (anchorIndex < 0) return 0;
for (let index = anchorIndex; index >= 0; index -= 1) {
const nextStart = nextStarts.get(previousSegments[index]?.id);
if (nextStart !== undefined) return nextStart - (previousStarts[index] ?? 0);
}
for (let index = anchorIndex + 1; index < previousSegments.length; index += 1) {
const nextStart = nextStarts.get(previousSegments[index]?.id);
if (nextStart !== undefined) return nextStart - (previousStarts[index] ?? 0);
}
return 0;
}

function isRowWindowComponent(component: Component): component is RowWindowComponent {
const candidate = component as Partial<RowWindowComponent>;
return (
Expand All @@ -346,6 +538,24 @@ function isRowWindowComponent(component: Component): component is RowWindowCompo
);
}

/**
* A component that can identify its own rows without being windowed.
*
* `ScrollableChatTranscriptComponent` builds its transcript without a cache key
* — deliberately, because that is what lets it reflect entries mutated in place
* — so it is not a `RowWindowComponent` and renders every row each frame. It
* still knows which entry produced which rows, and it is still the component
* spanning the anchor, so the anchor needs to ask.
*/
function segmentReporter(
component: Component,
): { rowSegments(width: number): readonly RowWindowSegment[] } | undefined {
const candidate = component as Partial<RowWindowComponent>;
return typeof candidate.rowSegments === "function"
? (candidate as { rowSegments(width: number): readonly RowWindowSegment[] })
: undefined;
}

export class ScrollableChatTranscriptComponent<TEntry extends ChatTranscriptEntryLike> implements Component {
private readonly viewport = new ScrollableComponentViewport();
private readonly transcript: ChatTranscriptComponent<TEntry>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export {
type ChatTranscriptEntryLike,
type ChatTranscriptRenderer,
type ChatTranscriptRole,
type RowWindowSegment,
ScrollableChatTranscriptComponent,
ScrollableComponentViewport,
} from "./chat-transcript.ts";
Expand Down
Loading