Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,24 @@ import {
} from "@/components/ai-elements/conversation";
import { Message, MessageContent } from "@/components/ai-elements/message";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { FileUIPart, ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { FileUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { TOOL_PART_PREFIX } from "../JobStatsBar/constants";
import { TurnStatsBar } from "../JobStatsBar/TurnStatsBar";
import { parseSpecialMarkers } from "./helpers";
import {
buildRenderSegments,
getTurnMessages,
type MessagePart,
type RenderSegment,
parseSpecialMarkers,
splitReasoningAndResponse,
} from "./helpers";
import { AssistantMessageActions } from "./components/AssistantMessageActions";
import { CollapsedToolGroup } from "./components/CollapsedToolGroup";
import { MessageAttachments } from "./components/MessageAttachments";
import { MessagePartRenderer } from "./components/MessagePartRenderer";
import { ReasoningCollapse } from "./components/ReasoningCollapse";
import { ThinkingIndicator } from "./components/ThinkingIndicator";

type MessagePart = UIMessage<unknown, UIDataTypes, UITools>["parts"][number];

interface Props {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
status: string;
Expand All @@ -27,113 +32,6 @@ interface Props {
sessionID?: string | null;
}

function isCompletedToolPart(part: MessagePart): part is ToolUIPart {
return (
part.type.startsWith("tool-") &&
"state" in part &&
(part.state === "output-available" || part.state === "output-error")
);
}

type RenderSegment =
| { kind: "part"; part: MessagePart; index: number }
| { kind: "collapsed-group"; parts: ToolUIPart[] };

// Tool types that have custom renderers and should NOT be collapsed
const CUSTOM_TOOL_TYPES = new Set([
"tool-find_block",
"tool-find_agent",
"tool-find_library_agent",
"tool-search_docs",
"tool-get_doc_page",
"tool-run_block",
"tool-run_mcp_tool",
"tool-run_agent",
"tool-schedule_agent",
"tool-create_agent",
"tool-edit_agent",
"tool-view_agent_output",
"tool-search_feature_requests",
"tool-create_feature_request",
]);

/**
* Groups consecutive completed generic tool parts into collapsed segments.
* Non-generic tools (those with custom renderers) and active/streaming tools
* are left as individual parts.
*/
function buildRenderSegments(
parts: MessagePart[],
baseIndex = 0,
): RenderSegment[] {
const segments: RenderSegment[] = [];
let pendingGroup: Array<{ part: ToolUIPart; index: number }> | null = null;

function flushGroup() {
if (!pendingGroup) return;
if (pendingGroup.length >= 2) {
segments.push({
kind: "collapsed-group",
parts: pendingGroup.map((p) => p.part),
});
} else {
for (const p of pendingGroup) {
segments.push({ kind: "part", part: p.part, index: p.index });
}
}
pendingGroup = null;
}

parts.forEach((part, i) => {
const absoluteIndex = baseIndex + i;
const isGenericCompletedTool =
isCompletedToolPart(part) && !CUSTOM_TOOL_TYPES.has(part.type);

if (isGenericCompletedTool) {
if (!pendingGroup) pendingGroup = [];
pendingGroup.push({ part: part as ToolUIPart, index: absoluteIndex });
} else {
flushGroup();
segments.push({ kind: "part", part, index: absoluteIndex });
}
});

flushGroup();
return segments;
}

/**
* For finalized assistant messages, split parts into "reasoning" (intermediate
* text + tools before the final response) and "response" (final text after the
* last tool). If there are no tools, everything is response.
*/
function splitReasoningAndResponse(parts: MessagePart[]): {
reasoning: MessagePart[];
response: MessagePart[];
} {
const lastToolIndex = parts.findLastIndex((p) => p.type.startsWith("tool-"));

// No tools → everything is response
if (lastToolIndex === -1) {
return { reasoning: [], response: parts };
}

// Check if there's any text after the last tool
const hasResponseAfterTools = parts
.slice(lastToolIndex + 1)
.some((p) => p.type === "text");

if (!hasResponseAfterTools) {
// No final text response → don't collapse anything
return { reasoning: [], response: parts };
}

return {
reasoning: parts.slice(0, lastToolIndex + 1),
response: parts.slice(lastToolIndex + 1),
};
}

function renderSegments(
segments: RenderSegment[],
messageID: string,
Expand All @@ -153,23 +51,6 @@ function renderSegments(
});
}

/** Collect all messages belonging to a turn: the user message + every
* assistant message up to (but not including) the next user message. */
function getTurnMessages(
messages: UIMessage<unknown, UIDataTypes, UITools>[],
lastAssistantIndex: number,
): UIMessage<unknown, UIDataTypes, UITools>[] {
const userIndex = messages.findLastIndex(
(m, i) => i < lastAssistantIndex && m.role === "user",
);
const nextUserIndex = messages.findIndex(
(m, i) => i > lastAssistantIndex && m.role === "user",
);
const start = userIndex >= 0 ? userIndex : lastAssistantIndex;
const end = nextUserIndex >= 0 ? nextUserIndex : messages.length;
return messages.slice(start, end);
}

export function ChatMessagesContainer({
messages,
status,
Expand Down Expand Up @@ -258,6 +139,8 @@ export function ChatMessagesContainer({
: { reasoning: [] as MessagePart[], response: message.parts };
const hasReasoning = reasoning.length > 0;

// Note: when interactive tools are pinned from reasoning into response,
// this index approximates their position (used only for React keys).
const responseStartIndex = message.parts.length - response.length;
const responseSegments =
message.role === "assistant"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,170 @@
import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";

export type MessagePart = UIMessage<
unknown,
UIDataTypes,
UITools
>["parts"][number];

export type RenderSegment =
| { kind: "part"; part: MessagePart; index: number }
| { kind: "collapsed-group"; parts: ToolUIPart[] };

const CUSTOM_TOOL_TYPES = new Set([
"tool-find_block",
"tool-find_agent",
"tool-find_library_agent",
"tool-search_docs",
"tool-get_doc_page",
"tool-run_block",
"tool-run_mcp_tool",
"tool-run_agent",
"tool-schedule_agent",
"tool-create_agent",
"tool-edit_agent",
"tool-view_agent_output",
"tool-search_feature_requests",
"tool-create_feature_request",
]);

const INTERACTIVE_RESPONSE_TYPES: ReadonlySet<string> = new Set([
ResponseType.setup_requirements,
ResponseType.agent_details,
ResponseType.block_details,
ResponseType.need_login,
ResponseType.input_validation_error,
ResponseType.clarification_needed,
ResponseType.suggested_goal,
ResponseType.agent_preview,
ResponseType.agent_saved,
]);

export function isCompletedToolPart(part: MessagePart): part is ToolUIPart {
return (
part.type.startsWith("tool-") &&
"state" in part &&
(part.state === "output-available" || part.state === "output-error")
);
}

export function isInteractiveToolPart(part: MessagePart): boolean {
if (!part.type.startsWith("tool-")) return false;
if (!("state" in part) || part.state !== "output-available") return false;

let output = (part as ToolUIPart).output;
if (!output) return false;

if (typeof output === "string") {
try {
output = JSON.parse(output);
} catch {
return false;
}
}

if (typeof output !== "object") return false;

const responseType = (output as Record<string, unknown>).type;
Comment thread
0ubbe marked this conversation as resolved.
return (
typeof responseType === "string" &&
INTERACTIVE_RESPONSE_TYPES.has(responseType)
);
}

export function buildRenderSegments(
parts: MessagePart[],
baseIndex = 0,
): RenderSegment[] {
const segments: RenderSegment[] = [];
let pendingGroup: Array<{ part: ToolUIPart; index: number }> | null = null;

function flushGroup() {
if (!pendingGroup) return;
if (pendingGroup.length >= 2) {
segments.push({
kind: "collapsed-group",
parts: pendingGroup.map((p) => p.part),
});
} else {
for (const p of pendingGroup) {
segments.push({ kind: "part", part: p.part, index: p.index });
}
}
pendingGroup = null;
}

parts.forEach((part, i) => {
const absoluteIndex = baseIndex + i;
const isGenericCompletedTool =
isCompletedToolPart(part) && !CUSTOM_TOOL_TYPES.has(part.type);

if (isGenericCompletedTool) {
if (!pendingGroup) pendingGroup = [];
pendingGroup.push({ part: part as ToolUIPart, index: absoluteIndex });
} else {
flushGroup();
segments.push({ kind: "part", part, index: absoluteIndex });
}
});

flushGroup();
return segments;
}

export function splitReasoningAndResponse(parts: MessagePart[]): {
reasoning: MessagePart[];
response: MessagePart[];
} {
const lastToolIndex = parts.findLastIndex((p) => p.type.startsWith("tool-"));

if (lastToolIndex === -1) {
return { reasoning: [], response: parts };
}

const hasResponseAfterTools = parts
.slice(lastToolIndex + 1)
.some((p) => p.type === "text");

if (!hasResponseAfterTools) {
return { reasoning: [], response: parts };
}

const rawReasoning = parts.slice(0, lastToolIndex + 1);
const rawResponse = parts.slice(lastToolIndex + 1);

const reasoning: MessagePart[] = [];
const pinnedParts: MessagePart[] = [];

for (const part of rawReasoning) {
if (isInteractiveToolPart(part)) {
pinnedParts.push(part);
} else {
reasoning.push(part);
}
}

return {
reasoning,
response: [...pinnedParts, ...rawResponse],
};
Comment thread
0ubbe marked this conversation as resolved.
}

export function getTurnMessages(
messages: UIMessage<unknown, UIDataTypes, UITools>[],
lastAssistantIndex: number,
): UIMessage<unknown, UIDataTypes, UITools>[] {
const userIndex = messages.findLastIndex(
(m, i) => i < lastAssistantIndex && m.role === "user",
);
const nextUserIndex = messages.findIndex(
(m, i) => i > lastAssistantIndex && m.role === "user",
);
const start = userIndex >= 0 ? userIndex : lastAssistantIndex;
const end = nextUserIndex >= 0 ? nextUserIndex : messages.length;
return messages.slice(start, end);
}

// Special message prefixes for text-based markers (set by backend).
// The hex suffix makes it virtually impossible for an LLM to accidentally
Expand Down
Loading
Loading