Buffer message streams and preserve scroll anchors

This commit is contained in:
murashit 2026-06-12 23:25:28 +09:00
parent 5b66d43c1e
commit 2e41795220
17 changed files with 913 additions and 189 deletions

View file

@ -12,15 +12,7 @@ import type { ThreadConversationSummary } from "../../../../domain/threads/trans
import { jsonPreview } from "../../../../utils";
import { activeTurnId, pendingTurnStart as pendingTurnStartForState, type ChatAction, type ChatState } from "../../state/reducer";
import { createAutoReviewResultItem, createReviewResultItem } from "../../display/items/review-result";
import {
appendAssistantDelta,
appendItemOutput,
appendItemText,
appendPlanDelta,
appendToolOutput,
completeReasoningItems,
upsertDisplayItem,
} from "../../state/message-stream-updates";
import { completeReasoningItems, upsertDisplayItem } from "../../state/message-stream-updates";
import {
displayItemFromTurnItem,
displayItemsFromTurns,
@ -33,6 +25,7 @@ import type { DisplayItem, DisplayKind, MessageDisplayItem } from "../../display
import { goalChangeItem } from "../../display/items/goal";
import { hookRunDisplayItem } from "../../display/items/hook-run";
import { attachHookRunsToTurn } from "../../state/message-stream-updates";
import { messageStreamDisplayItems } from "../../state/message-stream";
import {
routeServerNotification,
type DiagnosticStatusNotificationMethod,
@ -121,21 +114,23 @@ const USER_VISIBLE_NOTICE_PLANNERS = {
} satisfies ServerNotificationLocalPlannerMap<UserVisibleNoticeNotificationMethod>;
const STREAM_UPDATE_PLANNERS = {
"item/agentMessage/delta": (state, notification) => {
const { params } = notification;
const displayItems = appendAssistantDelta(
completeReasoningItems(state.messageStream.displayItems, params.turnId),
params.itemId,
params.turnId,
params.delta,
);
return actionPlan({ type: "message-stream/items-replaced", items: displayItems });
},
"item/plan/delta": (state, notification) => {
"item/agentMessage/delta": (_state, notification) => {
const { params } = notification;
return actionPlan({
type: "message-stream/items-replaced",
items: appendPlanDelta(state.messageStream.displayItems, params.itemId, params.turnId, params.delta),
type: "message-stream/assistant-delta-appended",
itemId: params.itemId,
turnId: params.turnId,
delta: params.delta,
completeReasoning: true,
});
},
"item/plan/delta": (_state, notification) => {
const { params } = notification;
return actionPlan({
type: "message-stream/plan-delta-appended",
itemId: params.itemId,
turnId: params.turnId,
delta: params.delta,
});
},
"turn/plan/updated": (_state, notification) =>
@ -151,53 +146,47 @@ const STREAM_UPDATE_PLANNERS = {
appendToolTextPlan(state, notification.params.itemId, notification.params.turnId, "reasoning", "", "reasoning"),
"item/started": (_state, notification) => startedItemPlan(notification.params.item, notification.params.turnId),
"item/completed": (state, notification) => completedItemPlan(state, notification.params.item, notification.params.turnId),
"item/commandExecution/outputDelta": (state, notification) =>
"item/commandExecution/outputDelta": (_state, notification) =>
actionPlan({
type: "message-stream/items-replaced",
items: appendItemOutput(
state.messageStream.displayItems,
notification.params.itemId,
notification.params.turnId,
notification.params.delta,
"command",
"Command running",
),
type: "message-stream/item-output-appended",
itemId: notification.params.itemId,
turnId: notification.params.turnId,
delta: notification.params.delta,
kind: "command",
fallbackText: "Command running",
}),
"item/fileChange/patchUpdated": (_state, notification) =>
fileChangePlan(notification.params.itemId, notification.params.turnId, notification.params.changes, "inProgress"),
"item/fileChange/outputDelta": (state, notification) =>
"item/fileChange/outputDelta": (_state, notification) =>
actionPlan({
type: "message-stream/items-replaced",
items: appendItemOutput(
state.messageStream.displayItems,
notification.params.itemId,
notification.params.turnId,
notification.params.delta,
"fileChange",
"File change inProgress",
),
type: "message-stream/item-output-appended",
itemId: notification.params.itemId,
turnId: notification.params.turnId,
delta: notification.params.delta,
kind: "fileChange",
fallbackText: "File change inProgress",
}),
"turn/diff/updated": (_state, notification) =>
actionPlan({ type: "message-stream/turn-diff-updated", turnId: notification.params.turnId, diff: notification.params.diff }),
"hook/started": (state, notification) => hookRunPlan(state, notification.params.run, notification.params.turnId, "running"),
"hook/completed": (state, notification) =>
hookRunPlan(state, notification.params.run, notification.params.turnId, notification.params.run.status),
"item/mcpToolCall/progress": (state, notification) =>
"item/mcpToolCall/progress": (_state, notification) =>
actionPlan({
type: "message-stream/items-replaced",
items: appendToolOutput(
state.messageStream.displayItems,
notification.params.itemId,
notification.params.turnId,
notification.params.message,
"mcp progress",
),
type: "message-stream/tool-output-appended",
itemId: notification.params.itemId,
turnId: notification.params.turnId,
delta: notification.params.message,
fallbackLabel: "mcp progress",
}),
"item/autoApprovalReview/started": autoApprovalReviewPlan,
"item/autoApprovalReview/completed": autoApprovalReviewPlan,
guardianWarning: (state, notification, localItemId) => {
const item = createReviewResultItem(localItemId("review"), notification.params.message);
if (isUnstructuredAutoReviewWarning(item) && hasStructuredAutoReviewResult(state.messageStream.displayItems, activeTurnId(state))) {
if (
isUnstructuredAutoReviewWarning(item) &&
hasStructuredAutoReviewResult(messageStreamDisplayItems(state.messageStream), activeTurnId(state))
) {
return EMPTY_PLAN;
}
return actionPlan({ type: "message-stream/item-upserted", item });
@ -396,7 +385,7 @@ function autoApprovalReviewPlan(
const reviewItem = createAutoReviewResultItem(notification.params);
return actionPlan({
type: "message-stream/items-replaced",
items: upsertDisplayItem(removeUnstructuredAutoReviewWarnings(state.messageStream.displayItems), reviewItem),
items: upsertDisplayItem(removeUnstructuredAutoReviewWarnings(messageStreamDisplayItems(state.messageStream)), reviewItem),
});
}
@ -410,11 +399,13 @@ function completedItemPlan(state: ChatState, item: TurnItem, turnId: string): Ch
if (item.type === "userMessage") return EMPTY_PLAN;
const displayItem = displayItemFromTurnItem(item, turnId);
if (!displayItem) return EMPTY_PLAN;
let displayItems = upsertDisplayItem(state.messageStream.displayItems, displayItem);
if (displayItem.kind === "reasoning") {
displayItems = completeReasoningItems(displayItems, turnId);
}
return actionPlan({ type: "message-stream/items-replaced", items: displayItems });
return {
actions: [
{ type: "message-stream/item-upserted", item: displayItem },
...(displayItem.kind === "reasoning" ? ([{ type: "message-stream/reasoning-completed", turnId }] satisfies ChatAction[]) : []),
],
effects: [],
};
}
function fileChangePlan(itemId: string, turnId: string, changes: FileUpdateChange[], status: string): ChatNotificationPlan {
@ -434,7 +425,7 @@ function fileChangePlan(itemId: string, turnId: string, changes: FileUpdateChang
}
function appendToolTextPlan(
state: ChatState,
_state: ChatState,
itemId: string,
turnId: string,
label: string,
@ -442,8 +433,12 @@ function appendToolTextPlan(
kind: Extract<DisplayKind, "tool" | "hook" | "reasoning"> = "tool",
): ChatNotificationPlan {
return actionPlan({
type: "message-stream/items-replaced",
items: appendItemText(state.messageStream.displayItems, itemId, turnId, label, delta, kind),
type: "message-stream/item-text-appended",
itemId,
turnId,
label,
delta,
kind,
});
}
@ -483,22 +478,22 @@ function hookRunTurnId(
function displayItemsWithPendingPromptSubmitHooks(state: ChatState, turnId: string): readonly DisplayItem[] {
const pending = pendingTurnStartForState(state);
if (!pending) return state.messageStream.displayItems;
return attachHookRunsToTurn(state.messageStream.displayItems, turnId, pending.promptSubmitHookItemIds, pending.anchorItemId);
const displayItems = messageStreamDisplayItems(state.messageStream);
if (!pending) return displayItems;
return attachHookRunsToTurn(displayItems, turnId, pending.promptSubmitHookItemIds, pending.anchorItemId);
}
function reconciledCompletedTurnItems(state: ChatState, turn: TurnRecord): readonly DisplayItem[] {
const turnItems = displayItemsFromTurns([turn]);
if (turnItems.length === 0) return state.messageStream.displayItems;
const displayItems = messageStreamDisplayItems(state.messageStream);
if (turnItems.length === 0) return displayItems;
const serverUserMessages = turnItems.filter(isUserMessage);
const serverUserClientIds = new Set(serverUserMessages.map((item) => item.clientId).filter(isString));
const serverUserMessagesByClientId = new Map(
serverUserMessages.flatMap((item) => (item.clientId ? ([[item.clientId, item]] as const) : [])),
);
const serverUserFallbackTexts = serverUserClientIds.size > 0 ? new Set<string>() : new Set(serverUserMessages.map((item) => item.text));
const stateDisplayItems = state.messageStream.displayItems.map(
(item) => serverUserMessageForOptimisticItem(item, serverUserMessagesByClientId) ?? item,
);
const stateDisplayItems = displayItems.map((item) => serverUserMessageForOptimisticItem(item, serverUserMessagesByClientId) ?? item);
let mergedTurnItems = stateDisplayItems
.filter((item) => item.turnId === turn.id)
.filter((item) => !isOptimisticUserMessage(item, serverUserClientIds, serverUserFallbackTexts));

View file

@ -4,6 +4,8 @@ import type { RateLimitSnapshot, ThreadTokenUsage } from "../../../domain/runtim
import type { ActivePermissionProfile, ApprovalPolicy, ApprovalsReviewer, ServiceTier } from "../../../domain/runtime/policy";
import type { ModelMetadata, ReasoningEffort } from "../../../domain/catalog/metadata";
import type { ActiveCollaborationMode, CollaborationMode, PendingRuntimeSetting, RequestedServiceTier } from "./pending-settings";
import type { DisplayItem } from "../display/types";
import { messageStreamDisplayItems } from "../state/message-stream";
export interface RuntimeSnapshot {
runtimeConfig: RuntimeConfigSnapshot | null;
@ -31,7 +33,7 @@ interface RuntimeSnapshotInput {
activeThread: Pick<ChatState["activeThread"], "id" | "tokenUsage">;
runtime: ChatState["runtime"];
rateLimit: ChatState["connection"]["rateLimit"];
displayItems: ChatState["messageStream"]["displayItems"];
displayItems: readonly DisplayItem[];
availableModels: ChatState["connection"]["availableModels"];
}
@ -64,7 +66,7 @@ export function runtimeSnapshotForChatState(state: ChatState): RuntimeSnapshot {
activeThread: state.activeThread,
runtime: state.runtime,
rateLimit: state.connection.rateLimit,
displayItems: state.messageStream.displayItems,
displayItems: messageStreamDisplayItems(state.messageStream),
availableModels: state.connection.availableModels,
});
}

View file

@ -1,8 +1,19 @@
import { upsertDisplayItem } from "./message-stream-updates";
import type { DisplayItem } from "../display/types";
import { normalizeProposedPlanMarkdown } from "../display/items/proposed-plan";
export interface ChatMessageStreamActiveSegment {
turnId: string | null;
items: readonly DisplayItem[];
indexById: ReadonlyMap<string, number>;
indexBySourceItemId: ReadonlyMap<string, number>;
}
export interface ChatMessageStreamState {
/** Compatibility projection for tests and legacy fixtures. Runtime code should use messageStreamDisplayItems. */
displayItems: readonly DisplayItem[];
stableItems: readonly DisplayItem[];
activeSegment: ChatMessageStreamActiveSegment | null;
turnDiffs: ReadonlyMap<string, string>;
historyCursor: string | null;
loadingHistory: boolean;
@ -21,39 +32,131 @@ export type MessageStreamAction =
loadingHistory?: boolean;
}
| { type: "message-stream/item-upserted"; item: DisplayItem }
| { type: "message-stream/reasoning-completed"; turnId: string }
| { type: "message-stream/assistant-delta-appended"; itemId: string; turnId: string; delta: string; completeReasoning?: boolean }
| { type: "message-stream/plan-delta-appended"; itemId: string; turnId: string; delta: string }
| {
type: "message-stream/item-text-appended";
itemId: string;
turnId: string;
label: string;
delta: string;
kind: "tool" | "hook" | "reasoning";
}
| {
type: "message-stream/tool-output-appended";
itemId: string;
turnId: string;
delta: string;
fallbackLabel: string;
}
| {
type: "message-stream/item-output-appended";
itemId: string;
turnId: string;
delta: string;
kind: "command" | "fileChange";
fallbackText: string;
}
| { type: "message-stream/turn-diff-updated"; turnId: string; diff: string };
export function initialChatMessageStreamState(): ChatMessageStreamState {
return {
displayItems: [],
export function initialChatMessageStreamState(items: readonly DisplayItem[] = []): ChatMessageStreamState {
return withDisplayItemsAccessor({
stableItems: items,
activeSegment: null,
turnDiffs: new Map(),
historyCursor: null,
loadingHistory: false,
reportedLogs: new Set(),
};
});
}
export function messageStreamDisplayItems(state: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">): readonly DisplayItem[] {
const legacyItems = legacyDisplayItems(state);
if (legacyItems && state.stableItems.length === 0 && (!state.activeSegment || state.activeSegment.items.length === 0)) return legacyItems;
if (!state.activeSegment || state.activeSegment.items.length === 0) return state.stableItems;
return [...state.stableItems, ...state.activeSegment.items];
}
export function messageStreamStableItems(state: Pick<ChatMessageStreamState, "stableItems">): readonly DisplayItem[] {
return state.stableItems;
}
export function messageStreamActiveItems(state: Pick<ChatMessageStreamState, "activeSegment">): readonly DisplayItem[] {
return state.activeSegment?.items ?? [];
}
export function messageStreamIsEmpty(state: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">): boolean {
const legacyItems = legacyDisplayItems(state);
if (legacyItems) return legacyItems.length === 0;
return state.stableItems.length === 0 && (!state.activeSegment || state.activeSegment.items.length === 0);
}
export function messageStreamWithDisplayItems(
state: ChatMessageStreamState,
items: readonly DisplayItem[],
patch: Partial<Pick<ChatMessageStreamState, "historyCursor" | "loadingHistory">> = {},
): ChatMessageStreamState {
return patchObject(state, {
stableItems: items,
activeSegment: null,
...patch,
});
}
export function messageStreamWithActiveTurnItems(
state: ChatMessageStreamState,
turnId: string,
items: readonly DisplayItem[],
): ChatMessageStreamState {
const stableItems = items.filter((item) => item.turnId !== turnId);
const activeItems = items.filter((item) => item.turnId === turnId);
return patchObject(state, {
stableItems,
activeSegment: activeSegmentFromItems(turnId, activeItems),
});
}
export function messageStreamStartActiveSegment(
state: ChatMessageStreamState,
turnId: string | null,
items: readonly DisplayItem[],
): ChatMessageStreamState {
return patchObject(state, { activeSegment: activeSegmentFromItems(turnId, items) });
}
export function reduceMessageStreamSlice(state: ChatMessageStreamState, action: MessageStreamAction): ChatMessageStreamState {
switch (action.type) {
case "message-stream/item-added":
case "message-stream/system-item-added":
return patchObject(state, { displayItems: [...state.displayItems, action.item] });
return appendMessageStreamItem(state, action.item);
case "message-stream/deduped-log-added":
if (state.reportedLogs.has(action.text)) return state;
return patchObject(state, {
reportedLogs: new Set([...state.reportedLogs, action.text]),
displayItems: [...state.displayItems, action.item],
...appendMessageStreamItemPatch(state, action.item),
});
case "message-stream/items-replaced":
return patchObject(state, {
displayItems: action.items,
return messageStreamWithDisplayItems(state, action.items, {
...definedPatch("historyCursor", action.historyCursor),
...definedPatch("loadingHistory", action.loadingHistory),
});
case "message-stream/history-loading-set":
return patchObject(state, { loadingHistory: action.loading });
case "message-stream/item-upserted":
return patchObject(state, { displayItems: upsertDisplayItem(state.displayItems, action.item) });
return upsertMessageStreamItem(state, action.item);
case "message-stream/reasoning-completed":
return completeReasoningInMessageStream(state, action.turnId);
case "message-stream/assistant-delta-appended":
return appendAssistantDeltaToMessageStream(state, action.itemId, action.turnId, action.delta, action.completeReasoning ?? false);
case "message-stream/plan-delta-appended":
return appendPlanDeltaToMessageStream(state, action.itemId, action.turnId, action.delta);
case "message-stream/item-text-appended":
return appendItemTextToMessageStream(state, action.itemId, action.turnId, action.label, action.delta, action.kind);
case "message-stream/tool-output-appended":
return appendToolOutputToMessageStream(state, action.itemId, action.turnId, action.delta, action.fallbackLabel);
case "message-stream/item-output-appended":
return appendItemOutputToMessageStream(state, action.itemId, action.turnId, action.delta, action.kind, action.fallbackText);
case "message-stream/turn-diff-updated":
return patchObject(state, {
turnDiffs: updatedTurnDiffs(state.turnDiffs, action.turnId, action.diff),
@ -61,6 +164,281 @@ export function reduceMessageStreamSlice(state: ChatMessageStreamState, action:
}
}
function appendMessageStreamItem(state: ChatMessageStreamState, item: DisplayItem): ChatMessageStreamState {
return patchObject(state, appendMessageStreamItemPatch(state, item));
}
function appendMessageStreamItemPatch(state: ChatMessageStreamState, item: DisplayItem): Partial<ChatMessageStreamState> {
if (shouldUseActiveSegment(state.activeSegment, item)) {
return { activeSegment: appendActiveSegmentItem(state.activeSegment, item) };
}
return { stableItems: [...state.stableItems, item] };
}
function upsertMessageStreamItem(state: ChatMessageStreamState, item: DisplayItem): ChatMessageStreamState {
if (shouldUseActiveSegment(state.activeSegment, item)) {
return patchObject(state, { activeSegment: upsertActiveSegmentItem(state.activeSegment, item) });
}
return patchObject(state, { stableItems: upsertDisplayItem(state.stableItems, item) });
}
function appendAssistantDeltaToMessageStream(
state: ChatMessageStreamState,
sourceItemId: string,
turnId: string,
delta: string,
completeReasoning: boolean,
): ChatMessageStreamState {
const current = completeReasoning ? completeReasoningInMessageStream(state, turnId) : state;
return updateActiveSegment(current, turnId, (segment) => {
const index = segment.indexBySourceItemId.get(sourceItemId);
if (index !== undefined) {
return replaceActiveSegmentItem(segment, index, (item) =>
item.kind === "message" && item.messageKind === "assistantResponse"
? {
...item,
text: `${item.text}${delta}`,
copyText: `${item.text}${delta}`,
turnId: item.turnId ?? turnId,
messageState: "streaming",
}
: item,
);
}
return appendActiveSegmentItem(segment, {
id: sourceItemId,
kind: "message",
messageKind: "assistantResponse",
role: "assistant",
text: delta,
copyText: delta,
turnId,
sourceItemId,
messageState: "streaming",
});
});
}
function appendPlanDeltaToMessageStream(
state: ChatMessageStreamState,
sourceItemId: string,
turnId: string,
delta: string,
): ChatMessageStreamState {
return updateActiveSegment(state, turnId, (segment) => {
const index = segment.indexBySourceItemId.get(sourceItemId);
if (index !== undefined) {
return replaceActiveSegmentItem(segment, index, (item) => {
if (item.kind !== "message" || item.role !== "assistant") return item;
const text = normalizeProposedPlanMarkdown(`${item.text}${delta}`);
return {
...item,
messageKind: "proposedPlan",
text,
copyText: text,
turnId: item.turnId ?? turnId,
messageState: "streaming",
};
});
}
const text = normalizeProposedPlanMarkdown(delta);
return appendActiveSegmentItem(segment, {
id: sourceItemId,
kind: "message",
messageKind: "proposedPlan",
role: "assistant",
text,
copyText: text,
turnId,
sourceItemId,
messageState: "streaming",
});
});
}
function appendItemTextToMessageStream(
state: ChatMessageStreamState,
sourceItemId: string,
turnId: string,
label: string,
delta: string,
kind: "tool" | "hook" | "reasoning",
): ChatMessageStreamState {
return updateActiveSegment(state, turnId, (segment) => {
const index = segment.indexBySourceItemId.get(sourceItemId);
if (index !== undefined) {
return replaceActiveSegmentItem(segment, index, (item) => ({ ...item, text: `${item.text}${delta}` }));
}
return appendActiveSegmentItem(segment, {
id: sourceItemId,
kind,
role: "tool",
text: `${label}: ${delta}`,
turnId,
sourceItemId,
});
});
}
function appendToolOutputToMessageStream(
state: ChatMessageStreamState,
sourceItemId: string,
turnId: string,
delta: string,
fallbackLabel: string,
): ChatMessageStreamState {
return updateActiveSegment(state, turnId, (segment) => {
const index = segment.indexBySourceItemId.get(sourceItemId);
if (index !== undefined) {
return replaceActiveSegmentItem(segment, index, (item) =>
item.kind === "tool" || item.kind === "hook" || item.kind === "reasoning"
? { ...item, output: `${item.output ?? ""}${delta}` }
: item,
);
}
return appendActiveSegmentItem(segment, {
id: sourceItemId,
kind: "tool",
role: "tool",
text: "details",
toolLabel: fallbackLabel,
turnId,
sourceItemId,
output: delta,
});
});
}
function appendItemOutputToMessageStream(
state: ChatMessageStreamState,
sourceItemId: string,
turnId: string,
delta: string,
kind: "command" | "fileChange",
fallbackText: string,
): ChatMessageStreamState {
return updateActiveSegment(state, turnId, (segment) => {
const index = segment.indexBySourceItemId.get(sourceItemId);
if (index !== undefined) {
return replaceActiveSegmentItem(segment, index, (item) =>
item.kind === "command" || item.kind === "fileChange" ? { ...item, output: `${item.output ?? ""}${delta}` } : item,
);
}
return appendActiveSegmentItem(segment, {
id: sourceItemId,
kind,
role: "tool",
text: fallbackText,
turnId,
sourceItemId,
output: delta,
...(kind === "fileChange"
? {
status: "inProgress",
changes: [],
executionState: "running",
}
: {
command: fallbackText,
cwd: "(unknown)",
status: "running",
executionState: "running",
}),
} as DisplayItem);
});
}
function completeReasoningInMessageStream(state: ChatMessageStreamState, turnId: string): ChatMessageStreamState {
const stableUpdate = completedReasoningItems(state.stableItems, turnId);
const activeSegment = state.activeSegment;
if (activeSegment?.turnId !== turnId) {
return stableUpdate.changed ? patchObject(state, { stableItems: stableUpdate.items }) : state;
}
const activeUpdate = completedReasoningItems(activeSegment.items, turnId);
return stableUpdate.changed || activeUpdate.changed
? patchObject(state, {
stableItems: stableUpdate.items,
activeSegment: activeUpdate.changed ? activeSegmentFromItems(activeSegment.turnId, activeUpdate.items) : activeSegment,
})
: state;
}
function completedReasoningItems(items: readonly DisplayItem[], turnId: string): { items: readonly DisplayItem[]; changed: boolean } {
let changed = false;
const nextItems: DisplayItem[] = [];
for (const item of items) {
if (item.kind !== "reasoning" || item.turnId !== turnId) {
nextItems.push(item);
} else {
changed = true;
nextItems.push({
...item,
status: "completed",
executionState: "completed",
} satisfies DisplayItem);
}
}
return { items: changed ? nextItems : items, changed };
}
function updateActiveSegment(
state: ChatMessageStreamState,
turnId: string,
update: (segment: ChatMessageStreamActiveSegment) => ChatMessageStreamActiveSegment,
): ChatMessageStreamState {
const segment = state.activeSegment?.turnId === turnId ? state.activeSegment : activeSegmentFromItems(turnId, []);
return patchObject(state, { activeSegment: update(segment) });
}
function shouldUseActiveSegment(
segment: ChatMessageStreamActiveSegment | null,
item: DisplayItem,
): segment is ChatMessageStreamActiveSegment {
if (!segment) return false;
return !item.turnId || !segment.turnId || item.turnId === segment.turnId;
}
function appendActiveSegmentItem(segment: ChatMessageStreamActiveSegment, item: DisplayItem): ChatMessageStreamActiveSegment {
return activeSegmentFromItems(segment.turnId, [...segment.items, item]);
}
function upsertActiveSegmentItem(segment: ChatMessageStreamActiveSegment, item: DisplayItem): ChatMessageStreamActiveSegment {
const index = segment.indexById.get(item.id);
if (index === undefined) return appendActiveSegmentItem(segment, item);
return replaceActiveSegmentItem(segment, index, (previous) => mergeDisplayItem(previous, item));
}
function replaceActiveSegmentItem(
segment: ChatMessageStreamActiveSegment,
index: number,
replacement: (item: DisplayItem) => DisplayItem,
): ChatMessageStreamActiveSegment {
const previous = segment.items[index];
if (!previous) return segment;
const next = replacement(previous);
if (next === previous) return segment;
const items = [...segment.items];
items[index] = next;
return activeSegmentFromItems(segment.turnId, items);
}
function mergeDisplayItem(previous: DisplayItem, next: DisplayItem): DisplayItem {
return upsertDisplayItem([previous], next)[0] ?? next;
}
function activeSegmentFromItems(turnId: string | null, items: readonly DisplayItem[]): ChatMessageStreamActiveSegment {
const indexById = new Map<string, number>();
const indexBySourceItemId = new Map<string, number>();
items.forEach((item, index) => {
indexById.set(item.id, index);
if (item.sourceItemId) indexBySourceItemId.set(item.sourceItemId, index);
});
return { turnId, items, indexById, indexBySourceItemId };
}
function updatedTurnDiffs(turnDiffs: ReadonlyMap<string, string>, turnId: string, diff: string): ReadonlyMap<string, string> {
const next = new Map(turnDiffs);
if (diff.trim().length > 0) {
@ -73,9 +451,35 @@ function updatedTurnDiffs(turnDiffs: ReadonlyMap<string, string>, turnId: string
function patchObject<T extends object>(current: T, patch: Partial<T>): T {
if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current;
return { ...current, ...patch };
const next = { ...current, ...patch };
return isMessageStreamState(next) ? (withDisplayItemsAccessor(next) as unknown as T) : next;
}
function definedPatch<Key extends string, Value>(key: Key, value: Value | undefined): Partial<Record<Key, Value>> {
return value === undefined ? {} : ({ [key]: value } as Partial<Record<Key, Value>>);
}
function isMessageStreamState(value: object): value is ChatMessageStreamState {
return "stableItems" in value && "activeSegment" in value && "turnDiffs" in value && "reportedLogs" in value;
}
function legacyDisplayItems(state: object): readonly DisplayItem[] | null {
if (!Object.prototype.propertyIsEnumerable.call(state, "displayItems")) return null;
const value = (state as { displayItems?: unknown }).displayItems;
return Array.isArray(value) ? (value as readonly DisplayItem[]) : null;
}
function withDisplayItemsAccessor(state: Omit<ChatMessageStreamState, "displayItems">): ChatMessageStreamState {
Object.defineProperty(state, "displayItems", {
configurable: true,
enumerable: false,
get() {
return messageStreamDisplayItems(this as ChatMessageStreamState);
},
set(items: readonly DisplayItem[]) {
(this as ChatMessageStreamState).stableItems = items;
(this as ChatMessageStreamState).activeSegment = null;
},
});
return state as unknown as ChatMessageStreamState;
}

View file

@ -28,7 +28,6 @@ import {
import type { RequestedServiceTier } from "../runtime/pending-settings";
import type { RequestId } from "../../../app-server/connection/rpc-messages";
import type { ComposerSuggestion } from "../conversation/composer/suggestions";
import { upsertDisplayItem } from "./message-stream-updates";
import type { DisplayItem } from "../display/types";
import type {
ActiveThreadResumedAction,
@ -48,7 +47,12 @@ import type {
} from "./actions";
import {
initialChatMessageStreamState,
messageStreamDisplayItems,
messageStreamStartActiveSegment,
messageStreamWithActiveTurnItems,
messageStreamWithDisplayItems,
reduceMessageStreamSlice,
type ChatMessageStreamActiveSegment,
type ChatMessageStreamState,
type MessageStreamAction,
} from "./message-stream";
@ -371,10 +375,7 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
activePermissionProfile: action.activePermissionProfile,
},
turn: initialTurnState(),
messageStream: {
...initialMessageStreamState(),
displayItems: action.displayItems ?? [],
},
messageStream: initialMessageStreamState(action.displayItems ?? []),
requests: initialRequestState(),
composer: initialComposerState(),
ui: initialUiState(),
@ -414,10 +415,7 @@ function reduceActiveThreadRestoredPlaceholderTransition(state: ChatState, actio
...state.runtime,
...initialActiveChatRuntimeState(),
},
messageStream: {
...initialMessageStreamState(),
displayItems: [],
},
messageStream: initialMessageStreamState(),
ui: initialUiState(),
}),
);
@ -429,10 +427,9 @@ function reduceTurnStartedTransition(state: ChatState, action: TurnStartedAction
activeThread: { ...state.activeThread, id: action.threadId },
turn: { lifecycle },
connection: { ...state.connection, status: "Turn running..." },
messageStream: {
...state.messageStream,
displayItems: action.displayItems ?? state.messageStream.displayItems,
},
messageStream: action.displayItems
? messageStreamWithActiveTurnItems(state.messageStream, action.turnId, action.displayItems)
: messageStreamStartActiveSegment(state.messageStream, action.turnId, []),
});
}
@ -441,7 +438,7 @@ function reduceTurnCompletedTransition(state: ChatState, action: TurnCompletedAc
if (lifecycle === state.turn.lifecycle) return state;
return patchChatState(state, {
turn: { lifecycle },
messageStream: { ...state.messageStream, displayItems: action.displayItems },
messageStream: messageStreamWithDisplayItems(state.messageStream, action.displayItems),
connection: { ...state.connection, status: `Turn ${action.status}.` },
});
}
@ -457,10 +454,7 @@ function reduceTurnOptimisticStartedTransition(state: ChatState, action: TurnOpt
});
return patchChatState(state, {
turn: { lifecycle },
messageStream: {
...state.messageStream,
displayItems: [...state.messageStream.displayItems, action.item],
},
messageStream: messageStreamStartActiveSegment(state.messageStream, null, [action.item]),
});
}
@ -472,7 +466,7 @@ function reduceTurnStartAcknowledgedTransition(state: ChatState, action: TurnSta
if (lifecycle === state.turn.lifecycle) return state;
return patchChatState(state, {
turn: { lifecycle },
messageStream: { ...state.messageStream, displayItems: action.displayItems },
messageStream: messageStreamWithActiveTurnItems(state.messageStream, action.turnId, action.displayItems),
});
}
@ -481,16 +475,13 @@ function reduceTurnStartFailedTransition(state: ChatState, action: TurnStartFail
if (lifecycle === state.turn.lifecycle) return state;
return patchChatState(state, {
turn: { lifecycle },
messageStream: { ...state.messageStream, displayItems: action.displayItems },
messageStream: messageStreamWithDisplayItems(state.messageStream, action.displayItems),
});
}
function reducePendingStartHookUpsertedTransition(state: ChatState, action: PendingStartHookUpsertedAction): ChatState {
return patchChatState(state, {
messageStream: {
...state.messageStream,
displayItems: upsertDisplayItem(state.messageStream.displayItems, action.item),
},
messageStream: reduceMessageStreamSlice(state.messageStream, { type: "message-stream/item-upserted", item: action.item }),
turn: {
lifecycle: transitionChatTurnLifecycleState(state.turn.lifecycle, {
type: "pending-start-hook-upserted",
@ -503,14 +494,12 @@ function reducePendingStartHookUpsertedTransition(state: ChatState, action: Pend
function reduceRequestResolvedTransition(state: ChatState, action: RequestResolvedAction): ChatState {
const requests = resolveChatRequest(state.requests, action.requestId);
if (requests === state.requests) return state;
const displayItems = action.resultItem ? [...state.messageStream.displayItems, action.resultItem] : state.messageStream.displayItems;
return patchChatState(state, {
requests,
ui: clearResolvedRequestDetailOpenState(state.ui, action.requestId),
messageStream: {
...state.messageStream,
displayItems,
},
messageStream: action.resultItem
? reduceMessageStreamSlice(state.messageStream, { type: "message-stream/item-added", item: action.resultItem })
: state.messageStream,
});
}
@ -641,6 +630,7 @@ function clearActiveTurnState(state: ChatState): ChatState {
turn: {
lifecycle: transitionChatTurnLifecycleState(state.turn.lifecycle, { type: "cleared" }),
},
messageStream: messageStreamWithDisplayItems(state.messageStream, messageStreamDisplayItems(state.messageStream)),
requests: initialRequestState(),
ui: clearAllRequestDetailOpenState(state.ui),
});
@ -715,8 +705,8 @@ function initialTurnState(): ChatTurnState {
return initialChatTurnState();
}
function initialMessageStreamState(): ChatMessageStreamState {
return initialChatMessageStreamState();
function initialMessageStreamState(items: readonly DisplayItem[] = []): ChatMessageStreamState {
return initialChatMessageStreamState(items);
}
function initialRequestState(): ChatRequestState {
@ -753,13 +743,7 @@ function cloneChatState(state: ChatState): ChatState {
activeThread: { ...state.activeThread },
runtime: { ...state.runtime },
turn: { lifecycle: state.turn.lifecycle },
messageStream: {
displayItems: [...state.messageStream.displayItems],
turnDiffs: new Map(state.messageStream.turnDiffs),
historyCursor: state.messageStream.historyCursor,
loadingHistory: state.messageStream.loadingHistory,
reportedLogs: new Set(state.messageStream.reportedLogs),
},
messageStream: cloneMessageStreamState(state.messageStream),
requests: {
approvals: [...state.requests.approvals],
pendingUserInputs: [...state.requests.pendingUserInputs],
@ -776,6 +760,27 @@ function cloneChatState(state: ChatState): ChatState {
};
}
function cloneMessageStreamState(state: ChatMessageStreamState): ChatMessageStreamState {
const legacyItems = !state.activeSegment && state.stableItems.length === 0 ? state.displayItems : [];
const next = initialChatMessageStreamState(legacyItems.length > 0 ? [...legacyItems] : [...state.stableItems]);
next.activeSegment = cloneActiveSegment(state.activeSegment);
next.turnDiffs = new Map(state.turnDiffs);
next.historyCursor = state.historyCursor;
next.loadingHistory = state.loadingHistory;
next.reportedLogs = new Set(state.reportedLogs);
return next;
}
function cloneActiveSegment(segment: ChatMessageStreamActiveSegment | null): ChatMessageStreamActiveSegment | null {
if (!segment) return null;
return {
turnId: segment.turnId,
items: [...segment.items],
indexById: new Map(segment.indexById),
indexBySourceItemId: new Map(segment.indexBySourceItemId),
};
}
function isMessageStreamAction(action: ChatSliceAction): action is MessageStreamAction {
return action.type.startsWith("message-stream/");
}

View file

@ -9,6 +9,7 @@ import type {
} from "./reducer";
import type { Thread } from "../../../domain/threads/model";
import type { DisplayItem } from "../display/types";
import { messageStreamDisplayItems, messageStreamIsEmpty } from "./message-stream";
export interface SubmissionStateSnapshot {
activeThreadId: string | null;
@ -32,7 +33,7 @@ export function listedThreads(state: ChatState): readonly Thread[] {
}
export function displayItemsEmpty(state: ChatState): boolean {
return state.messageStream.displayItems.length === 0;
return messageStreamIsEmpty(state.messageStream);
}
export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapshot {
@ -41,7 +42,7 @@ export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapsh
activeTurnId: selectActiveTurnId(state),
busy: chatTurnBusy(state),
listedThreads: state.threadList.listedThreads,
displayItems: state.messageStream.displayItems,
displayItems: messageStreamDisplayItems(state.messageStream),
pendingTurnStart: pendingTurnStart(state),
};
}
@ -54,12 +55,20 @@ export function implementPlanCandidateFromState(state: {
activeThread: Pick<ChatActiveThreadState, "id">;
turn: ChatTurnState;
runtime: Pick<ChatRuntimeState, "selectedCollaborationMode">;
messageStream: Pick<ChatMessageStreamState, "displayItems">;
messageStream: Pick<ChatMessageStreamState, "stableItems" | "activeSegment"> | Pick<ChatMessageStreamState, "displayItems">;
}): DisplayItem | null {
if (!state.activeThread.id || chatTurnBusy(state) || state.runtime.selectedCollaborationMode !== "plan") {
return null;
}
return (
[...state.messageStream.displayItems].reverse().find((item) => item.kind === "message" && item.messageKind === "proposedPlan") ?? null
[...selectorDisplayItems(state.messageStream)]
.reverse()
.find((item) => item.kind === "message" && item.messageKind === "proposedPlan") ?? null
);
}
function selectorDisplayItems(
messageStream: Pick<ChatMessageStreamState, "stableItems" | "activeSegment"> | Pick<ChatMessageStreamState, "displayItems">,
): readonly DisplayItem[] {
return "stableItems" in messageStream ? messageStreamDisplayItems(messageStream) : messageStream.displayItems;
}

View file

@ -5,6 +5,7 @@ import type { CodexPanelSettings } from "../../../settings/model";
import { generateThreadTitleWithCodex } from "../../thread-title/generation";
import { threadTitleContextFromConversationSummary, type ThreadTitleContext } from "../../thread-title/model";
import type { ChatAction, ChatState, ChatStateStore } from "../state/reducer";
import { messageStreamDisplayItems } from "../state/message-stream";
import { threadTitleContextFromDisplayItems } from "./title-context";
export interface AutoTitleControllerHost {
@ -45,7 +46,7 @@ export class AutoTitleController {
if (this.attemptedThreadIds.has(threadId) || this.inFlightThreadIds.has(threadId)) return;
const context =
threadTitleContextFromConversationSummary(completedSummary) ??
threadTitleContextFromDisplayItems(turnId, this.state.messageStream.displayItems);
threadTitleContextFromDisplayItems(turnId, messageStreamDisplayItems(this.state.messageStream));
if (!context) return;
this.attemptedThreadIds.add(threadId);

View file

@ -1,6 +1,7 @@
import { inheritedForkThreadName } from "../../../domain/threads/model";
import { chatTurnBusy } from "../state/reducer";
import { turnsAfterTurnId } from "../display/item-actions";
import { messageStreamDisplayItems } from "../state/message-stream";
import { archiveThreadOnServer } from "./archive-actions";
import type { ChatThreadActionsHost } from "./action-context";
import { threadActionState, threadActionStillTargetsOriginalPanel } from "./action-context";
@ -24,7 +25,7 @@ export async function forkThreadFromTurn(
if (!client) return;
const initialActiveThreadId = threadActionState(host).activeThread.id;
const turnsToDrop = turnId ? turnsAfterTurnId(threadActionState(host).messageStream.displayItems, turnId) : 0;
const turnsToDrop = turnId ? turnsAfterTurnId(messageStreamDisplayItems(threadActionState(host).messageStream), turnId) : 0;
if (turnsToDrop === null) {
host.addSystemMessage("Could not find the selected turn to fork.");
return;

View file

@ -3,6 +3,7 @@ import type { TurnItem } from "../../../app-server/protocol/turn";
import type { ThreadTurnsPage } from "../../../domain/threads/history";
import type { ChatAction, ChatState, ChatStateStore } from "../state/reducer";
import { displayItemsFromTurns } from "../display/turn-items";
import { messageStreamDisplayItems } from "../state/message-stream";
export interface HistoryControllerHost {
stateStore: ChatStateStore;
@ -79,12 +80,13 @@ export class HistoryController {
const response = await client.threadTurnsList(threadId, cursor, 20);
if (this.isStale(load)) return;
const current = this.state;
const currentItems = messageStreamDisplayItems(current.messageStream);
const olderItems = displayItemsFromTurns(response.data);
const existingIds = new Set(current.messageStream.displayItems.map((item) => item.id));
const existingIds = new Set(currentItems.map((item) => item.id));
this.host.keepCurrentScrollPosition();
this.dispatch({
type: "message-stream/items-replaced",
items: [...olderItems.filter((item) => !existingIds.has(item.id)), ...current.messageStream.displayItems],
items: [...olderItems.filter((item) => !existingIds.has(item.id)), ...currentItems],
historyCursor: response.nextCursor,
});
} catch (error) {

View file

@ -6,6 +6,7 @@ import type { CodexPanelSettings } from "../../../settings/model";
import { generateThreadTitleWithCodex } from "../../thread-title/generation";
import { findThreadTitleContext, THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE, type ThreadTitleContext } from "../../thread-title/model";
import type { ChatState, ChatStateStore } from "../state/reducer";
import { messageStreamDisplayItems } from "../state/message-stream";
import { renameConnectedThread } from "./rename-actions";
import { firstThreadTitleContextFromDisplayItems } from "./title-context";
@ -155,7 +156,9 @@ export class RenameController {
});
return (
context ??
(this.state.activeThread.id === threadId ? firstThreadTitleContextFromDisplayItems(this.state.messageStream.displayItems) : null)
(this.state.activeThread.id === threadId
? firstThreadTitleContextFromDisplayItems(messageStreamDisplayItems(this.state.messageStream))
: null)
);
}

View file

@ -2,6 +2,7 @@ import { rollbackThread as rollbackThreadOnAppServer } from "../../../app-server
import { rollbackCandidateFromItems } from "../display/item-actions";
import { displayItemsFromTurns } from "../display/turn-items";
import { chatTurnBusy } from "../state/reducer";
import { messageStreamDisplayItems } from "../state/message-stream";
import type { ChatThreadActionsHost } from "./action-context";
import { threadActionDispatch, threadActionState, threadActionStillTargetsPanel } from "./action-context";
import { resumedThreadActionFromActiveRuntime } from "./resume";
@ -15,7 +16,7 @@ export async function rollbackThread(host: ChatThreadActionsHost, threadId: stri
const client = host.currentClient();
if (!client) return;
const candidate = rollbackCandidateFromItems(threadActionState(host).messageStream.displayItems);
const candidate = rollbackCandidateFromItems(messageStreamDisplayItems(threadActionState(host).messageStream));
if (!candidate) {
host.addSystemMessage("No completed turn to roll back.");
return;

View file

@ -45,6 +45,8 @@ interface MessageStreamLayoutContext {
historyCursor: string | null;
loadingHistory: boolean;
displayItems: readonly DisplayItem[];
stableItems?: readonly DisplayItem[];
activeItems?: readonly DisplayItem[];
turnDiffs?: ReadonlyMap<string, string>;
workspaceRoot?: string | null;
loadOlderTurns: () => void;

View file

@ -11,6 +11,7 @@ import { MarkdownMessageRenderer } from "./markdown-renderer";
import { messageStreamViewportNode, type MessageStreamViewportState } from "./viewport";
import type { DisplayItem } from "../../display/types";
import { implementPlanCandidateFromState } from "../../state/selectors";
import { messageStreamActiveItems, messageStreamDisplayItems, messageStreamStableItems } from "../../state/message-stream";
import {
forkCandidatesFromItems,
isForkCandidateItem,
@ -105,8 +106,9 @@ export class MessageStreamRenderer {
private messageStreamContext(state: ChatState, port: ChatMessageStreamContextPort): MessageStreamContext {
const busy = chatTurnBusy(state);
const rollbackCandidate = busy ? null : rollbackCandidateFromItems(state.messageStream.displayItems);
const forkCandidates = busy ? [] : forkCandidatesFromItems(state.messageStream.displayItems);
const displayItems = messageStreamDisplayItems(state.messageStream);
const rollbackCandidate = busy ? null : rollbackCandidateFromItems(displayItems);
const forkCandidates = busy ? [] : forkCandidatesFromItems(displayItems);
const implementPlanCandidate = implementPlanCandidateFromState(state);
return {
@ -114,7 +116,9 @@ export class MessageStreamRenderer {
turnLifecycle: state.turn.lifecycle,
historyCursor: state.messageStream.historyCursor,
loadingHistory: state.messageStream.loadingHistory,
displayItems: state.messageStream.displayItems,
displayItems,
stableItems: messageStreamStableItems(state.messageStream),
activeItems: messageStreamActiveItems(state.messageStream),
turnDiffs: state.messageStream.turnDiffs,
workspaceRoot: state.activeThread.cwd ?? port.vaultPath,
openDetails: state.ui.openDetails,

View file

@ -52,7 +52,7 @@ export function messageStreamBlocks(context: MessageStreamContext): MessageStrea
});
}
if (context.displayItems.length === 0) {
if (messageStreamBlockItemsEmpty(context)) {
blocks.push({
key: "empty",
node: <EmptyMessage />,
@ -60,8 +60,7 @@ export function messageStreamBlocks(context: MessageStreamContext): MessageStrea
return blocks;
}
const streamItems = activeTurn ? withoutActiveTaskProgress(context.displayItems, activeTurn) : context.displayItems;
for (const block of displayBlocksForItems(streamItems, activeTurn, context.workspaceRoot, context.turnDiffs)) {
for (const block of displayBlocksForContext(context, activeTurn)) {
if (block.type === "item") {
blocks.push({
key: `item:${block.item.id}`,
@ -80,6 +79,26 @@ export function messageStreamBlocks(context: MessageStreamContext): MessageStrea
return blocks;
}
function messageStreamBlockItemsEmpty(context: MessageStreamContext): boolean {
if (!context.stableItems && !context.activeItems) return context.displayItems.length === 0;
return (context.stableItems?.length ?? 0) === 0 && (context.activeItems?.length ?? 0) === 0;
}
function displayBlocksForContext(context: MessageStreamContext, activeTurn: string | null): DisplayBlock[] {
if (!activeTurn || !context.stableItems || !context.activeItems) {
const streamItems = activeTurn ? withoutActiveTaskProgress(context.displayItems, activeTurn) : context.displayItems;
return displayBlocksForItems(streamItems, activeTurn, context.workspaceRoot, context.turnDiffs);
}
const stableBlocks = displayBlocksForItems(context.stableItems, activeTurn, context.workspaceRoot, context.turnDiffs);
const activeBlocks = displayBlocksForItems(
withoutActiveTaskProgress(context.activeItems, activeTurn),
activeTurn,
context.workspaceRoot,
context.turnDiffs,
);
return [...stableBlocks, ...activeBlocks];
}
function bottomLiveBlocks(context: MessageStreamContext, activeTurn: string | null): MessageStreamBlock[] {
const blocks: MessageStreamBlock[] = [];
if (activeTurn) blocks.push(...activeTurnLiveBlocks(context, activeTurn));
@ -104,9 +123,10 @@ function bottomLiveBlocks(context: MessageStreamContext, activeTurn: string | nu
}
function activeTurnLiveBlocks(context: MessageStreamContext, activeTurn: string): MessageStreamBlock[] {
const agentSummaryAnchorId = activeAgentRunSummaryAnchorId(context.displayItems, activeTurn);
const items = context.activeItems ?? context.displayItems;
const agentSummaryAnchorId = activeAgentRunSummaryAnchorId(items, activeTurn);
const agentSummary = agentSummaryAnchorId ? activeAgentRunSummaryBlock(context) : null;
const blocks = context.displayItems.flatMap((item): MessageStreamBlock[] => {
const blocks = items.flatMap((item): MessageStreamBlock[] => {
if (item.kind === "taskProgress" && item.turnId === activeTurn) {
return [
{

View file

@ -42,6 +42,7 @@ interface MessageVirtualizerRuntime {
renderGeneration: number;
onVirtualizerChange: (() => void) | null;
followEndIntent: boolean;
preserveReadingAnchor: boolean;
viewportMeasurementsInvalid: boolean;
viewportRestoreFrame: number | null;
settleScrollToEndFrame: number | null;
@ -149,6 +150,7 @@ function createMessageVirtualizerRuntime(): MessageVirtualizerRuntime {
renderGeneration: 0,
onVirtualizerChange: null,
followEndIntent: false,
preserveReadingAnchor: false,
viewportMeasurementsInvalid: false,
viewportRestoreFrame: null,
settleScrollToEndFrame: null,
@ -175,9 +177,8 @@ function prepareMessageVirtualizerRender(
runtime.blocks = blocks;
const shouldScrollToBottom = blocks.length > 0 && (intent === "force-bottom" || intent === "follow-bottom" || shouldFollowAppend);
if (intent === "preserve") {
runtime.followEndIntent = false;
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.bottomReconcileCommitGeneration = null;
clearMessageVirtualizerFollowEndIntent(runtime);
cancelPendingMessageVirtualizerBottomFollow(runtime);
}
updateMessageVirtualizerOptions(runtime);
updateMessageVirtualizer(runtime.virtualizer);
@ -208,7 +209,10 @@ function renderMessageVirtualizer(
}
function getMessageVirtualizerTotalSize(runtime: MessageVirtualizerRuntime): number {
return runtime.virtualizer.getTotalSize();
const totalSize = runtime.virtualizer.getTotalSize();
const container = runtime.container;
if (!container || !runtime.preserveReadingAnchor) return totalSize;
return Math.max(totalSize, container.scrollTop + container.clientHeight + MESSAGE_BOTTOM_THRESHOLD + 1);
}
function getMessageVirtualizerItems(runtime: MessageVirtualizerRuntime): VirtualItem[] {
@ -276,7 +280,7 @@ function syncMessageVirtualizerScrollOffset(runtime: MessageVirtualizerRuntime):
runtime.virtualizer.scrollOffset = offset;
const scrollOffsetMovedAwayFromEnd = messageVirtualizerScrollOffsetMovedAwayFromEnd(runtime, container, offsetChanged);
if (scrollOffsetMovedAwayFromEnd) {
runtime.followEndIntent = false;
markMessageVirtualizerReadingAnchorIntent(runtime);
}
return scrollOffsetMovedAwayFromEnd;
}
@ -298,6 +302,33 @@ function hasPendingMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRunt
return runtime.settleScrollToEndFrame !== null;
}
function markMessageVirtualizerFollowEndIntent(runtime: MessageVirtualizerRuntime): void {
runtime.followEndIntent = true;
runtime.preserveReadingAnchor = false;
updateMessageVirtualizerOptions(runtime);
}
function clearMessageVirtualizerFollowEndIntent(runtime: MessageVirtualizerRuntime): void {
runtime.followEndIntent = false;
}
function markMessageVirtualizerReadingAnchorIntent(runtime: MessageVirtualizerRuntime): void {
runtime.followEndIntent = false;
runtime.preserveReadingAnchor = true;
updateMessageVirtualizerOptions(runtime);
}
function resetMessageVirtualizerScrollIntent(runtime: MessageVirtualizerRuntime): void {
runtime.followEndIntent = false;
runtime.preserveReadingAnchor = false;
updateMessageVirtualizerOptions(runtime);
}
function cancelPendingMessageVirtualizerBottomFollow(runtime: MessageVirtualizerRuntime): void {
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.bottomReconcileCommitGeneration = null;
}
function setMessageVirtualizerChangeHandler(runtime: MessageVirtualizerRuntime, callback: (() => void) | null): void {
runtime.onVirtualizerChange = callback;
}
@ -311,7 +342,7 @@ function pinMessageVirtualizerToBottom(runtime: MessageVirtualizerRuntime, conta
}
function repinMessageVirtualizerToBottomIfPinned(runtime: MessageVirtualizerRuntime): void {
if (!runtime.followEndIntent) return;
if (!hasMessageVirtualizerFollowEndIntent(runtime)) return;
requestMessageVirtualizerScrollToEnd(runtime);
}
@ -372,16 +403,15 @@ function scrollMessageVirtualizerByPage(runtime: MessageVirtualizerRuntime, dire
function disposeMessageVirtualizer(runtime: MessageVirtualizerRuntime): void {
cancelViewportRestoreMessageVirtualizerReset(runtime);
cancelSettledMessageVirtualizerScrollToEnd(runtime);
cancelPendingMessageVirtualizerBottomFollow(runtime);
runtime.cleanupVirtualizer?.();
runtime.cleanupVirtualizer = null;
runtime.container = null;
runtime.blocks = [];
runtime.commitGeneration = 0;
runtime.bottomReconcileCommitGeneration = null;
runtime.renderGeneration = 0;
runtime.onVirtualizerChange = null;
runtime.followEndIntent = false;
resetMessageVirtualizerScrollIntent(runtime);
runtime.viewportMeasurementsInvalid = false;
runtime.userScrollIntentUntil = 0;
}
@ -395,14 +425,13 @@ function attachMessageVirtualizer(runtime: MessageVirtualizerRuntime, container:
function detachMessageVirtualizer(runtime: MessageVirtualizerRuntime): void {
cancelViewportRestoreMessageVirtualizerReset(runtime);
cancelSettledMessageVirtualizerScrollToEnd(runtime);
cancelPendingMessageVirtualizerBottomFollow(runtime);
runtime.cleanupVirtualizer?.();
runtime.cleanupVirtualizer = null;
runtime.container = null;
runtime.blocks = [];
runtime.bottomReconcileCommitGeneration = null;
runtime.renderGeneration = 0;
runtime.followEndIntent = false;
resetMessageVirtualizerScrollIntent(runtime);
runtime.viewportMeasurementsInvalid = false;
runtime.userScrollIntentUntil = 0;
}
@ -413,7 +442,7 @@ function resetMessageVirtualizer(runtime: MessageVirtualizerRuntime, container:
runtime.cleanupVirtualizer?.();
runtime.cleanupVirtualizer = null;
runtime.container = container;
runtime.followEndIntent = false;
resetMessageVirtualizerScrollIntent(runtime);
runtime.virtualizer = new Virtualizer(messageVirtualizerOptions(runtime));
configureMessageVirtualizerSizeAdjustment(runtime);
runtime.cleanupVirtualizer = mountMessageVirtualizer(runtime);
@ -429,7 +458,7 @@ function messageVirtualizerOptions(runtime: MessageVirtualizerRuntime) {
initialRect: scrollElementRect(runtime.container),
initialOffset: () => runtime.container?.scrollTop ?? 0,
getItemKey: (index: number) => runtime.blocks[index]?.key ?? index,
anchorTo: "end" as const,
anchorTo: hasMessageVirtualizerFollowEndIntent(runtime) ? ("end" as const) : ("start" as const),
followOnAppend: true,
scrollEndThreshold: MESSAGE_BOTTOM_THRESHOLD,
paddingStart: paddingBlock,
@ -439,24 +468,9 @@ function messageVirtualizerOptions(runtime: MessageVirtualizerRuntime) {
useAnimationFrameWithResizeObserver: true,
overscan: 8,
observeElementRect: (instance: Virtualizer<HTMLElement, HTMLElement>, callback: (rect: { width: number; height: number }) => void) =>
observeElementRect(instance, (rect) => {
handleMessageVirtualizerViewportRect(runtime, rect);
callback(rect);
}),
observeMessageVirtualizerElementRect(runtime, instance, callback),
observeElementOffset: (instance: Virtualizer<HTMLElement, HTMLElement>, callback: (offset: number, isScrolling: boolean) => void) =>
observeElementOffset(instance, (offset, isScrolling) => {
callback(offset, isScrolling);
const element = instance.scrollElement;
if (element && isMessageVirtualizerObservedOffsetAtEnd(instance, element, offset)) {
runtime.followEndIntent = true;
return;
}
if (element && userIntendedMessageVirtualizerScroll(runtime, element)) {
runtime.followEndIntent = false;
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.bottomReconcileCommitGeneration = null;
}
}),
observeMessageVirtualizerElementOffset(runtime, instance, callback),
scrollToFn: elementScroll,
measureElement: (element: HTMLElement, entry: ResizeObserverEntry | undefined, instance: Virtualizer<HTMLElement, HTMLElement>) =>
measureMessageElement(runtime, element, entry, instance),
@ -466,6 +480,45 @@ function messageVirtualizerOptions(runtime: MessageVirtualizerRuntime) {
};
}
function observeMessageVirtualizerElementRect(
runtime: MessageVirtualizerRuntime,
instance: Virtualizer<HTMLElement, HTMLElement>,
callback: (rect: { width: number; height: number }) => void,
): ReturnType<typeof observeElementRect> {
return observeElementRect(instance, (rect) => {
handleMessageVirtualizerViewportRect(runtime, rect);
callback(rect);
});
}
function observeMessageVirtualizerElementOffset(
runtime: MessageVirtualizerRuntime,
instance: Virtualizer<HTMLElement, HTMLElement>,
callback: (offset: number, isScrolling: boolean) => void,
): ReturnType<typeof observeElementOffset> {
return observeElementOffset(instance, (offset, isScrolling) => {
callback(offset, isScrolling);
handleMessageVirtualizerObservedOffset(runtime, instance, offset);
});
}
function handleMessageVirtualizerObservedOffset(
runtime: MessageVirtualizerRuntime,
instance: Virtualizer<HTMLElement, HTMLElement>,
offset: number,
): void {
const element = instance.scrollElement;
if (!element) return;
if (isMessageVirtualizerObservedOffsetAtEnd(runtime, instance, element, offset)) {
if (runtime.preserveReadingAnchor && instance.scrollDirection === "backward") return;
markMessageVirtualizerFollowEndIntent(runtime);
return;
}
if (!userIntendedMessageVirtualizerScroll(runtime, element)) return;
markMessageVirtualizerReadingAnchorIntent(runtime);
cancelPendingMessageVirtualizerBottomFollow(runtime);
}
function updateMessageVirtualizerOptions(runtime: MessageVirtualizerRuntime): void {
runtime.virtualizer.setOptions(messageVirtualizerOptions(runtime));
configureMessageVirtualizerSizeAdjustment(runtime);
@ -478,10 +531,17 @@ function configureMessageVirtualizerSizeAdjustment(runtime: MessageVirtualizerRu
}
function scrollMessageVirtualizerBy(runtime: MessageVirtualizerRuntime, delta: number): void {
runtime.followEndIntent = false;
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.bottomReconcileCommitGeneration = null;
runtime.virtualizer.scrollBy(delta);
const container = runtime.container;
if (!container) return;
clearMessageVirtualizerFollowEndIntent(runtime);
cancelPendingMessageVirtualizerBottomFollow(runtime);
syncMessageVirtualizerScrollOffset(runtime);
markMessageVirtualizerReadingAnchorIntent(runtime);
const currentScrollSize = Math.max(container.scrollHeight, runtime.virtualizer.getTotalSize());
const currentScrollEnd = Math.max(0, currentScrollSize - container.clientHeight);
const targetOffset = Math.max(0, Math.min(container.scrollTop + delta, currentScrollEnd));
runtime.virtualizer.scrollToOffset(targetOffset);
syncMessageVirtualizerScrollOffset(runtime);
}
function hasMessageVirtualizerFollowEndIntent(runtime: MessageVirtualizerRuntime): boolean {
@ -489,7 +549,7 @@ function hasMessageVirtualizerFollowEndIntent(runtime: MessageVirtualizerRuntime
}
function requestMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime): void {
runtime.followEndIntent = true;
markMessageVirtualizerFollowEndIntent(runtime);
runtime.virtualizer.getTotalSize();
runtime.virtualizer.scrollToEnd();
reconcileMessageVirtualizerDomEnd(runtime);
@ -593,6 +653,7 @@ function isElementAtEnd(element: HTMLElement, fallbackScrollSize: number, thresh
}
function isMessageVirtualizerObservedOffsetAtEnd(
runtime: MessageVirtualizerRuntime,
instance: Virtualizer<HTMLElement, HTMLElement>,
element: HTMLElement,
offset: number,
@ -627,12 +688,22 @@ function measureMessageElement(
function shouldAdjustMessageScrollPositionOnItemSizeChange(
runtime: MessageVirtualizerRuntime,
item: VirtualItem,
_delta: number,
delta: number,
instance: Virtualizer<HTMLElement, HTMLElement>,
): boolean {
if (!hasMessageVirtualizerFollowEndIntent(runtime)) return false;
const scrollOffset = instance.scrollOffset ?? 0;
if (!hasMessageVirtualizerFollowEndIntent(runtime)) {
if (instance.scrollDirection === "backward") return false;
return runtime.preserveReadingAnchor && shouldPreserveMessageScrollAnchorOnItemSizeChange(item, delta, scrollOffset);
}
if (runtime.container && !isMessageVirtualizerObservedAtEnd(runtime, runtime.container)) return false;
return item.start < (instance.scrollOffset ?? 0) && instance.scrollDirection !== "backward";
return item.start < scrollOffset && instance.scrollDirection !== "backward";
}
function shouldPreserveMessageScrollAnchorOnItemSizeChange(item: VirtualItem, delta: number, scrollOffset: number): boolean {
if (delta === 0) return false;
const previousEnd = item.end - delta;
return previousEnd <= scrollOffset;
}
function textLineHeight(element: HTMLElement): number {
@ -672,20 +743,32 @@ function mountMessageVirtualizerUserScrollTracking(runtime: MessageVirtualizerRu
const markKeyboardScrollIntent = (event: KeyboardEvent) => {
if (isKeyboardScrollEvent(event)) markUserScrollIntent();
};
const markDocumentKeyboardScrollIntent = (event: KeyboardEvent) => {
if (isDocumentKeyboardScrollEventForMessageViewport(event, container)) markUserScrollIntent();
};
container.addEventListener("wheel", markUserScrollIntent, { passive: true });
container.addEventListener("touchstart", markUserScrollIntent, { passive: true });
container.addEventListener("pointerdown", markUserScrollIntent);
container.addEventListener("mousedown", markUserScrollIntent);
container.addEventListener("keydown", markKeyboardScrollIntent);
container.ownerDocument.addEventListener("keydown", markDocumentKeyboardScrollIntent, true);
return () => {
container.removeEventListener("wheel", markUserScrollIntent);
container.removeEventListener("touchstart", markUserScrollIntent);
container.removeEventListener("pointerdown", markUserScrollIntent);
container.removeEventListener("mousedown", markUserScrollIntent);
container.removeEventListener("keydown", markKeyboardScrollIntent);
container.ownerDocument.removeEventListener("keydown", markDocumentKeyboardScrollIntent, true);
};
}
function isDocumentKeyboardScrollEventForMessageViewport(event: KeyboardEvent, container: HTMLElement): boolean {
if (!isKeyboardScrollEvent(event)) return false;
const target = event.target;
const document = container.ownerDocument;
return target === document.body || target === document.documentElement;
}
function isKeyboardScrollEvent(event: KeyboardEvent): boolean {
return (
event.key === "ArrowUp" ||

View file

@ -29,6 +29,7 @@ export type WorkItemDisplayItem = TaskProgressDisplayItem | AgentDisplayItem | R
export interface WorkItemContext {
turnLifecycle: ChatTurnLifecycleState;
displayItems: readonly DisplayItem[];
activeItems?: readonly DisplayItem[];
openDetails: ReadonlySet<string>;
onDetailsToggle?: (key: string, open: boolean) => void;
}
@ -38,7 +39,7 @@ function workItemsActiveTurnId(context: Pick<WorkItemContext, "turnLifecycle">):
}
export function activeAgentRunSummaryBlock(context: WorkItemContext): AgentRunSummary | null {
return activeAgentRunSummary(context.displayItems, workItemsActiveTurnId(context));
return activeAgentRunSummary(context.activeItems ?? context.displayItems, workItemsActiveTurnId(context));
}
export function agentRunSummaryNode(summary: AgentRunSummary): UiNode {
@ -255,6 +256,8 @@ function isReasoningActive(item: ReasoningDisplayItem, context: WorkItemContext)
const activeTurn = workItemsActiveTurnId(context);
if (!activeTurn || item.turnId !== activeTurn) return false;
if (item.executionState === "completed") return false;
const latestActiveTurnItem = [...context.displayItems].reverse().find((candidate) => candidate.turnId === activeTurn);
const latestActiveTurnItem = [...(context.activeItems ?? context.displayItems)]
.reverse()
.find((candidate) => candidate.turnId === activeTurn);
return latestActiveTurnItem?.id === item.id;
}

View file

@ -10,6 +10,7 @@ import {
transitionChatTurnLifecycleState,
type ChatState,
} from "../../../src/features/chat/state/reducer";
import { messageStreamDisplayItems } from "../../../src/features/chat/state/message-stream";
import type { ThreadGoal } from "../../../src/domain/threads/goal";
import type { DisplayItem } from "../../../src/features/chat/display/types";
import type { Thread } from "../../../src/domain/threads/model";
@ -262,6 +263,54 @@ describe("chatReducer", () => {
expect(transitionChatTurnLifecycleState(running, { type: "completed", turnId: "turn" })).toEqual({ kind: "idle" });
});
it("appends streaming assistant deltas into the active segment without replacing stable history", () => {
const state = createChatState();
state.messageStream.displayItems = [message("history")];
const running = chatReducer(state, { type: "turn/started", threadId: "thread", turnId: "turn" });
const next = chatReducer(running, {
type: "message-stream/assistant-delta-appended",
itemId: "assistant",
turnId: "turn",
delta: "hello",
});
expect(next.messageStream.stableItems).toBe(running.messageStream.stableItems);
expect(next.messageStream.activeSegment?.items).toEqual([expect.objectContaining({ id: "assistant", text: "hello", turnId: "turn" })]);
expect(messageStreamDisplayItems(next.messageStream)).toEqual([
message("history"),
expect.objectContaining({ id: "assistant", text: "hello" }),
]);
});
it("updates repeated streaming output through the active source-item index", () => {
let state = createChatState();
state = chatReducer(state, { type: "turn/started", threadId: "thread", turnId: "turn" });
state = chatReducer(state, {
type: "message-stream/item-output-appended",
itemId: "cmd",
turnId: "turn",
delta: "one",
kind: "command",
fallbackText: "Command running",
});
const firstActiveItems = state.messageStream.activeSegment?.items;
const next = chatReducer(state, {
type: "message-stream/item-output-appended",
itemId: "cmd",
turnId: "turn",
delta: "two",
kind: "command",
fallbackText: "Command running",
});
expect(next.messageStream.activeSegment?.items).toHaveLength(1);
expect(next.messageStream.activeSegment?.indexBySourceItemId.get("cmd")).toBe(0);
expect(next.messageStream.activeSegment?.items).not.toBe(firstActiveItems);
expect(next.messageStream.activeSegment?.items[0]).toMatchObject({ id: "cmd", output: "onetwo" });
});
it("clears running state when a turn start fails", () => {
const state = createChatState();
state.turn.lifecycle = { kind: "starting", pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: ["hook"] } };

View file

@ -425,24 +425,42 @@ describe("TestMessageStreamVirtualizer", () => {
controller.dispose();
});
it("keeps the current reading offset when an earlier item resizes while unpinned", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
it("keeps the visible reading anchor when an earlier item resizes while unpinned", () => {
withResizeObserverEntries((resizeElement, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const first = measuredElement("first", 0, 300);
renderVirtualItems(controller, container, ["first", "second", "third"], [300, 300, 300], "preserve");
container.scrollTop = 360;
container.dispatchEvent(new Event("scroll"));
renderVirtualItems(controller, container, ["first", "second", "third"], [300, 300, 300], "preserve");
controller.measureElement(first);
userScrollTo(container, 360);
container.dataset["testTotalSize"] = "1100";
measureVirtualItem(controller, "first", 0, 500);
container.dataset["testTotalSize"] = "1100";
resizeElement(first, 500);
flushFrames();
expect(container.scrollTop).toBe(360);
expect(container.scrollTop).toBe(560);
controller.dispose();
});
});
container.dataset["testTotalSize"] = "800";
measureVirtualItem(controller, "first", 0, 200);
it("keeps the current reading offset when a visible item resizes while unpinned", () => {
withResizeObserverEntries((resizeElement, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const second = measuredElement("second", 1, 300);
expect(container.scrollTop).toBe(360);
controller.dispose();
renderVirtualItems(controller, container, ["first", "second", "third"], [300, 300, 300], "preserve");
controller.measureElement(second);
userScrollTo(container, 360);
container.dataset["testTotalSize"] = "1050";
resizeElement(second, 450);
flushFrames();
expect(container.scrollTop).toBe(360);
controller.dispose();
});
});
it("keeps the reading position when the message viewport shrinks away from the end", () => {
@ -535,6 +553,69 @@ describe("TestMessageStreamVirtualizer", () => {
controller.dispose();
});
it("scrolls by text lines from the rendered bottom before a scroll event settles", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = createMessageStreamVirtualizerDriver(container);
const keys = ["first", "second"];
container.dataset["testTotalSize"] = "480";
controller.render(
keys.map((key) => ({ key, node: null })),
"force-bottom",
);
controller.getTotalSize();
keys.forEach((key, index) => {
measureVirtualItem(controller, key, index, 240);
});
controller.getTotalSize();
expect(container.scrollTop).toBe(380);
controller.scrollByTextLines(-1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(344);
controller.dispose();
});
it("does not compound composer edge scrolling with backward remeasurements", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second", "third"], [240, 240, 240], "force-bottom");
expect(container.scrollTop).toBe(620);
controller.scrollByTextLines(-1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(584);
container.dataset["testTotalSize"] = "980";
measureVirtualItem(controller, "first", 0, 500);
expect(container.scrollTop).toBe(584);
controller.dispose();
});
it("treats body-targeted keyboard scrolling as message viewport user intent", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second"], [300, 200], "force-bottom");
expect(container.scrollTop).toBe(400);
document.body.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
container.scrollTop = 360;
container.dispatchEvent(new Event("scroll"));
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(360);
controller.dispose();
});
});
it("scrolls by a viewport step for composer PageUp and PageDown shortcuts", () => {
const container = messageContainer({ scrollTop: 220, clientHeight: 200 });
const controller = createMessageStreamVirtualizerDriver(container);
@ -559,6 +640,60 @@ describe("TestMessageStreamVirtualizer", () => {
controller.dispose();
});
it("keeps a composer PageUp target when newly measured items shrink the total size", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const keys = numberedKeys("item", 50);
container.dataset["testTotalSize"] = "4800";
controller.render(
keys.map((key) => ({ key, node: null })),
"force-bottom",
);
expect(container.scrollTop).toBe(4700);
controller.scrollByPage(-1);
expect(container.scrollTop).toBe(4620);
for (const item of controller.getVirtualItems()) {
measureVirtualItem(controller, keys[item.index] ?? "", item.index, 20);
}
container.dataset["testTotalSize"] = String(controller.getTotalSize());
clampScrollTop(container);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(4620);
controller.dispose();
});
it("keeps repeated composer line scrolling near the bottom when measurements shrink", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = createMessageStreamVirtualizerDriver(container);
const keys = numberedKeys("item", 50);
container.dataset["testTotalSize"] = "4800";
controller.render(
keys.map((key) => ({ key, node: null })),
"force-bottom",
);
userScrollTo(container, 4660);
controller.scrollByTextLines(-1);
expect(container.scrollTop).toBe(4624);
for (const item of controller.getVirtualItems()) {
measureVirtualItem(controller, keys[item.index] ?? "", item.index, 20);
}
container.dataset["testTotalSize"] = String(controller.getTotalSize());
clampScrollTop(container);
container.dispatchEvent(new Event("scroll"));
controller.scrollByTextLines(-1);
expect(container.scrollTop).toBe(4588);
controller.dispose();
});
it("drops stale same-key measurements when force-bottom replaces the message stream", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
@ -648,6 +783,11 @@ function userScrollTo(container: HTMLElement, scrollTop: number): void {
container.dispatchEvent(new Event("scroll"));
}
function clampScrollTop(container: HTMLElement): void {
const scrollTop = container.scrollTop;
container.scrollTop = scrollTop;
}
function measureVirtualItem(controller: MessageStreamVirtualizerDriver, key: string, index: number, height: number): void {
controller.measureElement(measuredElement(key, index, height));
}