mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Buffer message streams and preserve scroll anchors
This commit is contained in:
parent
5b66d43c1e
commit
2e41795220
17 changed files with 913 additions and 189 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] } };
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue