Files
kandev/apps/web/hooks/use-processed-messages.ts
T

296 lines
9.2 KiB
TypeScript

import { useMemo } from "react";
import type { Message, ClarificationRequestMetadata, MessageType } from "@/lib/types/http";
import type { ToolCallMetadata } from "@/components/task/chat/types";
const ACTIVITY_MESSAGE_TYPES: Set<MessageType> = new Set([
"thinking",
"tool_call",
"tool_edit",
"tool_read",
"tool_execute",
"tool_search",
]);
const VISIBLE_MESSAGE_TYPES: Set<string> = new Set([
"message",
"content",
"tool_call",
"tool_read",
"tool_edit",
"tool_execute",
"tool_search",
"progress",
"status",
"error",
"thinking",
"todo",
"script_execution",
"agent_plan",
]);
function isVisibleMessageType(type: MessageType | undefined): boolean {
return !type || VISIBLE_MESSAGE_TYPES.has(type);
}
function isPermissionVisible(message: Message, toolCallIds: Set<string>): boolean {
const metadata = message.metadata as { tool_call_id?: string; status?: string } | undefined;
const toolCallId = metadata?.tool_call_id;
if (toolCallId && toolCallIds.has(toolCallId)) return false;
const status = metadata?.status;
if (status === "approved" || status === "denied" || status === "cancelled") return false;
return true;
}
export type TurnGroup = {
type: "turn_group";
id: string;
turnId: string | null;
messages: Message[];
};
export type RenderItem = { type: "message"; message: Message } | TurnGroup;
function buildToolCallIds(messages: Message[]): Set<string> {
const set = new Set<string>();
for (const message of messages) {
if (message.type === "tool_call") {
const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id;
if (toolCallId) set.add(toolCallId);
}
}
return set;
}
function buildPermissionsByToolCallId(messages: Message[]): Map<string, Message> {
const map = new Map<string, Message>();
for (const message of messages) {
if (message.type === "permission_request") {
const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id;
if (toolCallId) map.set(toolCallId, message);
}
}
return map;
}
function buildChildrenByParentToolCallId(messages: Message[]): Map<string, Message[]> {
const map = new Map<string, Message[]>();
for (const message of messages) {
const metadata = message.metadata as ToolCallMetadata | undefined;
const parentId = metadata?.parent_tool_call_id;
if (parentId) {
const children = map.get(parentId) || [];
children.push(message);
map.set(parentId, children);
}
}
return map;
}
function buildSubagentChildIds(childrenByParentToolCallId: Map<string, Message[]>): Set<string> {
const set = new Set<string>();
for (const children of childrenByParentToolCallId.values()) {
for (const child of children) set.add(child.id);
}
return set;
}
function findPendingClarification(messages: Message[]): Message | null {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.type === "clarification_request") {
const metadata = message.metadata as ClarificationRequestMetadata | undefined;
if (!metadata?.status || metadata.status === "pending") return message;
}
}
return null;
}
function isRecoveryMessage(message: Message): boolean {
const meta = message.metadata as Record<string, unknown> | undefined;
return meta?.recovery_actions === true;
}
/** Hide recovery messages that have been superseded by later conversation activity
* (user/agent messages prove the session recovered) or by a newer recovery message. */
function deduplicateRecoveryMessages(messages: Message[]): Message[] {
let lastRecoveryIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (isRecoveryMessage(messages[i])) {
lastRecoveryIdx = i;
break;
}
}
if (lastRecoveryIdx === -1) return messages;
const hasLaterActivity = messages
.slice(lastRecoveryIdx + 1)
.some((m) => m.type === "message" || m.type === "content");
return messages.filter((msg, i) => {
if (!isRecoveryMessage(msg)) return true;
if (hasLaterActivity) return false;
return i === lastRecoveryIdx;
});
}
function filterVisibleMessages(
messages: Message[],
toolCallIds: Set<string>,
subagentChildIds: Set<string>,
): Message[] {
const filtered = messages.filter((message) => {
if (subagentChildIds.has(message.id)) return false;
if (message.type === "clarification_request") {
const metadata = message.metadata as ClarificationRequestMetadata | undefined;
return !(!metadata?.status || metadata.status === "pending");
}
if (
message.type === "status" &&
(message.content === "New session started" || message.content === "Session resumed")
)
return false;
if (isVisibleMessageType(message.type)) return true;
if (message.type === "permission_request") return isPermissionVisible(message, toolCallIds);
return false;
});
return deduplicateRecoveryMessages(filtered);
}
function groupActivityMessages(allMessages: Message[]): RenderItem[] {
const items: RenderItem[] = [];
let currentGroup: Message[] = [];
let currentTurnId: string | null = null;
const flushGroup = () => {
if (currentGroup.length >= 2) {
items.push({
type: "turn_group",
id: `turn-group-${currentGroup[0].id}`,
turnId: currentGroup[0].turn_id ?? null,
messages: currentGroup,
});
} else if (currentGroup.length === 1) {
items.push({ type: "message", message: currentGroup[0] });
}
currentGroup = [];
currentTurnId = null;
};
for (const message of allMessages) {
const isActivity = message.type && ACTIVITY_MESSAGE_TYPES.has(message.type);
const messageTurnId = message.turn_id ?? null;
if (isActivity && messageTurnId) {
if (currentGroup.length > 0 && currentTurnId === messageTurnId) {
currentGroup.push(message);
} else {
flushGroup();
currentGroup = [message];
currentTurnId = messageTurnId;
}
} else {
flushGroup();
items.push({ type: "message", message });
}
}
flushGroup();
return items;
}
export function useProcessedMessages(
messages: Message[],
taskId: string | null,
resolvedSessionId: string | null,
taskDescription: string | null,
) {
const toolCallIds = useMemo(() => buildToolCallIds(messages), [messages]);
const permissionsByToolCallId = useMemo(() => buildPermissionsByToolCallId(messages), [messages]);
const childrenByParentToolCallId = useMemo(
() => buildChildrenByParentToolCallId(messages),
[messages],
);
const subagentChildIds = useMemo(
() => buildSubagentChildIds(childrenByParentToolCallId),
[childrenByParentToolCallId],
);
const pendingClarification = useMemo(() => findPendingClarification(messages), [messages]);
const visibleMessages = useMemo(
() => filterVisibleMessages(messages, toolCallIds, subagentChildIds),
[messages, toolCallIds, subagentChildIds],
);
const taskDescriptionMessage: Message | null = useMemo(() => {
return taskDescription && visibleMessages.length === 0
? {
id: "task-description",
task_id: taskId ?? "",
session_id: resolvedSessionId ?? "",
author_type: "user",
content: taskDescription,
type: "message",
created_at: "",
}
: null;
}, [taskDescription, visibleMessages.length, taskId, resolvedSessionId]);
const allMessages = useMemo(() => {
return taskDescriptionMessage ? [taskDescriptionMessage, ...visibleMessages] : visibleMessages;
}, [taskDescriptionMessage, visibleMessages]);
const todoItems = useMemo(() => {
const latestTodos = [...visibleMessages]
.reverse()
.find(
(message) => message.type === "todo" || (message.metadata as { todos?: unknown })?.todos,
);
return (
(
latestTodos?.metadata as
| { todos?: Array<{ text: string; done?: boolean } | string> }
| undefined
)?.todos
?.map((item) => (typeof item === "string" ? { text: item, done: false } : item))
.filter((item) => item.text) ?? []
);
}, [visibleMessages]);
const agentMessageCount = useMemo(() => {
return visibleMessages.filter((c) => c.author_type !== "user").length;
}, [visibleMessages]);
// Separate footer action messages (e.g. missing branch guidance with archive/delete
// buttons) from regular messages so they render after the env prep error status.
const { regularMessages, footerActionMessages } = useMemo(() => {
const regular: Message[] = [];
const footer: Message[] = [];
for (const msg of allMessages) {
const meta = msg.metadata as Record<string, unknown> | undefined;
if (Array.isArray(meta?.actions) && !meta?.recovery_actions) {
footer.push(msg);
} else {
regular.push(msg);
}
}
return { regularMessages: regular, footerActionMessages: footer };
}, [allMessages]);
const groupedItems = useMemo<RenderItem[]>(
() => groupActivityMessages(regularMessages),
[regularMessages],
);
return {
visibleMessages,
allMessages,
groupedItems,
footerActionMessages,
toolCallIds,
permissionsByToolCallId,
childrenByParentToolCallId,
todoItems,
agentMessageCount,
pendingClarification,
};
}