refactor(ui): split chat thread projection (#113714)

* refactor(ui): split chat thread projection

* refactor(ui): tighten chat thread module boundaries

* refactor(ui): type chat stream grouping locally
This commit is contained in:
Peter Steinberger
2026-07-25 08:28:02 -07:00
committed by GitHub
parent 481d826ff4
commit f808147f62
5 changed files with 1838 additions and 1844 deletions
-1
View File
@@ -1059,7 +1059,6 @@ ui/src/pages/chat/chat-send.test.ts
ui/src/pages/chat/chat-state.test.ts
ui/src/pages/chat/chat-state.ts
ui/src/pages/chat/chat-thread.test.ts
ui/src/pages/chat/chat-thread.ts
ui/src/pages/chat/chat-view.test.ts
ui/src/pages/chat/components/chat-message.test.ts
ui/src/pages/chat/components/chat-model-controls.ts
+438
View File
@@ -0,0 +1,438 @@
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import type { QuestionPrompt } from "../../app/question-prompt.ts";
import type { ChatItem, ChatQueueItem, MessageGroup } from "../../lib/chat/chat-types.ts";
import {
streamSegmentHasItemId,
streamSegmentUsesAccumulatedText,
trimAccumulatedStreamPrefix,
type ChatStreamSegment,
} from "../../lib/chat/chat-types.ts";
import {
isAssistantHeartbeatAckForDisplay,
stripHeartbeatTokenForDisplay,
} from "../../lib/chat/heartbeat-display.ts";
import { extractTextCached } from "../../lib/chat/message-extract.ts";
import { normalizeRoleForGrouping } from "../../lib/chat/message-normalizer.ts";
import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts";
import {
buildCompactionDividerItem,
clearWorkingProgress,
resolveWorkingProgress,
shouldRenderQueuedSendInThread,
} from "./chat-progress.ts";
import {
annotateToolTurnOutcome,
coalesceToolActivityMessages,
groupMessages,
isKeyedAssistantStreamFallbackMessage,
} from "./chat-thread-grouping.ts";
import {
appendCanvasBlockToAssistantMessage,
buildMessageKeys,
canvasPreviewBaseIdentity,
chatItemTimestamp,
collapseSequentialDuplicateMessages,
createCanvasAssistantMessage,
extractChatMessagePreview,
findCanvasInsertionIndex,
findNearestAssistantMessageIndex,
hasRenderableNormalizedMessage,
messageKey,
messageMatchesSearchQuery,
queuedSendThreadMessage,
rawMessageTimestamp,
safeNormalizeMessage,
sanitizeStreamText,
sortChatItemsByVisibleTime,
timestampAfterVisibleItems,
transcriptPositionTimestamp,
turnHasMatchingAssistant,
} from "./chat-thread-items.ts";
import { chatMessagesContainQueuedSend } from "./steer-lifecycle.ts";
import type { PlanStatus } from "./tool-stream.ts";
export type BuildChatItemsProps = {
paneId: string;
sessionKey: string;
runId?: string | null;
/** Invalidates cached display copy when the active UI language changes. */
locale?: string;
messages: unknown[];
toolMessages: unknown[];
streamSegments: ChatStreamSegment[];
stream: string | null;
streamStartedAt: number | null;
queue?: ChatQueueItem[];
showToolCalls: boolean;
persistCommentary?: boolean;
/** True while the agent is visibly working (isChatRunWorking). */
runWorking?: boolean;
/** Keeps the status row visible while a running tool is parked for approval. */
waitingApproval?: boolean;
/** True while the current session has an abortable live run. */
runActive?: boolean;
planStatus?: PlanStatus | null;
questionPrompts?: readonly QuestionPrompt[];
/** True while chat history is loading (initial load or background reload). */
loading?: boolean;
searchOpen?: boolean;
searchQuery?: string;
};
export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGroup> {
let items: ChatItem[] = [];
const history = props.messages.filter(
(message) =>
!isAssistantHeartbeatAckForDisplay(message) &&
(props.persistCommentary !== false || !isKeyedAssistantStreamFallbackMessage(message)),
);
const tools = props.toolMessages.filter((message) => asRecord(message) !== null);
const historyKeys = buildMessageKeys(history);
const toolKeys = buildMessageKeys(tools, history.length);
const liftedCanvasSources = tools.flatMap((message, index) => {
const source = extractChatMessagePreview(message);
return source ? [{ ...source, message, index }] : [];
});
const searchFiltering = props.searchOpen === true && Boolean(props.searchQuery?.trim());
const persistedCanvasIdentities = new Set<string>();
for (const message of history) {
const source = extractChatMessagePreview(message);
if (!source) {
continue;
}
const baseIdentity = canvasPreviewBaseIdentity(message, source);
if (baseIdentity) {
// fetchMcpAppView assigns a fresh viewId to every invocation. Matching the call and
// view therefore identifies the same preview while still tolerating a reused call ID.
persistedCanvasIdentities.add(baseIdentity);
}
}
for (let i = 0; i < history.length; i++) {
const msg = history[i];
const itemKey = historyKeys[i] ?? messageKey(msg, i);
const normalized = safeNormalizeMessage(msg);
if (!normalized) {
continue;
}
const raw = asRecord(msg) ?? {};
const marker = raw["__openclaw"] as Record<string, unknown> | undefined;
if (marker && marker.kind === "compaction") {
items.push(buildCompactionDividerItem(marker, normalized.timestamp ?? Date.now(), i));
continue;
}
const role = normalizeRoleForGrouping(normalized.role);
if (role === "system") {
const text = extractTextCached(msg);
if (text?.trim()) {
items.push({ kind: "notice", key: itemKey, text, timestamp: normalized.timestamp });
}
continue;
}
const isToolResult = normalized.role.toLowerCase() === "toolresult";
const persistedCanvasSource = isToolResult ? extractChatMessagePreview(msg) : null;
const renderPersistedPreview =
persistedCanvasSource != null &&
(!searchFiltering || turnHasMatchingAssistant(history, i, props.searchQuery ?? ""));
if (persistedCanvasSource && renderPersistedPreview) {
items.push({
kind: "message",
key: `${itemKey}:canvas`,
message: createCanvasAssistantMessage(
persistedCanvasSource,
persistedCanvasSource.timestamp ?? transcriptPositionTimestamp(history, i),
),
});
}
if (!props.showToolCalls && isToolResult) {
continue;
}
const searchQuery = props.searchQuery ?? "";
if (props.searchOpen && searchQuery.trim() && !messageMatchesSearchQuery(msg, searchQuery)) {
continue;
}
if (!hasRenderableNormalizedMessage(msg) && normalized.role.toLowerCase() !== "assistant") {
continue;
}
items.push({
kind: "message",
key: itemKey,
message: msg,
});
}
const queuedSends = props.queue ?? [];
// Once authoritative history carries the send id, that message owns the bubble.
// Keep the queue row for run progress and delivery retirement, but do not render both copies.
const threadQueuedSends = queuedSends.filter(
(queued) => !chatMessagesContainQueuedSend(history, queued, true),
);
const activeRunQueuedSends = threadQueuedSends.filter(
(queued) => queued.sendState === "waiting-model",
);
const futureQueuedSends = threadQueuedSends.filter(
(queued) => queued.sendState !== "waiting-model",
);
const futureQueuedTimestamp = futureQueuedSends.reduce<number | null>(
(earliest, queued) =>
earliest == null ? queued.createdAt : Math.min(earliest, queued.createdAt),
null,
);
const appendQueuedSend = (queued: ChatQueueItem) => {
if (!shouldRenderQueuedSendInThread(queued)) {
return;
}
const message = queuedSendThreadMessage(queued);
if (!message) {
return;
}
const searchQuery = props.searchQuery ?? "";
if (
props.searchOpen &&
searchQuery.trim() &&
!messageMatchesSearchQuery(message, searchQuery)
) {
return;
}
items.push({
kind: "message",
// Mirror buildMessageKeys for a send-identity source key so the pending
// row and its history successor resolve to the same Lit key.
key: queued.sendRunId ? `msg:send:${queued.sendRunId}:0` : `pending-send:${queued.id}`,
message,
});
};
for (const queued of activeRunQueuedSends) {
appendQueuedSend(queued);
}
for (const liftedCanvasSource of liftedCanvasSources) {
const baseIdentity = canvasPreviewBaseIdentity(liftedCanvasSource.message, liftedCanvasSource);
if (baseIdentity && persistedCanvasIdentities.has(baseIdentity)) {
continue;
}
const assistantIndex = findNearestAssistantMessageIndex(items, liftedCanvasSource.timestamp);
if (assistantIndex == null) {
if (searchFiltering) {
continue;
}
const insertionIndex = findCanvasInsertionIndex(items, liftedCanvasSource.timestamp);
const nextItem = items[insertionIndex];
const nextTimestamp =
nextItem?.kind === "message" ? rawMessageTimestamp(nextItem.message) : null;
const boundaryTimestamp =
nextTimestamp == null
? futureQueuedTimestamp
: futureQueuedTimestamp == null
? nextTimestamp
: Math.min(nextTimestamp, futureQueuedTimestamp);
const timestamp =
liftedCanvasSource.timestamp != null && boundaryTimestamp != null
? Math.min(liftedCanvasSource.timestamp, boundaryTimestamp)
: liftedCanvasSource.timestamp;
items.splice(insertionIndex, 0, {
kind: "message",
key: `${
toolKeys[liftedCanvasSource.index] ??
messageKey(liftedCanvasSource.message, liftedCanvasSource.index + history.length)
}:canvas`,
message: createCanvasAssistantMessage(liftedCanvasSource, timestamp),
});
continue;
}
const item = items[assistantIndex];
if (!item || item.kind !== "message") {
continue;
}
items[assistantIndex] = {
...item,
message: appendCanvasBlockToAssistantMessage(
item.message as Record<string, unknown>,
liftedCanvasSource.preview,
liftedCanvasSource.text,
),
};
}
for (const queued of futureQueuedSends) {
appendQueuedSend(queued);
}
items = items.filter(
(item) => item.kind !== "message" || hasRenderableNormalizedMessage(item.message),
);
const segments = props.streamSegments;
const keyedSegments = segments.filter(streamSegmentHasItemId);
const indexedSegments = segments.filter((segment) => !streamSegmentHasItemId(segment));
const toolItems = tools.map((message, index) => ({
key: toolKeys[index] ?? messageKey(message, index + history.length),
message,
}));
const toolKeysByCallId = new Map<string, string>();
for (const tool of toolItems) {
const toolCallId = asRecord(tool.message)?.toolCallId;
if (typeof toolCallId === "string" && toolCallId.trim()) {
toolKeysByCallId.set(toolCallId.trim(), tool.key);
}
}
const maxLen = Math.max(indexedSegments.length, tools.length);
let previousAccumulatedStreamText: string | null = null;
const toolStreamPredecessors = new Map<string, string>();
for (let i = 0; i < maxLen; i++) {
if (i < indexedSegments.length) {
const segment = indexedSegments[i];
if (!segment) {
continue;
}
const text = sanitizeStreamText(segment.text);
const usesAccumulatedText = streamSegmentUsesAccumulatedText(segment);
const visibleText = usesAccumulatedText
? trimAccumulatedStreamPrefix(text, previousAccumulatedStreamText)
: text;
if (usesAccumulatedText && text.length > 0) {
previousAccumulatedStreamText = text;
}
if (visibleText.length > 0) {
const streamKey = `stream-seg:${props.sessionKey}:${i}`;
items.push({
kind: "stream",
key: streamKey,
text: visibleText,
startedAt: segment.ts,
isStreaming: false,
});
const toolCallId = segment.toolCallId?.trim();
const toolKey = toolCallId ? toolKeysByCallId.get(toolCallId) : undefined;
if (toolKey) {
// Gateway and browser clocks can disagree. Keep the assistant text that
// introduced a tool causally before its card even when timestamps do not.
toolStreamPredecessors.set(toolKey, streamKey);
}
}
}
const tool = toolItems[i];
if (tool && props.showToolCalls) {
items.push({
kind: "message",
key: tool.key,
message: tool.message,
});
}
}
for (const segment of keyedSegments) {
const text = sanitizeStreamText(segment.text);
if (text.length === 0) {
continue;
}
const commentaryItem: ChatItem = {
kind: "stream",
key: `stream-seg:${props.sessionKey}:${segment.itemId}`,
text,
startedAt: segment.ts,
isStreaming: false,
};
// Merge keyed commentary into the timestamp ordering path instead of
// appending it after every tool card. Insert before the first already-built
// item whose visible timestamp is strictly later, so a preamble that
// arrived before a later tool renders above that tool while the run is live
// (not only after final materialization). Tools that share the commentary's
// timestamp and are already visible stay above it.
const insertionIndex = items.findIndex((existing) => {
const existingTimestamp = chatItemTimestamp(existing);
return existingTimestamp != null && existingTimestamp > segment.ts;
});
if (insertionIndex === -1) {
items.push(commentaryItem);
} else {
items.splice(insertionIndex, 0, commentaryItem);
}
}
// Working spark contract: whenever the agent works with nothing visibly
// streaming (pre-first-token, or a queued send in flight), the thread shows
// the reading indicator where the reply will materialize. Streaming text
// and running tool rows take over as the signal once content flows.
// A visible running tool row already signals active work, so the spark is
// suppressed rather than stacked under it; hidden tool calls keep the spark.
const hasVisibleRunningTool =
props.showToolCalls &&
tools.some((message) => {
const record = asRecord(message);
return (
record?.["__openclawToolStreamLive"] === true &&
record["__openclawToolStreamResultReceived"] !== true
);
});
// The initial-load skeleton owns the empty thread; a background reload with
// content still visible keeps the spark (it is the only working signal).
const initialHistoryLoad = props.loading === true && items.length === 0;
const hasPendingResponse =
props.stream === null &&
((props.runWorking === true &&
(props.waitingApproval === true || !hasVisibleRunningTool) &&
!initialHistoryLoad) ||
queuedSends.some(
(item) => item.sendState === "sending" && shouldRenderQueuedSendInThread(item),
));
if (props.runWorking !== true && props.stream === null && !hasPendingResponse) {
clearWorkingProgress(props.sessionKey);
}
const resolveProgress = () =>
resolveWorkingProgress(
props.sessionKey,
props.runId ?? null,
props.streamStartedAt,
queuedSends,
segments,
tools,
);
if (hasPendingResponse) {
const progress = resolveProgress();
items.push({ kind: "reading-indicator", ...progress });
} else if (props.stream !== null) {
const text = sanitizeStreamText(props.stream);
const visibleText = trimAccumulatedStreamPrefix(text, previousAccumulatedStreamText);
if (visibleText.length > 0) {
if (!stripHeartbeatTokenForDisplay(visibleText).shouldSkip) {
const progress = resolveProgress();
items.push({
kind: "stream",
key: progress.key,
text: visibleText,
startedAt: timestampAfterVisibleItems(items, props.streamStartedAt ?? Date.now()),
isStreaming: true,
});
}
} else if (props.stream.trim().length === 0) {
const progress = resolveProgress();
items.push({ kind: "reading-indicator", ...progress });
}
}
if (props.runActive === true && props.planStatus && props.planStatus.steps.length > 0) {
items.push({ kind: "plan", key: `plan:${props.sessionKey}:active` });
}
for (const prompt of props.questionPrompts ?? []) {
// Pending questions live in the composer dock. Only their terminal summary becomes transcript.
if (
prompt.status === "pending" ||
!prompt.sessionKey ||
!areUiSessionKeysEquivalent(prompt.sessionKey, props.sessionKey)
) {
continue;
}
items.push({
kind: "question",
key: `question:${prompt.id}`,
questionId: prompt.id,
startedAt: prompt.createdAtMs,
});
}
return annotateToolTurnOutcome(
groupMessages(
collapseSequentialDuplicateMessages(
coalesceToolActivityMessages(sortChatItemsByVisibleTime(items, toolStreamPredecessors)),
),
),
);
}
+701
View File
@@ -0,0 +1,701 @@
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import {
isToolCallContentType,
isToolResultContentType,
} from "../../../../src/chat/tool-content.js";
import type { ChatItem, MessageGroup, ToolCard } from "../../lib/chat/chat-types.ts";
import { extractTextCached } from "../../lib/chat/message-extract.ts";
import { normalizeMessage, normalizeRoleForGrouping } from "../../lib/chat/message-normalizer.ts";
import { senderIdentityKey } from "../../lib/chat/sender-label.ts";
import { extractToolCardsCached, isToolCardError } from "../../lib/chat/tool-cards.ts";
import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts";
import {
resolveMessageToolUseId,
resolveToolBlockId,
safeNormalizeMessage,
} from "./chat-thread-items.ts";
export function isKeyedAssistantStreamFallbackMessage(message: unknown): boolean {
const record = asRecord(message);
if (normalizeLowercaseStringOrEmpty(record?.role) !== "assistant") {
return false;
}
const fallback = asRecord(record?.openclawStreamFallback);
return typeof fallback?.itemId === "string" && fallback.itemId.trim().length > 0;
}
function stampReplyAttribution(
items: Array<ChatItem | MessageGroup>,
): Array<ChatItem | MessageGroup> {
const userSenderKeys = new Set<string>();
for (const item of items) {
if (item.kind !== "group" || item.role !== "user" || !item.sender) {
continue;
}
const senderKey = senderIdentityKey(item.sender);
if (senderKey) {
userSenderKeys.add(senderKey);
}
}
if (userSenderKeys.size < 2) {
return items;
}
let latestUserSender: MessageGroup["sender"];
for (const item of items) {
if (item.kind !== "group") {
continue;
}
if (item.role === "user") {
// A sender-less user group clears attribution: no chip is safer than
// mislabeling the reply as addressed to the previous participant.
latestUserSender = item.sender;
} else if (item.role === "assistant" && latestUserSender) {
item.replyToSender = latestUserSender;
}
}
return items;
}
export function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
const result: Array<ChatItem | MessageGroup> = [];
let currentGroup: MessageGroup | null = null;
for (const item of items) {
if (item.kind !== "message") {
if (currentGroup) {
result.push(currentGroup);
currentGroup = null;
}
result.push(item);
continue;
}
const normalized = normalizeMessage(item.message);
const role = normalizeRoleForGrouping(normalized.role);
const senderLabel =
role === "user" || role === "assistant" ? (normalized.senderLabel ?? null) : null;
const sender = role === "user" ? normalized.sender : undefined;
const timestamp = normalized.timestamp || Date.now();
const shouldSplitBySender = role === "user" || role === "assistant";
const startsProjectedTurn =
asRecord(asRecord(item.message)?.["__openclaw"])?.turnBoundary === true;
const splitsAssistantCommentary =
role === "assistant" &&
currentGroup?.role === "assistant" &&
isKeyedAssistantStreamFallbackMessage(currentGroup.messages[0]?.message) !==
isKeyedAssistantStreamFallbackMessage(item.message);
if (
!currentGroup ||
startsProjectedTurn ||
currentGroup.role !== role ||
splitsAssistantCommentary ||
(shouldSplitBySender &&
(currentGroup.senderLabel !== senderLabel ||
senderIdentityKey(currentGroup.sender) !== senderIdentityKey(sender)))
) {
if (currentGroup) {
result.push(currentGroup);
}
currentGroup = {
kind: "group",
key: `group:${role}:${item.key}`,
role,
senderLabel,
...(sender ? { sender } : {}),
messages: [{ message: item.message, key: item.key, duplicateCount: item.duplicateCount }],
timestamp,
isStreaming: false,
};
} else {
currentGroup.messages.push({
message: item.message,
key: item.key,
duplicateCount: item.duplicateCount,
});
}
}
if (currentGroup) {
result.push(currentGroup);
}
return stampReplyAttribution(result);
}
function mergeToolCallResultPair(callItem: ChatItem, resultItem: ChatItem): ChatItem | null {
if (callItem.kind !== "message" || resultItem.kind !== "message") {
return null;
}
const callMessage = asRecord(callItem.message);
const resultMessage = asRecord(resultItem.message);
if (!callMessage || !resultMessage) {
return null;
}
const callRole = typeof callMessage.role === "string" ? callMessage.role.toLowerCase() : "";
const normalizedResult = safeNormalizeMessage(resultItem.message);
const resultRole = normalizedResult ? normalizeRoleForGrouping(normalizedResult.role) : "unknown";
if (callRole !== "assistant" || resultRole !== "tool" || !Array.isArray(callMessage.content)) {
return null;
}
const hasToolCallBlock = callMessage.content.some((block) =>
isToolCallContentType(asRecord(block)?.type),
);
if (!hasToolCallBlock) {
return null;
}
const callCards = extractToolCardsCached(callItem.message, `${callItem.key}:activity-call`);
const resultCards = extractToolCardsCached(
resultItem.message,
`${resultItem.key}:activity-result`,
);
if (callCards.length === 0 || resultCards.length === 0) {
return null;
}
const rawResultContent = Array.isArray(resultMessage.content) ? resultMessage.content : [];
if (rawResultContent.some((block) => isToolCallContentType(asRecord(block)?.type))) {
return null;
}
const resultOnlyContent = rawResultContent.filter(
(block) => !isToolCallContentType(asRecord(block)?.type),
);
const hasToolResultBlock = resultOnlyContent.some((block) =>
isToolResultContentType(asRecord(block)?.type),
);
const hasToolResult =
hasToolResultBlock ||
resultCards.some((card) => card.outputText !== undefined || card.isError !== undefined);
if (!hasToolResult) {
return null;
}
const unresolvedCallIds = unresolvedToolCallIds(callItem);
const matchedResults = new Map<string, { resultCard: ToolCard; resultName: string }>();
for (const resultCard of resultCards) {
const callId = resultCard.callId;
if (!callId || !unresolvedCallIds.has(callId) || matchedResults.has(callId)) {
return null;
}
const callCard = callCards.find((card) => card.callId === callId);
if (!callCard) {
return null;
}
const resultName = resultCard.name === "tool" ? callCard.name : resultCard.name;
if (
normalizeLowercaseStringOrEmpty(callCard.name) !== normalizeLowercaseStringOrEmpty(resultName)
) {
return null;
}
matchedResults.set(callId, { resultCard, resultName });
}
const preservedResultContent = resultOnlyContent.filter(
(block) => asRecord(block)?.type !== "text",
);
// Raw transcript result blocks usually carry the call id and tool name on the
// message, not the block. Stamp both onto the merged blocks (plus message-level
// details) so card extraction pairs them with the call instead of rendering a
// second bare "Tool" card.
const resultContent = hasToolResultBlock
? resultOnlyContent.map((block) => {
const record = asRecord(block);
if (!record || !isToolResultContentType(record.type)) {
return block;
}
const callId = resolveToolBlockId(record, resultMessage);
const matched = callId ? matchedResults.get(callId) : undefined;
if (!matched) {
return block;
}
const stamped: Record<string, unknown> = Object.assign({}, record);
stamped.id = callId;
stamped.name =
typeof record.name === "string" && record.name.trim() ? record.name : matched.resultName;
if (record.details === undefined && resultMessage.details !== undefined) {
stamped.details = resultMessage.details;
}
if (
record.isError === undefined &&
record.is_error === undefined &&
matched.resultCard.isError !== undefined
) {
stamped.isError = matched.resultCard.isError;
}
return stamped;
})
: (() => {
const [matched] = matchedResults.values();
if (!matched) {
return preservedResultContent;
}
return [
{
type: "tool_result",
id: matched.resultCard.callId,
name: matched.resultName,
text: matched.resultCard.outputText ?? "",
...(matched.resultCard.details !== undefined
? { details: matched.resultCard.details }
: {}),
...(matched.resultCard.isError !== undefined
? { isError: matched.resultCard.isError }
: {}),
},
...preservedResultContent,
];
})();
return {
...callItem,
message: {
...callMessage,
content: [...callMessage.content, ...resultContent],
},
};
}
function unresolvedToolCallIds(item: ChatItem): Set<string> {
const unresolved = new Set<string>();
if (item.kind !== "message") {
return unresolved;
}
const message = asRecord(item.message);
if (
!message ||
typeof message.role !== "string" ||
message.role.toLowerCase() !== "assistant" ||
!Array.isArray(message.content)
) {
return unresolved;
}
for (const block of message.content) {
const record = asRecord(block);
if (!record) {
continue;
}
const callId = resolveToolBlockId(record, message);
if (!callId) {
continue;
}
if (isToolCallContentType(record.type)) {
unresolved.add(callId);
} else if (isToolResultContentType(record.type)) {
unresolved.delete(callId);
}
}
return unresolved;
}
function isToolTimelineItem(item: ChatItem): boolean {
if (item.kind !== "message") {
return false;
}
const normalized = safeNormalizeMessage(item.message);
return normalized ? normalizeRoleForGrouping(normalized.role) === "tool" : false;
}
function splitBundledToolResultItems(item: ChatItem): ChatItem[] {
if (item.kind !== "message") {
return [item];
}
const message = asRecord(item.message);
if (!message || !Array.isArray(message.content) || message.content.length < 2) {
return [item];
}
const blocksByCallId = new Map<string, unknown[]>();
for (const block of message.content) {
const record = asRecord(block);
if (!record || !isToolResultContentType(record.type)) {
return [item];
}
const callId = resolveToolBlockId(record, message);
if (!callId) {
return [item];
}
const blocks = blocksByCallId.get(callId) ?? [];
blocks.push(block);
blocksByCallId.set(callId, blocks);
}
if (blocksByCallId.size < 2) {
return [item];
}
return Array.from(blocksByCallId.values(), (content, index) => ({
...item,
key: `${item.key}:result:${index}`,
message: { ...message, content },
}));
}
function resolveToolResultCallId(item: ChatItem): string | undefined {
if (item.kind !== "message") {
return undefined;
}
const message = asRecord(item.message);
if (!message) {
return undefined;
}
const content = Array.isArray(message.content) ? message.content : [];
if (content.some((block) => isToolCallContentType(asRecord(block)?.type))) {
return undefined;
}
const resultIds = new Set<string>();
for (const block of content) {
const record = asRecord(block);
if (record && isToolResultContentType(record.type)) {
const callId = resolveToolBlockId(record, message);
if (callId) {
resultIds.add(callId);
}
}
}
if (resultIds.size > 1) {
return undefined;
}
return resultIds.values().next().value ?? resolveMessageToolUseId(message);
}
function refreshOpenCallIds(
openCallIndexes: Map<string, number>,
coalesced: ChatItem[],
callIndex: number,
) {
for (const [callId, index] of openCallIndexes) {
if (index === callIndex) {
openCallIndexes.delete(callId);
}
}
const item = coalesced[callIndex];
if (!item) {
return;
}
for (const callId of unresolvedToolCallIds(item)) {
openCallIndexes.set(callId, callIndex);
}
}
export function coalesceToolActivityMessages(items: ChatItem[]): ChatItem[] {
const coalesced: ChatItem[] = [];
// Parallel calls can outnumber any fixed lookback window, so each unresolved
// call id owns its current transcript item until a non-tool boundary.
const openCallIndexes = new Map<string, number>();
for (const item of items) {
const resultItems = splitBundledToolResultItems(item);
const unmatchedResultItems: ChatItem[] = [];
let mergedResult = false;
for (const resultItem of resultItems) {
const callId = resolveToolResultCallId(resultItem);
const callIndex = callId ? openCallIndexes.get(callId) : undefined;
const callItem = callIndex === undefined ? undefined : coalesced[callIndex];
const merged =
callIndex === undefined || !callItem ? null : mergeToolCallResultPair(callItem, resultItem);
if (!merged || callIndex === undefined) {
unmatchedResultItems.push(resultItem);
continue;
}
coalesced[callIndex] = merged;
refreshOpenCallIds(openCallIndexes, coalesced, callIndex);
mergedResult = true;
}
if (mergedResult) {
for (const unmatched of unmatchedResultItems) {
coalesced.push(unmatched);
}
continue;
}
const unresolvedCallIds = unresolvedToolCallIds(item);
if (unresolvedCallIds.size === 1) {
const callId = unresolvedCallIds.values().next().value;
const previousIndex = callId ? openCallIndexes.get(callId) : undefined;
const previous = previousIndex === undefined ? undefined : coalesced[previousIndex];
if (previousIndex !== undefined && previous && unresolvedToolCallIds(previous).size === 1) {
coalesced[previousIndex] = item;
refreshOpenCallIds(openCallIndexes, coalesced, previousIndex);
continue;
}
}
coalesced.push(item);
if (unresolvedCallIds.size > 0) {
const callIndex = coalesced.length - 1;
for (const callId of unresolvedCallIds) {
openCallIndexes.set(callId, callIndex);
}
continue;
}
if (isToolTimelineItem(item)) {
// Orphan results keep the window open for later siblings.
continue;
}
// Any other content (user text, assistant reply, dividers) closes the run.
openCallIndexes.clear();
}
return coalesced;
}
function assistantGroupHasReplyText(group: MessageGroup): boolean {
return group.messages.some(({ message }) => {
if (extractTextCached(message)?.trim()) {
return true;
}
return safeNormalizeMessage(message)?.content.some((block) => block.type === "canvas") ?? false;
});
}
function assistantGroupIsForwardedBoundary(group: MessageGroup): boolean {
return group.messages.some(({ message }) => {
const provenance = asRecord(asRecord(message)?.provenance);
return provenance?.kind === "inter_session" && provenance.sourceTool === "sessions_send";
});
}
function groupStartsProjectedTurnBoundary(group: MessageGroup): boolean {
return asRecord(asRecord(group.messages[0]?.message)?.["__openclaw"])?.turnBoundary === true;
}
export function annotateToolTurnOutcome(
items: Array<ChatItem | MessageGroup>,
): Array<ChatItem | MessageGroup> {
let sawAssistantReply = false;
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.kind !== "group") {
continue;
}
const role = item.role.toLowerCase();
const forwardedBoundary = role === "assistant" && assistantGroupIsForwardedBoundary(item);
const projectedBoundary = groupStartsProjectedTurnBoundary(item);
if (role === "user") {
sawAssistantReply = false;
} else if (role === "assistant") {
if (forwardedBoundary) {
// Gateway preserves sessions_send provenance when projecting inputs as assistant groups.
// Those groups start a new autonomous turn; they are not replies to an earlier tool.
sawAssistantReply = false;
} else if (assistantGroupHasReplyText(item)) {
sawAssistantReply = true;
}
} else if (role === "tool") {
item.turnSucceeded = sawAssistantReply;
}
if (role !== "user" && !forwardedBoundary && projectedBoundary) {
// This group belongs to the new hidden-input turn. Reset only after
// processing it so replies from this turn cannot classify older tools.
sawAssistantReply = false;
}
}
return items;
}
type RenderChatItem = ChatItem | MessageGroup;
type StreamRunRenderItem = {
kind: "stream-run";
key: string;
parts: Array<
Extract<
ChatItem,
{ kind: "stream" } | { kind: "reading-indicator" } | { kind: "question" } | { kind: "plan" }
>
>;
};
export function coalesceStreamRuns(
items: RenderChatItem[],
): Array<RenderChatItem | StreamRunRenderItem> {
const result: Array<RenderChatItem | StreamRunRenderItem> = [];
let run: StreamRunRenderItem["parts"] = [];
// Contiguous in-flight stream, plan, and reading-indicator items render under one
// assistant avatar; messages, groups, and dividers intentionally break the run.
const flush = () => {
const [first] = run;
if (first) {
result.push({ kind: "stream-run", key: `stream-run:${first.key}`, parts: run });
run = [];
}
};
for (const item of items) {
if (item.kind === "stream" || item.kind === "reading-indicator" || item.kind === "plan") {
run.push(item);
continue;
}
flush();
result.push(item);
}
flush();
return result;
}
/** Collapsed rollup of a completed turn's intermediate work (tools, commentary). */
type WorkGroupRenderItem = {
kind: "work-group";
key: string;
groups: MessageGroup[];
durationMs: number | null;
hasError: boolean;
};
type TurnRenderItem = RenderChatItem | StreamRunRenderItem;
function isTurnBoundaryGroup(item: TurnRenderItem): boolean {
if (item.kind !== "group") {
return false;
}
const role = item.role.toLowerCase();
// sessions_send projections start a new autonomous turn, same contract as
// annotateToolTurnOutcome; they are inputs, not work produced by this turn.
return (
role === "user" ||
groupStartsProjectedTurnBoundary(item) ||
(role === "assistant" && assistantGroupIsForwardedBoundary(item))
);
}
function isCollapsibleWorkGroup(item: TurnRenderItem): item is MessageGroup {
if (item.kind !== "group" || item.isStreaming) {
return false;
}
const role = item.role.toLowerCase();
return role === "tool" || (role === "assistant" && !assistantGroupIsForwardedBoundary(item));
}
// Attachment/canvas/media-only replies carry no text but are still the turn's
// visible outcome; they must never fold into the work rollup. Normalized
// content passes unknown block types through (e.g. raw image blocks), so
// anything that is not a tool block counts as visible reply content.
function assistantGroupHasVisibleReplyContent(group: MessageGroup): boolean {
return group.messages.some(({ message }) => {
if (extractTextCached(message)?.trim()) {
return true;
}
const content = safeNormalizeMessage(message)?.content ?? [];
return content.some((block) => {
if (block.type === "text") {
return Boolean(block.text?.trim());
}
return !isToolCallContentType(block.type) && !isToolResultContentType(block.type);
});
});
}
// History carries no final-vs-commentary marker (commentary exists only as
// live stream segments), so the last assistant group with visible content
// stands in for the final reply. Turns whose last content is commentary
// merely collapse less; the visible reply is never folded away.
function isFinalReplyGroup(item: TurnRenderItem): boolean {
return (
isCollapsibleWorkGroup(item) &&
item.role.toLowerCase() === "assistant" &&
assistantGroupHasVisibleReplyContent(item)
);
}
function workGroupHasError(groups: MessageGroup[]): boolean {
return groups.some(
(group) =>
group.role.toLowerCase() === "tool" &&
group.turnSucceeded !== true &&
group.messages.some((entry) =>
extractToolCardsCached(entry.message, entry.key).some(isToolCardError),
),
);
}
/**
* Once a turn is done, its intermediate work (tool groups and assistant
* commentary before the final reply) collapses behind one "Worked for X"
* disclosure so the thread reads final-output-first. Live turns stay fully
* expanded; the collapse itself is the done signal.
*/
export function collapseCompletedTurnWork(
items: TurnRenderItem[],
opts: { sessionKey: string; runWorking: boolean; searchActive?: boolean },
): Array<TurnRenderItem | WorkGroupRenderItem> {
const [scope, agentId, kind, sessionId, ...extraParts] = normalizeLowercaseStringOrEmpty(
opts.sessionKey,
).split(":");
const isDashboardSession =
scope === "agent" &&
Boolean(agentId) &&
kind === "dashboard" &&
Boolean(sessionId) &&
extraParts.length === 0;
// Channel sessions can also be opened in the Control UI, but their full
// transcript remains the canonical presentation on message surfaces.
if (!isDashboardSession || opts.searchActive) {
return items;
}
const turns: TurnRenderItem[][] = [];
let currentTurn: TurnRenderItem[] = [];
for (const item of items) {
if (isTurnBoundaryGroup(item) && currentTurn.length > 0) {
turns.push(currentTurn);
currentTurn = [];
}
currentTurn.push(item);
}
if (currentTurn.length > 0) {
turns.push(currentTurn);
}
const result: Array<TurnRenderItem | WorkGroupRenderItem> = [];
for (const [turnIndex, turn] of turns.entries()) {
// In-flight content (stream runs, streaming groups) marks the turn live.
// While the run works, the trailing turn also stays expanded so activity
// is watchable until the terminal rebuild collapses it.
const isLive =
(opts.runWorking && turnIndex === turns.length - 1) ||
turn.some(
(item) => item.kind === "stream-run" || (item.kind === "group" && item.isStreaming),
);
if (isLive) {
result.push(...turn);
continue;
}
let finalReplyIndex = -1;
for (let index = turn.length - 1; index >= 0; index -= 1) {
const candidate = turn[index];
if (candidate && isFinalReplyGroup(candidate)) {
finalReplyIndex = index;
break;
}
}
// Without a final reply, the tool rows are the turn's only visible result.
// Keep them exposed instead of replacing the result with an opaque rollup.
if (finalReplyIndex === -1) {
result.push(...turn);
continue;
}
const segmentEnd = finalReplyIndex - 1;
let segmentStart = segmentEnd + 1;
for (let index = segmentEnd; index >= 0; index -= 1) {
const candidate = turn[index];
if (!candidate || !isCollapsibleWorkGroup(candidate)) {
break;
}
segmentStart = index;
}
const groups = turn.slice(segmentStart, segmentEnd + 1) as MessageGroup[];
const firstGroup = groups[0];
if (!firstGroup) {
result.push(...turn);
continue;
}
const boundary = turn[0];
const startTimestamp =
boundary && boundary.kind === "group" && isTurnBoundaryGroup(boundary)
? boundary.timestamp
: firstGroup.timestamp;
const finalReply = turn[finalReplyIndex] as MessageGroup;
const endTimestamp = finalReply.timestamp;
const durationMs = endTimestamp > startTimestamp ? endTimestamp - startTimestamp : null;
result.push(...turn.slice(0, segmentStart));
result.push({
kind: "work-group",
// The final reply survives older-history prepends; the first work row does not.
key: `work:${finalReply.key}`,
groups,
durationMs,
hasError: workGroupHasError(groups),
});
result.push(...turn.slice(segmentEnd + 1));
}
return result;
}
+690
View File
@@ -0,0 +1,690 @@
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import { resolveToolUseId } from "../../../../src/chat/tool-content.js";
import { escapeRegExp } from "../../../../src/shared/regexp.js";
import type { ChatItem, NormalizedMessage, ToolCard } from "../../lib/chat/chat-types.ts";
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
import { extractTextCached } from "../../lib/chat/message-extract.ts";
import {
normalizeMessage,
stripMessageDisplayMetadataText,
} from "../../lib/chat/message-normalizer.ts";
import { normalizeRoleForGrouping } from "../../lib/chat/message-normalizer.ts";
import { extractToolCardsCached, extractToolPreview } from "../../lib/chat/tool-cards.ts";
import { fnv1aUtf16 } from "../../lib/fnv1a.ts";
import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts";
import { buildUserChatMessageContentBlocks } from "./user-message-content.ts";
export function appendCanvasBlockToAssistantMessage(
message: unknown,
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>,
rawText: string | null,
) {
const raw = message as Record<string, unknown>;
const existingContent = Array.isArray(raw.content)
? [...raw.content]
: typeof raw.content === "string"
? [{ type: "text", text: raw.content }]
: typeof raw.text === "string"
? [{ type: "text", text: raw.text }]
: [];
const alreadyHasArtifact = existingContent.some((block) => {
if (!block || typeof block !== "object") {
return false;
}
const typed = block as {
type?: unknown;
preview?: { kind?: unknown; viewId?: unknown; url?: unknown };
};
return (
typed.type === "canvas" &&
typed.preview?.kind === "canvas" &&
((preview.viewId && typed.preview.viewId === preview.viewId) ||
(preview.url && typed.preview.url === preview.url))
);
});
if (alreadyHasArtifact) {
return message;
}
return {
...raw,
content: [
...existingContent,
{
type: "canvas",
preview,
...(rawText ? { rawText } : {}),
},
],
};
}
export function safeNormalizeMessage(message: unknown): NormalizedMessage | null {
if (!asRecord(message)) {
return null;
}
try {
return normalizeMessage(message);
} catch {
return null;
}
}
export function messageMatchesSearchQuery(message: unknown, query: string): boolean {
const normalizedQuery = normalizeLowercaseStringOrEmpty(query);
if (!normalizedQuery) {
return true;
}
const text = normalizeLowercaseStringOrEmpty(extractTextCached(message));
return text.includes(normalizedQuery);
}
export function turnHasMatchingAssistant(
messages: unknown[],
sourceIndex: number,
searchQuery: string,
): boolean {
for (let index = sourceIndex + 1; index < messages.length; index += 1) {
const message = messages[index];
const normalized = safeNormalizeMessage(message);
if (!normalized) {
continue;
}
const role = normalizeRoleForGrouping(normalized.role).toLowerCase();
if (role === "user" || role === "system") {
return false;
}
if (role === "assistant" && messageMatchesSearchQuery(message, searchQuery)) {
return true;
}
}
return false;
}
type ChatMessagePreview = {
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>;
text: string | null;
timestamp: number | null;
};
export function extractChatMessagePreview(toolMessage: unknown): ChatMessagePreview | null {
if (!safeNormalizeMessage(toolMessage)) {
return null;
}
const cards = extractToolCardsCached(toolMessage, "preview");
for (let index = cards.length - 1; index >= 0; index--) {
const card = cards[index];
if (card?.preview?.kind === "canvas") {
return {
preview: card.preview,
text: card.outputText ?? null,
timestamp: rawMessageTimestamp(toolMessage),
};
}
}
const text = extractTextCached(toolMessage) ?? undefined;
const toolRecord = toolMessage as Record<string, unknown>;
const toolName =
typeof toolRecord.toolName === "string"
? toolRecord.toolName
: typeof toolRecord.tool_name === "string"
? toolRecord.tool_name
: undefined;
const preview = extractToolPreview(text, toolName);
if (preview?.kind !== "canvas") {
return null;
}
return { preview, text: text ?? null, timestamp: rawMessageTimestamp(toolMessage) };
}
export function canvasPreviewBaseIdentity(
message: unknown,
source: ChatMessagePreview,
): string | null {
const toolCallId = resolveMessageToolUseId(asRecord(message) ?? {});
const previewId = source.preview.viewId ?? source.preview.url;
return toolCallId && previewId ? JSON.stringify([toolCallId, previewId]) : null;
}
export function createCanvasAssistantMessage(
source: ChatMessagePreview,
timestamp = source.timestamp,
): unknown {
return appendCanvasBlockToAssistantMessage(
{
role: "assistant",
content: [],
...(timestamp != null ? { timestamp } : {}),
},
source.preview,
source.text,
);
}
export function transcriptPositionTimestamp(
messages: unknown[],
sourceIndex: number,
): number | null {
let previous: number | null = null;
for (let index = sourceIndex - 1; index >= 0; index -= 1) {
previous = rawMessageTimestamp(messages[index]);
if (previous != null) {
break;
}
}
let next: number | null = null;
for (let index = sourceIndex + 1; index < messages.length; index += 1) {
next = rawMessageTimestamp(messages[index]);
if (next != null) {
break;
}
}
if (previous != null && next != null) {
return previous < next ? Math.min(previous + 1, next) : next;
}
if (previous != null) {
return previous + 1;
}
return next;
}
export function findNearestAssistantMessageIndex(
items: ChatItem[],
toolTimestamp: number | null,
): number | null {
let currentTurnStart = 0;
let currentTurnEnd = items.length;
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
if (item?.kind !== "message") {
continue;
}
const normalized = safeNormalizeMessage(item.message);
if (!normalized || normalizeRoleForGrouping(normalized.role).toLowerCase() !== "user") {
continue;
}
if (
toolTimestamp != null &&
normalized.timestamp != null &&
normalized.timestamp > toolTimestamp
) {
currentTurnEnd = index;
break;
}
currentTurnStart = index + 1;
}
const assistantEntries = items
.map((item, index) => {
if (index < currentTurnStart || index >= currentTurnEnd || item.kind !== "message") {
return null;
}
const message = asRecord(item.message);
const role = typeof message?.role === "string" ? message.role.toLowerCase() : "";
if (role !== "assistant") {
return null;
}
return {
index,
timestamp: safeNormalizeMessage(item.message)?.timestamp ?? null,
};
})
.filter(Boolean) as Array<{ index: number; timestamp: number | null }>;
if (assistantEntries.length === 0) {
return null;
}
if (toolTimestamp == null) {
return assistantEntries[assistantEntries.length - 1]?.index ?? null;
}
let previous: { index: number; timestamp: number } | null = null;
let next: { index: number; timestamp: number } | null = null;
for (const entry of assistantEntries) {
if (entry.timestamp == null) {
continue;
}
if (entry.timestamp <= toolTimestamp) {
previous = { index: entry.index, timestamp: entry.timestamp };
continue;
}
next = { index: entry.index, timestamp: entry.timestamp };
break;
}
if (previous && next) {
const previousDelta = toolTimestamp - previous.timestamp;
const nextDelta = next.timestamp - toolTimestamp;
return nextDelta < previousDelta ? next.index : previous.index;
}
if (previous) {
return previous.index;
}
if (next) {
return next.index;
}
return assistantEntries[assistantEntries.length - 1]?.index ?? null;
}
export function findCanvasInsertionIndex(items: ChatItem[], toolTimestamp: number | null): number {
if (toolTimestamp == null) {
return items.length;
}
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
if (item?.kind !== "message") {
continue;
}
const normalized = safeNormalizeMessage(item.message);
if (
normalized &&
normalizeRoleForGrouping(normalized.role).toLowerCase() === "user" &&
normalized.timestamp != null &&
normalized.timestamp > toolTimestamp
) {
return index;
}
}
return items.length;
}
export function resolveMessageToolUseId(message: Record<string, unknown>): string | undefined {
for (const field of ["tool_call_id", "toolCallId", "tool_use_id", "toolUseId"] as const) {
const value = message[field];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return undefined;
}
export function resolveToolBlockId(
block: Record<string, unknown>,
message: Record<string, unknown>,
): string | undefined {
return resolveToolUseId(block) ?? resolveMessageToolUseId(message);
}
export function isPendingSendMessage(message: unknown): boolean {
return asRecord(asRecord(message)?.["__openclaw"])?.kind === "pending-send";
}
/** Every projection of one composer submit (pending queue row, locally
* materialized turn, authoritative history) shares this identity so the
* rendered bubble keeps one Lit key and never remounts mid-handoff. */
function userTurnSendIdentity(message: unknown): string | null {
const record = asRecord(message);
if (typeof record?.role !== "string" || record.role.toLowerCase() !== "user") {
return null;
}
const idempotencyKey = asRecord(record["__openclaw"])?.idempotencyKey;
if (typeof idempotencyKey !== "string" || !idempotencyKey.trim()) {
return null;
}
const base = idempotencyKey.endsWith(":user")
? idempotencyKey.slice(0, -":user".length)
: idempotencyKey;
return `send:${base}`;
}
function sourceMessageId(message: unknown): string | null {
const record = asRecord(message);
if (!record) {
return null;
}
const openclawId = asRecord(record["__openclaw"])?.id;
if (typeof openclawId === "string" && openclawId.trim()) {
return openclawId.trim();
}
const messageId = typeof record.messageId === "string" ? record.messageId.trim() : "";
if (messageId) {
return messageId;
}
const id = typeof record.id === "string" ? record.id.trim() : "";
return id || null;
}
export function persistedMessageEntryId(message: unknown): string | null {
return isPendingSendMessage(message) ? null : sourceMessageId(message);
}
function transcriptMessageSourceKey(message: unknown): string | null {
const record = asRecord(message);
if (!record) {
return null;
}
// Send identity outranks transcript ids: the same submit is re-projected with
// different id/seq metadata across the pending -> history handoff, and a key
// change there remounts the bubble (visible flicker).
const sendIdentity = userTurnSendIdentity(message);
if (sendIdentity) {
return sendIdentity;
}
const id = sourceMessageId(message);
if (id) {
return `id:${id}`;
}
const seq = asRecord(record["__openclaw"])?.seq;
const normalizedSeq =
typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : null;
return normalizedSeq == null ? null : `seq:${normalizedSeq}`;
}
const messageProjectionDigests = new WeakMap<object, string>();
function messageProjectionDigest(message: unknown): string {
if (message && typeof message === "object") {
const cached = messageProjectionDigests.get(message);
if (cached) {
return cached;
}
}
const record = asRecord(message);
const source = [
typeof record?.role === "string" ? record.role : "",
typeof record?.toolCallId === "string" ? record.toolCallId : "",
record ? extractTextCached(message) : "",
].join("\u0000");
const digest = `p${fnv1aUtf16(source).toString(36)}${source.length.toString(36)}`;
if (message && typeof message === "object") {
messageProjectionDigests.set(message, digest);
}
return digest;
}
export function buildMessageKeys(messages: unknown[], indexOffset = 0): string[] {
const sourceKeys = messages.map(transcriptMessageSourceKey);
const sourceCounts = new Map<string, number>();
for (const sourceKey of sourceKeys) {
if (sourceKey) {
sourceCounts.set(sourceKey, (sourceCounts.get(sourceKey) ?? 0) + 1);
}
}
const projectionOccurrences = new Map<string, number>();
return messages.map((message, index) => {
const sourceKey = sourceKeys[index];
const needsProjectionIdentity = sourceKey == null || (sourceCounts.get(sourceKey) ?? 0) > 1;
const projectionKey = needsProjectionIdentity
? `${sourceKey ?? "legacy"}:projection:${messageProjectionDigest(message)}`
: (sourceKey ?? "legacy");
const occurrence = projectionOccurrences.get(projectionKey) ?? 0;
projectionOccurrences.set(projectionKey, occurrence + 1);
return messageKey(message, index + indexOffset, `${projectionKey}:${occurrence}`);
});
}
function collapseDuplicateSourceKey(message: unknown): string | null {
if (isPendingSendMessage(message)) {
return null;
}
const normalized = safeNormalizeMessage(message);
if (!normalized) {
return null;
}
const role = normalizeRoleForGrouping(normalized.role).toLowerCase();
if (role !== "assistant") {
return null;
}
const id = sourceMessageId(message);
return id ? `${role}:${id}` : null;
}
function prefersNativeChatSurface(message: unknown): boolean {
const normalized = safeNormalizeMessage(message);
if (!normalized) {
return false;
}
const role = normalizeRoleForGrouping(normalized.role).toLowerCase();
return (role === "user" || role === "assistant") && !(normalized.senderLabel ?? "").trim();
}
function stripSenderLabelPrefix(text: string, senderLabel: string): string {
const label = senderLabel.trim();
if (!label) {
return text;
}
return text.replace(new RegExp(`^${escapeRegExp(label)}(?::||-|—)?[ \\t]+`), "");
}
function textOnlyMessageParts(message: unknown) {
const normalized = safeNormalizeMessage(message);
if (!normalized || normalized.content.length === 0) {
return null;
}
const textParts: string[] = [];
for (const block of normalized.content) {
if (block.type !== "text" || typeof block.text !== "string") {
return null;
}
textParts.push(block.text);
}
return {
role: normalizeRoleForGrouping(normalized.role).toLowerCase(),
senderLabel: (normalized.senderLabel ?? "").trim(),
text: textParts.join("\n"),
};
}
function sourceDuplicateDisplayParts(message: unknown) {
const parts = textOnlyMessageParts(message);
return parts?.role === "assistant" && parts.text.trim() ? parts : null;
}
function isSameSourceRelayNativeDuplicate(previousMessage: unknown, nextMessage: unknown): boolean {
const previous = sourceDuplicateDisplayParts(previousMessage);
const next = sourceDuplicateDisplayParts(nextMessage);
if (!previous || !next || previous.role !== next.role) {
return false;
}
if (Boolean(previous.senderLabel) === Boolean(next.senderLabel)) {
return false;
}
const labeled = previous.senderLabel ? previous : next;
const native = previous.senderLabel ? next : previous;
return (
labeled.text === native.text ||
stripSenderLabelPrefix(labeled.text, labeled.senderLabel) === native.text
);
}
function collapseDuplicateDisplaySignature(message: unknown): string | null {
if (isPendingSendMessage(message)) {
return null;
}
const parts = textOnlyMessageParts(message);
if (!parts || !parts.role || parts.role === "tool") {
return null;
}
const { role } = parts;
const text = parts.text.trim().replace(/\s+/g, " ");
if (!text) {
return null;
}
const senderLabel = role === "user" || role === "assistant" ? parts.senderLabel : "";
return `${role}:${senderLabel}:${text}`;
}
export function collapseSequentialDuplicateMessages(items: ChatItem[]): ChatItem[] {
const collapsed: ChatItem[] = [];
let previousSignature: string | null = null;
let previousSourceKey: string | null = null;
for (const item of items) {
if (item.kind !== "message") {
collapsed.push(item);
previousSignature = null;
previousSourceKey = null;
continue;
}
const signature = collapseDuplicateDisplaySignature(item.message);
const sourceKey = collapseDuplicateSourceKey(item.message);
const previous = collapsed[collapsed.length - 1];
if (
sourceKey &&
previousSourceKey === sourceKey &&
previous?.kind === "message" &&
isSameSourceRelayNativeDuplicate(previous.message, item.message)
) {
if (!prefersNativeChatSurface(previous.message) && prefersNativeChatSurface(item.message)) {
collapsed[collapsed.length - 1] = item;
previousSignature = signature;
}
continue;
}
if (
signature &&
previousSignature === signature &&
previous?.kind === "message" &&
!(sourceKey && previousSourceKey && sourceKey !== previousSourceKey)
) {
previous.duplicateCount = (previous.duplicateCount ?? 1) + 1;
continue;
}
collapsed.push(item);
previousSignature = signature;
previousSourceKey = sourceKey;
}
return collapsed;
}
export function hasRenderableNormalizedMessage(message: unknown): boolean {
const normalized = safeNormalizeMessage(message);
if (!normalized) {
return false;
}
const role = normalizeRoleForGrouping(normalized.role);
const hasVisibleSenderLabel = role === "assistant" && Boolean(normalized.senderLabel?.trim());
return normalized.content.length > 0 || Boolean(normalized.replyTarget) || hasVisibleSenderLabel;
}
export function sanitizeStreamText(text: string): string {
const stripped = stripMessageDisplayMetadataText(text);
return stripped.trim().length > 0 ? stripped : "";
}
export function queuedSendThreadMessage(item: ChatQueueItem): Record<string, unknown> | null {
const content = buildUserChatMessageContentBlocks(item.text, item.attachments);
if (content.length === 0) {
return null;
}
return {
role: "user",
content,
timestamp: item.createdAt,
__openclaw: {
kind: "pending-send",
id: item.id,
state: item.sendState,
...(item.sender?.id ? { senderId: item.sender.id } : {}),
...(item.sender?.name ? { senderName: item.sender.name } : {}),
...(item.sender?.username ? { senderUsername: item.sender.username } : {}),
...(item.sender?.profileAvatarUrl
? { senderProfileAvatarUrl: item.sender.profileAvatarUrl }
: {}),
},
};
}
export function rawMessageTimestamp(message: unknown): number | null {
const timestamp = asRecord(message)?.timestamp;
return typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : null;
}
export function chatItemTimestamp(item: ChatItem): number | null {
switch (item.kind) {
case "message":
return rawMessageTimestamp(item.message);
case "divider":
case "notice":
return item.timestamp;
case "stream":
return item.startedAt;
case "question":
return item.startedAt;
case "reading-indicator":
case "plan":
return null;
}
return null;
}
export function timestampAfterVisibleItems(items: ChatItem[], desiredTimestamp: number): number {
const latestTimestamp = items.reduce<number | null>((latest, item) => {
const timestamp = chatItemTimestamp(item);
if (timestamp == null) {
return latest;
}
return latest == null || timestamp > latest ? timestamp : latest;
}, null);
return latestTimestamp != null && desiredTimestamp <= latestTimestamp
? latestTimestamp + 1
: desiredTimestamp;
}
export function sortChatItemsByVisibleTime(
items: ChatItem[],
toolStreamPredecessors: ReadonlyMap<string, string>,
): ChatItem[] {
const timestampsByKey = new Map<string, number>();
for (const item of items) {
const timestamp = chatItemTimestamp(item);
if (timestamp != null) {
timestampsByKey.set(item.key, timestamp);
}
}
return items
.map((item, index) => {
const timestamp = chatItemTimestamp(item);
const predecessorKey = toolStreamPredecessors.get(item.key);
const predecessorTimestamp = predecessorKey ? timestampsByKey.get(predecessorKey) : null;
return {
item,
index,
predecessorKey,
timestamp:
timestamp != null && predecessorTimestamp != null
? Math.max(timestamp, predecessorTimestamp)
: timestamp,
};
})
.toSorted((a, b) => {
if (a.timestamp == null && b.timestamp == null) {
return a.index - b.index;
}
if (a.timestamp == null) {
return 1;
}
if (b.timestamp == null) {
return -1;
}
if (a.timestamp !== b.timestamp) {
return a.timestamp - b.timestamp;
}
if (a.predecessorKey === b.item.key) {
return 1;
}
if (b.predecessorKey === a.item.key) {
return -1;
}
return a.index - b.index;
})
.map(({ item }) => item);
}
export function messageKey(message: unknown, index: number, transcriptKey?: string): string {
const m = asRecord(message) ?? {};
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
const role = typeof m.role === "string" ? m.role : "unknown";
const id = typeof m.id === "string" && m.id ? m.id : null;
const messageId = typeof m.messageId === "string" && m.messageId ? m.messageId : null;
const identity = id ?? messageId;
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
if (toolCallId) {
const suffix =
transcriptKey ?? identity ?? (timestamp == null ? index : `${timestamp}:${index}`);
return `tool:${role}:${toolCallId}:${suffix}`;
}
if (transcriptKey) {
return `msg:${transcriptKey}`;
}
if (identity) {
return `msg:${identity}`;
}
return timestamp == null ? `msg:${role}:${index}` : `msg:${role}:${timestamp}:${index}`;
}
File diff suppressed because it is too large Load Diff