Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
"lodash": "^4.17.21",
"lowdb": "^7.0.1",
"lowlight": "^3.3.0",
"lucide-react": "^0.563.0",
"monaco-editor": "^0.55.1",
"nanoid": "^5.1.6",
"node-addon-api": "^7.1.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type {
ToolResultPart,
UIMessage,
} from "@superset/durable-session/react";
import type { ExploringGroupItem } from "@superset/ui/ai-elements/exploring-group";
import { ExploringGroup } from "@superset/ui/ai-elements/exploring-group";
import {
Message,
MessageAction,
Expand All @@ -17,6 +19,8 @@ import {
ReasoningTrigger,
} from "@superset/ui/ai-elements/reasoning";
import { HiMiniArrowPath, HiMiniClipboard } from "react-icons/hi2";
import { safeParseJson } from "../../utils/map-tool-state";
import { getToolMeta, getToolStatus } from "../../utils/tool-registry";
import { ToolCallBlock } from "../ToolCallBlock";

interface ChatMessageItemProps {
Expand All @@ -25,6 +29,9 @@ interface ChatMessageItemProps {
onDeny?: (approvalId: string) => void;
}

const EXPLORING_TOOLS = new Set(["Read", "Grep", "Glob"]);
const MIN_GROUP_SIZE = 3;

function getPartKey(part: MessagePart, index: number): string {
switch (part.type) {
case "tool-call":
Expand All @@ -36,6 +43,85 @@ function getPartKey(part: MessagePart, index: number): string {
}
}

type RenderedItem =
| { kind: "part"; part: MessagePart; index: number }
| {
kind: "exploring-group";
items: Array<{ part: ToolCallPart; index: number }>;
};

/** Group consecutive exploring tool-calls into ExploringGroup when 3+. */
function groupParts(parts: MessagePart[]): RenderedItem[] {
const result: RenderedItem[] = [];
let i = 0;

while (i < parts.length) {
const part = parts[i];

if (part.type === "tool-call" && EXPLORING_TOOLS.has(part.name)) {
// Collect consecutive exploring tool-calls
const run: Array<{ part: ToolCallPart; index: number }> = [];
while (i < parts.length) {
const current = parts[i];
if (current.type === "tool-call" && EXPLORING_TOOLS.has(current.name)) {
run.push({ part: current as ToolCallPart, index: i });
i++;
} else if (current.type === "tool-result") {
// Skip tool-result parts inline (they're handled via toolResults map)
i++;
} else {
break;
}
}

if (run.length >= MIN_GROUP_SIZE) {
result.push({ kind: "exploring-group", items: run });
} else {
for (const item of run) {
result.push({ kind: "part", part: item.part, index: item.index });
}
}
} else {
result.push({ kind: "part", part, index: i });
i++;
}
}

return result;
}

function buildGroupItem({
tc,
toolResult,
}: {
tc: ToolCallPart;
toolResult?: ToolResultPart;
}): ExploringGroupItem {
const meta = getToolMeta(tc.name);
const args = safeParseJson(tc.arguments);
const resultContent = toolResult?.content
? safeParseJson(toolResult.content)
: {};
const state = toolResult
? toolResult.error
? "output-error"
: "output-available"
: (tc.state ?? "input-available");
const { isPending, isError } = getToolStatus(
state,
Boolean(toolResult),
Boolean(toolResult?.error),
);

return {
icon: meta.icon,
title: meta.title(args, resultContent, state),
subtitle: meta.subtitle?.(args, resultContent, state),
isPending,
isError,
};
}

export function ChatMessageItem({
message,
onApprove,
Expand All @@ -52,11 +138,59 @@ export function ChatMessageItem({
(p) => p.type === "text" && p.content,
);

const grouped = groupParts(message.parts);

// User messages: render as 1code-style bubble
if (message.role === "user") {
const textContent = message.parts
.filter((p) => p.type === "text" && p.content)
.map((p) => (p as { content: string }).content)
.join("\n");

return (
<Message from="user">
<MessageContent>
{textContent && (
<div className="relative max-h-[100px] overflow-hidden rounded-xl border bg-input px-3 py-2 text-sm whitespace-pre-wrap">
{textContent}
</div>
)}
</MessageContent>
</Message>
);
}

// Assistant messages: render with tool grouping + text
return (
<Message from={message.role}>
<MessageContent>
{message.parts.map((part, i) => {
const key = getPartKey(part, i);
{grouped.map((item) => {
if (item.kind === "exploring-group") {
const groupKey = item.items.map((i) => i.part.id).join("-");
const isStreaming = item.items.some(
(i) =>
!toolResults.has(i.part.id) &&
(i.part.state === "input-streaming" ||
i.part.state === "awaiting-input"),
);
const groupItems = item.items.map((i) =>
buildGroupItem({
tc: i.part,
toolResult: toolResults.get(i.part.id),
}),
);
return (
<ExploringGroup
isStreaming={isStreaming}
items={groupItems}
key={groupKey}
/>
);
}

const { part, index } = item;
const key = getPartKey(part, index);

switch (part.type) {
case "thinking":
return (
Expand Down Expand Up @@ -88,7 +222,7 @@ export function ChatMessageItem({
}
})}
</MessageContent>
{message.role === "assistant" && hasTextContent && (
{hasTextContent && (
<MessageActions>
<MessageAction tooltip="Copy">
<HiMiniClipboard className="size-3.5" />
Expand Down
Loading
Loading