Skip to content
Open
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
4 changes: 4 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1603,6 +1603,10 @@
"sessions.toolActivity.tools_other": "Called {{count}} tools",
"sessions.toolActivity.thinking": "Thinking…",
"sessions.toolActivity.thought": "Thought",
"sessions.turnPlaceholder.user": "You",
"sessions.turnPlaceholder.assistant": "Agent",
"sessions.turnPlaceholder.system": "System",
"sessions.turnPlaceholder.loading": "Loading turn",
"sessions.prCi.failing": "CI failed",
"sessions.prCi.expected": "CI expected",
"sessions.prCi.label": "CI checks",
Expand Down
4 changes: 4 additions & 0 deletions locales/zh_CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1603,6 +1603,10 @@
"sessions.toolActivity.tools_other": "调用了 {{count}} 个工具",
"sessions.toolActivity.thinking": "思考中…",
"sessions.toolActivity.thought": "思考过程",
"sessions.turnPlaceholder.user": "你",
"sessions.turnPlaceholder.assistant": "智能体",
"sessions.turnPlaceholder.system": "系统",
"sessions.turnPlaceholder.loading": "正在加载轮次",
"sessions.prCi.failing": "CI 未通过",
"sessions.prCi.expected": "等待 CI 上报",
"sessions.prCi.label": "CI 检查",
Expand Down
42 changes: 38 additions & 4 deletions packages/components/src/components/ai-gui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,38 @@
| Turns | `assistant-turn-render-blocks.ts` | Activity groups and foldable segments. |
| Outline | `conversation-outline-*` | Round ticks and navigation. |

## History Source

- The renderer reads history ONLY through `ConversationView`
(`lib/conversation-view`): `SessionChatStream` takes `conversationView`
and, on the full-Mirror rollback path, the owner's explicit
`fallbackHistory` array. Nothing under this directory reads
`sessionDoc.history` / `doc.history`; on the view path that getter
materializes the whole transcript. Pinned by
`tests/ai-gui-reads-history-through-view.test.ts`.
- `buildChatStreamItemsFromView` emits one item per turn: a message item where
the turn is hydrated, a `placeholder` item (index row) elsewhere. Both carry
`turnIndex`, and a placeholder uses the ENTRY ID as its Virtua key so
hydration swaps content under a stable key. `TurnPlaceholderRow` renders
role, time, `summary.headText` and activity counts when present; its
`minHeight` is `estimateTurnHeightPx` (summary `textChars`/activity, else
`itemCount`, else a role constant), which Virtua then measures as real.
- The hydration window is `computeHydrationRange` over the visible turn
range the view reports (`onVisibleTurnRangeChange`, from Virtua's offset
math after the initial scroll restore): the viewport plus two spans each
side, snapped to `HYDRATION_RANGE_QUANTUM`; `useTurnRange` retains and
hydrates it and re-renders per frame of view changes. The tail stays
hydrated by the view itself; `lastAssistantMessageId` /
`lastCompletedAssistantMessageId` come from index rows.
- Outline entries come from index rows for placeholders (`summary.headText`
as title/preview, `textChars + thoughtChars` as weight); a round without
a summary shows the untitled fallback until the rail's `onHoverRound`
hydrates it (`onOutlineHoverTurn`). An active in-conversation search
hydrates every turn so matched rows exist — a temporary bridge until the
search index reads through the view.
- `SessionChatStreamHandle.scrollToIndex` takes a TURN index; rows resolve
through `row.turnIndex`, never the item position.

## Stream And Search

- In-conversation search indexes prose only: user/assistant text, thinking, and
Expand All @@ -21,9 +53,10 @@
history indexes must translate to the matching virtual child row.
- Keep Virtua `shift={false}`; stale cumulative heights otherwise overlap rows.
`bufferSize` trades fast-scroll blanks against retaining resizing rows.
- `buildChatStreamItems()` drops empty assistant entries (a `null` render cannot
be measured) and de-duplicates history ids (duplicate Virtua keys desync the
list). See `tests/build-chat-stream-items.test.ts`.
- `buildChatStreamItems()` / `buildChatStreamItemsFromView()` drop empty
assistant entries (a `null` render cannot be measured; placeholders decide
from `itemCount`/`planCount`) and de-duplicate history ids (duplicate Virtua
keys desync the list). See `tests/build-chat-stream-items.test.ts`.
- `leadingContent` is a real first row. Include it in sticky counts and every
scroll target; never overlay or persist it. A `session_create` completion
renders one card per successful target and reads only that target's title.
Expand Down Expand Up @@ -100,7 +133,8 @@ work) and a hover preview.
`OUTLINE_ANCHOR_TOLERANCE_PX` greater than jump tolerance.
- Follow-output suppression is owned by `pendingOutlineJumpRef`, not a render;
React may skip the commit when clicking the already-active round.
- Coverage: `tests/conversation-outline*.test.ts` and `ExtremeConversation`.
- Coverage: `tests/conversation-outline*.test.ts`, `ExtremeConversation` and
the view-backed `ExtremeConversationView` (3,000 turns through a Loro doc).

## Content Contracts

Expand Down
246 changes: 168 additions & 78 deletions packages/components/src/components/ai-gui/build-chat-stream-items.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { MessageContent, SessionHistory, SessionHistoryParsed, SessionId } from '@lody/shared';
import type { ChatStreamItem } from './view';
import type { ConversationView, TurnIndexRow } from '@/lib/conversation-view';
import type { ChatStreamItem, SessionMessageItem, TurnPlaceholderItem } from './view';
import { normalizeMessageContent } from './message-content-guards';

export type BuildChatStreamItemsCache = ReadonlyMap<string, CachedChatStreamMessageItem>;
Expand All @@ -12,7 +13,7 @@ export type BuildChatStreamItemsResult = {
};

type CachedChatStreamMessageItem = {
readonly item: ChatStreamItem & { type: 'message' };
readonly item: SessionMessageItem;
readonly rawEntry: SessionHistory;
readonly rawAcpTurnId: unknown;
readonly rawItems: unknown;
Expand Down Expand Up @@ -42,16 +43,22 @@ const isEmptyAssistantMessage = (message: SessionHistoryParsed): boolean =>
!message.items.length &&
!(message.plan && message.plan.length > 0);

/** The same rule read from an index row, for a turn that is not hydrated. */
const isEmptyAssistantRow = (row: TurnIndexRow): boolean =>
row.role === 'assistant' && (row.itemCount ?? 0) === 0 && (row.planCount ?? 0) === 0;

function canReuseCachedMessageItem(
cached: CachedChatStreamMessageItem | undefined,
entry: SessionHistory,
sessionId: SessionId,
turnIndex: number,
/** Resolved config we would attach to this message (user's own or inherited). */
expectedInputConfig: SessionHistoryParsed['inputConfig']
): cached is CachedChatStreamMessageItem {
return (
cached !== undefined &&
cached.item.sessionId === sessionId &&
cached.item.turnIndex === turnIndex &&
cached.rawEntry === entry &&
cached.rawAcpTurnId === entry.acpTurnId &&
cached.rawItems === entry.items &&
Expand All @@ -73,10 +80,11 @@ function canReuseCachedMessageItem(
function createCachedMessageItem(
entry: SessionHistory,
sessionId: SessionId,
turnIndex: number,
message: SessionHistoryParsed
): CachedChatStreamMessageItem {
return {
item: { type: 'message', sessionId, message },
item: { type: 'message', sessionId, turnIndex, message },
rawEntry: entry,
rawAcpTurnId: entry.acpTurnId,
rawItems: entry.items,
Expand All @@ -88,6 +96,125 @@ function createCachedMessageItem(
};
}

/**
* Placeholders are keyed by index ROW: `ConversationView` hands out the same
* row object until the turn changes, so an unchanged placeholder keeps its
* identity across rebuilds and the memoized row components stay quiet.
*/
const placeholderByRow = new WeakMap<TurnIndexRow, TurnPlaceholderItem>();

const placeholderItem = (
row: TurnIndexRow,
sessionId: SessionId,
turnIndex: number
): TurnPlaceholderItem => {
const cached = placeholderByRow.get(row);
if (cached && cached.sessionId === sessionId && cached.turnIndex === turnIndex) return cached;
const item: TurnPlaceholderItem = { type: 'placeholder', sessionId, turnIndex, row };
placeholderByRow.set(row, item);
return item;
};

type Builder = {
items: ChatStreamItem[];
seenIds: Set<string>;
cache: Map<string, CachedChatStreamMessageItem>;
previousCache: BuildChatStreamItemsCache | undefined;
sessionId: SessionId;
lastAssistantMessageId: string | null;
lastCompletedAssistantMessageId: string | null;
/** Config from the latest user turn — attached to the following assistant
* so the model meta row can show the full turn run-config on demand. */
lastUserInputConfig: SessionHistoryParsed['inputConfig'] | undefined;
};

const createBuilder = (
sessionId: SessionId,
previousCache: BuildChatStreamItemsCache | undefined
): Builder => ({
items: [],
seenIds: new Set(),
cache: new Map(),
previousCache,
sessionId,
lastAssistantMessageId: null,
lastCompletedAssistantMessageId: null,
lastUserInputConfig: undefined,
});

const noteAssistant = (builder: Builder, id: string, finished: boolean | undefined): void => {
builder.lastAssistantMessageId = id;
if (finished === true) builder.lastCompletedAssistantMessageId = id;
};

const pushHydratedEntry = (builder: Builder, entry: SessionHistory, turnIndex: number): void => {
if (entry.role === 'user' && entry.inputConfig) {
builder.lastUserInputConfig = entry.inputConfig;
}

const expectedInputConfig =
entry.role === 'user'
? entry.inputConfig
: entry.role === 'assistant'
? (entry.inputConfig ?? builder.lastUserInputConfig)
: entry.inputConfig;

const cached = builder.previousCache?.get(entry.id);
if (canReuseCachedMessageItem(cached, entry, builder.sessionId, turnIndex, expectedInputConfig)) {
if (builder.seenIds.has(entry.id)) return;
builder.seenIds.add(entry.id);
if (entry.role === 'assistant') noteAssistant(builder, entry.id, entry.finished);
builder.cache.set(entry.id, cached);
builder.items.push(cached.item);
return;
}

const message: SessionHistoryParsed = {
id: entry.id,
items: parseHistoryItemsForRender(entry.items),
role: entry.role,
status: entry.status,
read: entry.read ?? false,
timestamp: entry.timestamp,
endedAt: entry.endedAt,
userId: entry.userId,
acpTurnId: entry.acpTurnId,
modelInfo: entry.modelInfo,
fileDiff: entry.fileDiff,
finished: entry.finished,
plan: entry.plan,
// User turns keep their own config; assistant turns inherit the
// preceding user's so the header can list mode / effort / plan / fast.
inputConfig: expectedInputConfig,
};

if (isEmptyAssistantMessage(message)) return;
if (builder.seenIds.has(message.id)) return;
builder.seenIds.add(message.id);

const cachedMessageItem = createCachedMessageItem(entry, builder.sessionId, turnIndex, message);
builder.cache.set(message.id, cachedMessageItem);
if (message.role === 'assistant') noteAssistant(builder, message.id, message.finished);
builder.items.push(cachedMessageItem.item);
};

const finish = (builder: Builder): BuildChatStreamItemsResult => {
if (!builder.items.length) {
return {
items: [EMPTY_CHAT_STREAM_ITEM],
lastAssistantMessageId: null,
lastCompletedAssistantMessageId: null,
cache: builder.cache,
};
}
return {
items: builder.items,
lastAssistantMessageId: builder.lastAssistantMessageId,
lastCompletedAssistantMessageId: builder.lastCompletedAssistantMessageId,
cache: builder.cache,
};
};

/**
* Build the Virtua VList item list from raw session history.
*
Expand All @@ -106,89 +233,52 @@ function createCachedMessageItem(
*
* `lastAssistantMessageId` is computed over the normalized list so context-window
* usage / quick actions attach to the last *rendered* assistant message.
*
* This is the rollback path (no `ConversationView`); `turnIndex` is the
* position in `history`.
*/
export function buildChatStreamItems(
history: readonly SessionHistory[],
sessionId: SessionId,
previousCache?: BuildChatStreamItemsCache
): BuildChatStreamItemsResult {
const items: ChatStreamItem[] = [];
const seenIds = new Set<string>();
const cache = new Map<string, CachedChatStreamMessageItem>();
let lastAssistantMessageId: string | null = null;
let lastCompletedAssistantMessageId: string | null = null;
/** Config from the latest user turn — attached to the following assistant
* so the model meta row can show the full turn run-config on demand. */
let lastUserInputConfig: SessionHistoryParsed['inputConfig'] | undefined;

for (const entry of history) {
if (entry.role === 'user' && entry.inputConfig) {
lastUserInputConfig = entry.inputConfig;
}
const builder = createBuilder(sessionId, previousCache);
for (let turnIndex = 0; turnIndex < history.length; turnIndex += 1) {
const entry = history[turnIndex];
if (entry) pushHydratedEntry(builder, entry, turnIndex);
}
return finish(builder);
}

const expectedInputConfig =
entry.role === 'user'
? entry.inputConfig
: entry.role === 'assistant'
? (entry.inputConfig ?? lastUserInputConfig)
: entry.inputConfig;

const cached = previousCache?.get(entry.id);
if (canReuseCachedMessageItem(cached, entry, sessionId, expectedInputConfig)) {
if (seenIds.has(entry.id)) continue;
seenIds.add(entry.id);
if (entry.role === 'assistant') {
lastAssistantMessageId = entry.id;
if (entry.finished === true) {
lastCompletedAssistantMessageId = entry.id;
}
}
cache.set(entry.id, cached);
items.push(cached.item);
/**
* The same list read through a `ConversationView`: one item per turn, a
* full message item where the turn is hydrated and a placeholder built from
* the index row everywhere else. Both carry the turn's index and share the
* entry id as their Virtua key, so hydration swaps content under a stable
* key. The normalizations above apply to placeholders from their index row
* (`itemCount` / `planCount`), and the last-assistant ids come from index
* rows too, so they never wait on hydration.
*
* O(turnCount) in cheap work per rebuild; the only `toJSON` cost is what the
* caller already hydrated.
*/
export function buildChatStreamItemsFromView(
view: ConversationView,
sessionId: SessionId,
previousCache?: BuildChatStreamItemsCache
): BuildChatStreamItemsResult {
const builder = createBuilder(sessionId, previousCache);
for (let turnIndex = 0; turnIndex < view.turnCount; turnIndex += 1) {
const entry = view.turn(turnIndex);
Comment on lines +271 to +272

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 Badge Bound stream rebuilding to the active window

Whenever a long conversation is streaming, each ConversationView version re-runs this loop across every turn and emits placeholders for the entire unhydrated history; SessionChatStreamView subsequently maps that complete list into React children. The per-update work therefore remains O(total turns), not O(window), so a 3,000-turn session still creates and reconciles thousands of entries on each streamed update and can retain the UI stalls this change is intended to eliminate. Build only the retained/visible window and represent the off-window geometry without rebuilding every turn.

Useful? React with 👍 / 👎.

if (entry) {
pushHydratedEntry(builder, entry, turnIndex);
continue;
}

const message: SessionHistoryParsed = {
id: entry.id,
items: parseHistoryItemsForRender(entry.items),
role: entry.role,
status: entry.status,
read: entry.read ?? false,
timestamp: entry.timestamp,
endedAt: entry.endedAt,
userId: entry.userId,
acpTurnId: entry.acpTurnId,
modelInfo: entry.modelInfo,
fileDiff: entry.fileDiff,
finished: entry.finished,
plan: entry.plan,
// User turns keep their own config; assistant turns inherit the
// preceding user's so the header can list mode / effort / plan / fast.
inputConfig: expectedInputConfig,
};

if (isEmptyAssistantMessage(message)) continue;
if (seenIds.has(message.id)) continue;
seenIds.add(message.id);

const cachedMessageItem = createCachedMessageItem(entry, sessionId, message);
cache.set(message.id, cachedMessageItem);
if (message.role === 'assistant') {
lastAssistantMessageId = message.id;
if (message.finished === true) {
lastCompletedAssistantMessageId = message.id;
}
}
items.push(cachedMessageItem.item);
}

if (!items.length) {
return {
items: [EMPTY_CHAT_STREAM_ITEM],
lastAssistantMessageId: null,
lastCompletedAssistantMessageId: null,
cache,
};
const row = view.index(turnIndex);
if (!row?.id || isEmptyAssistantRow(row) || builder.seenIds.has(row.id)) continue;
builder.seenIds.add(row.id);
if (row.role === 'assistant') noteAssistant(builder, row.id, row.finished);
builder.items.push(placeholderItem(row, sessionId, turnIndex));
}
return { items, lastAssistantMessageId, lastCompletedAssistantMessageId, cache };
return finish(builder);
}
Loading
Loading