From 359f5f3d4842168d3394c6c2882eb33e00e00763 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 00:52:14 -0800 Subject: [PATCH 01/11] Add bash tool and diff tools --- .../ToolCallBlock/ToolCallBlock.tsx | 199 +++++++++++++-- .../src/components/ai-elements/bash-tool.tsx | 223 +++++++++++++++++ .../components/ai-elements/file-diff-tool.tsx | 227 ++++++++++++++++++ 3 files changed, 633 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/components/ai-elements/bash-tool.tsx create mode 100644 packages/ui/src/components/ai-elements/file-diff-tool.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx index 1185e52450a..335318b9e74 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx @@ -2,6 +2,7 @@ import type { ToolCallPart, ToolResultPart, } from "@superset/durable-session/react"; +import { BashTool } from "@superset/ui/ai-elements/bash-tool"; import { Confirmation, ConfirmationAction, @@ -9,6 +10,7 @@ import { ConfirmationRequest, ConfirmationTitle, } from "@superset/ui/ai-elements/confirmation"; +import { FileDiffTool } from "@superset/ui/ai-elements/file-diff-tool"; import { Tool, ToolContent, @@ -16,7 +18,11 @@ import { ToolInput, ToolOutput, } from "@superset/ui/ai-elements/tool"; -import { mapApproval, mapToolCallState } from "../../utils/map-tool-state"; +import { + mapApproval, + mapToolCallState, + safeParseJson, +} from "../../utils/map-tool-state"; interface ToolCallBlockProps { toolCallPart: ToolCallPart; @@ -25,6 +31,133 @@ interface ToolCallBlockProps { onDeny?: (approvalId: string) => void; } +type SpecializedToolState = + | "input-streaming" + | "input-available" + | "output-available" + | "output-error"; + +/** + * Map the broader ToolDisplayState to the 4-state subset + * used by BashTool and FileDiffTool. + */ +function toSpecializedState( + tc: ToolCallPart, + result?: ToolResultPart, +): SpecializedToolState { + if (result) { + return result.error ? "output-error" : "output-available"; + } + switch (tc.state) { + case "input-streaming": + case "awaiting-input": + return "input-streaming"; + case "approval-requested": + case "approval-responded": + return tc.output != null ? "output-available" : "input-available"; + default: + return "input-available"; + } +} + +function BashToolBlock({ + toolCallPart, + toolResultPart, +}: { + toolCallPart: ToolCallPart; + toolResultPart?: ToolResultPart; +}) { + const state = toSpecializedState(toolCallPart, toolResultPart); + const args = safeParseJson(toolCallPart.arguments); + const command = typeof args.command === "string" ? args.command : undefined; + + // Parse result content for stdout/stderr + const resultContent = toolResultPart?.content + ? safeParseJson(toolResultPart.content) + : {}; + const stdout = + typeof resultContent.stdout === "string" + ? resultContent.stdout + : typeof toolResultPart?.content === "string" && + !toolResultPart.content.startsWith("{") + ? toolResultPart.content + : undefined; + const stderr = + typeof resultContent.stderr === "string" ? resultContent.stderr : undefined; + const exitCode = + typeof resultContent.exit_code === "number" + ? resultContent.exit_code + : undefined; + + return ( + + ); +} + +function FileDiffToolBlock({ + toolCallPart, + toolResultPart, + isWriteMode, +}: { + toolCallPart: ToolCallPart; + toolResultPart?: ToolResultPart; + isWriteMode: boolean; +}) { + const state = toSpecializedState(toolCallPart, toolResultPart); + const args = safeParseJson(toolCallPart.arguments); + const filePath = + typeof args.file_path === "string" ? args.file_path : undefined; + + // Parse result for structured patch + const resultContent = toolResultPart?.content + ? safeParseJson(toolResultPart.content) + : {}; + const structuredPatch = Array.isArray(resultContent.structured_patch) + ? resultContent.structured_patch + : undefined; + + if (isWriteMode) { + const content = typeof args.content === "string" ? args.content : undefined; + return ( + + ); + } + + const oldString = + typeof args.old_string === "string" ? args.old_string : undefined; + const newString = + typeof args.new_string === "string" ? args.new_string : undefined; + + return ( + + ); +} + +/** Tool names that get specialized rendering. */ +const SPECIALIZED_TOOLS: Record = { + Bash: "bash", + FileEdit: "file-edit", + FileWrite: "file-write", +}; + export function ToolCallBlock({ toolCallPart, toolResultPart, @@ -32,25 +165,59 @@ export function ToolCallBlock({ onDeny, }: ToolCallBlockProps) { const state = mapToolCallState(toolCallPart, toolResultPart); - const output = toolResultPart?.content ?? toolCallPart.output; - const errorText = toolResultPart?.error; const approval = mapApproval(toolCallPart.approval); + const specialized = SPECIALIZED_TOOLS[toolCallPart.name]; + + const toolContent = (() => { + switch (specialized) { + case "bash": + return ( + + ); + case "file-edit": + return ( + + ); + case "file-write": + return ( + + ); + default: { + const output = toolResultPart?.content ?? toolCallPart.output; + const errorText = toolResultPart?.error; + return ( + + + + + {(output || errorText) && ( + + )} + + + ); + } + } + })(); return (
- - - - - {(output || errorText) && ( - - )} - - + {toolContent} {approval && ( diff --git a/packages/ui/src/components/ai-elements/bash-tool.tsx b/packages/ui/src/components/ai-elements/bash-tool.tsx new file mode 100644 index 00000000000..670e8a43528 --- /dev/null +++ b/packages/ui/src/components/ai-elements/bash-tool.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { + CheckCircleIcon, + ChevronDownIcon, + TerminalIcon, + XCircleIcon, +} from "lucide-react"; +import { useState } from "react"; +import { cn } from "../../lib/utils"; +import { Loader } from "./loader"; +import { Shimmer } from "./shimmer"; + +type BashToolState = + | "input-streaming" + | "input-available" + | "output-available" + | "output-error"; + +type BashToolProps = { + command?: string; + stdout?: string; + stderr?: string; + exitCode?: number; + state: BashToolState; + className?: string; +}; + +/** Extract a short summary of the command for display in the header. */ +function extractCommandSummary(command: string): string { + const trimmed = command.trim(); + // For piped commands, show the first segment + const firstSegment = trimmed.split(/\s*\|\s*/)[0] ?? trimmed; + // Limit to first ~60 chars + if (firstSegment.length > 60) { + return `${firstSegment.slice(0, 57)}...`; + } + return firstSegment; +} + +/** Limit text to N lines, returning whether it was truncated. */ +function limitLines( + text: string, + maxLines: number, +): { text: string; truncated: boolean } { + const lines = text.split("\n"); + if (lines.length <= maxLines) { + return { text, truncated: false }; + } + return { text: lines.slice(0, maxLines).join("\n"), truncated: true }; +} + +const MAX_COLLAPSED_LINES = 3; + +const StatusIcon = ({ + state, + exitCode, +}: { + state: BashToolState; + exitCode?: number; +}) => { + if (state === "input-streaming" || state === "input-available") { + return ; + } + if (state === "output-error" || (exitCode !== undefined && exitCode !== 0)) { + return ; + } + return ; +}; + +const HeaderText = ({ + state, + command, +}: { + state: BashToolState; + command?: string; +}) => { + if (state === "input-streaming") { + return ( + + Running command... + + ); + } + if (state === "input-available") { + return ( + Running command... + ); + } + if (!command) { + return ( + + {state === "output-error" ? "Command failed" : "Ran command"} + + ); + } + const summary = extractCommandSummary(command); + return ( + + {state === "output-error" ? "Failed:" : "Ran:"}{" "} + {summary} + + ); +}; + +export const BashTool = ({ + command, + stdout, + stderr, + exitCode, + state, + className, +}: BashToolProps) => { + const [expanded, setExpanded] = useState(false); + + const hasOutput = Boolean(stdout || stderr); + const isStreaming = state === "input-streaming"; + const isPending = state === "input-available"; + + return ( +
+ {/* Header */} + + + {/* Expandable body */} + {expanded && ( +
+ {/* Command */} + {command && ( +
+ $ +
{command}
+
+ )} + + {/* stdout */} + {stdout && ( + + )} + + {/* stderr */} + {stderr && ( + + )} +
+ )} +
+ ); +}; + +const OutputBlock = ({ + text, + exitCode, + isStreaming, +}: { + text: string; + exitCode?: number; + isStreaming: boolean; +}) => { + const [showAll, setShowAll] = useState(false); + const { text: limited, truncated } = limitLines(text, MAX_COLLAPSED_LINES); + const isError = exitCode !== undefined && exitCode !== 0; + + return ( +
+
+				{showAll ? text : limited}
+			
+ {truncated && !showAll && ( + + )} + {isStreaming && ( + + ... + + )} +
+ ); +}; diff --git a/packages/ui/src/components/ai-elements/file-diff-tool.tsx b/packages/ui/src/components/ai-elements/file-diff-tool.tsx new file mode 100644 index 00000000000..771f61c6c7a --- /dev/null +++ b/packages/ui/src/components/ai-elements/file-diff-tool.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { ChevronDownIcon, FileCode2Icon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { cn } from "../../lib/utils"; +import { Shimmer } from "./shimmer"; + +type FileDiffToolState = + | "input-streaming" + | "input-available" + | "output-available" + | "output-error"; + +type DiffLine = { type: "added" | "removed" | "context"; content: string }; + +type FileDiffToolProps = { + filePath?: string; + oldString?: string; + newString?: string; + content?: string; + isWriteMode?: boolean; + state: FileDiffToolState; + structuredPatch?: Array<{ lines: string[] }>; + className?: string; +}; + +/** Extract the filename from a full path. */ +function extractFilename(filePath: string): string { + return filePath.split("/").pop() ?? filePath; +} + +/** Parse structured patch hunks into typed diff lines. */ +function getDiffLines(hunks: Array<{ lines: string[] }>): DiffLine[] { + const result: DiffLine[] = []; + for (const hunk of hunks) { + for (const line of hunk.lines) { + if (line.startsWith("+")) { + result.push({ type: "added", content: line.slice(1) }); + } else if (line.startsWith("-")) { + result.push({ type: "removed", content: line.slice(1) }); + } else { + // Context line (starts with space or is unchanged) + result.push({ + type: "context", + content: line.startsWith(" ") ? line.slice(1) : line, + }); + } + } + } + return result; +} + +/** Build diff lines from old/new strings using a simple line-based comparison. */ +function buildSimpleDiff({ + oldString, + newString, +}: { + oldString: string; + newString: string; +}): DiffLine[] { + const oldLines = oldString.split("\n"); + const newLines = newString.split("\n"); + const result: DiffLine[] = []; + + for (const line of oldLines) { + result.push({ type: "removed", content: line }); + } + for (const line of newLines) { + result.push({ type: "added", content: line }); + } + return result; +} + +/** Count additions and removals from diff lines. */ +function calculateDiffStats(lines: DiffLine[]): { + additions: number; + removals: number; +} { + let additions = 0; + let removals = 0; + for (const line of lines) { + if (line.type === "added") additions++; + else if (line.type === "removed") removals++; + } + return { additions, removals }; +} + +const COLLAPSED_MAX_HEIGHT = 72; +const EXPANDED_MAX_HEIGHT = 200; + +export const FileDiffTool = ({ + filePath, + oldString, + newString, + content, + isWriteMode, + state, + structuredPatch, + className, +}: FileDiffToolProps) => { + const [expanded, setExpanded] = useState(false); + + const isStreaming = state === "input-streaming"; + + const diffLines = useMemo(() => { + // Use structured patch if available + if (structuredPatch?.length) { + return getDiffLines(structuredPatch); + } + // Write mode: all lines are additions + if (isWriteMode && content) { + return content + .split("\n") + .map((line): DiffLine => ({ type: "added", content: line })); + } + // Edit mode: build diff from old/new + if (oldString !== undefined && newString !== undefined) { + return buildSimpleDiff({ oldString, newString }); + } + return []; + }, [structuredPatch, isWriteMode, content, oldString, newString]); + + const stats = useMemo(() => calculateDiffStats(diffLines), [diffLines]); + const hasDiff = diffLines.length > 0; + + return ( +
+ {/* Header */} + + + {/* Diff body */} + {hasDiff && ( +
+
+ {diffLines.map((line, i) => ( +
+ + {line.type === "added" + ? "+" + : line.type === "removed" + ? "-" + : " "} + +
+									{line.content}
+								
+
+ ))} +
+
+ )} + + {/* Streaming indicator */} + {isStreaming && !hasDiff && ( +
+ + ... + +
+ )} +
+ ); +}; From 92670360fbe7c4c273ed1fca3f23fd5adf2963b1 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 01:07:17 -0800 Subject: [PATCH 02/11] Add more tools --- apps/desktop/package.json | 1 + .../ChatMessageItem/ChatMessageItem.tsx | 117 ++++++- .../ToolCallBlock/ToolCallBlock.tsx | 162 ++++++++-- .../ChatInterface/utils/tool-registry.ts | 289 ++++++++++++++++++ bun.lock | 1 + .../ai-elements/exploring-group.tsx | 121 ++++++++ .../src/components/ai-elements/tool-call.tsx | 81 +++++ .../components/ai-elements/web-fetch-tool.tsx | 120 ++++++++ .../ai-elements/web-search-tool.tsx | 127 ++++++++ 9 files changed, 986 insertions(+), 33 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/tool-registry.ts create mode 100644 packages/ui/src/components/ai-elements/exploring-group.tsx create mode 100644 packages/ui/src/components/ai-elements/tool-call.tsx create mode 100644 packages/ui/src/components/ai-elements/web-fetch-tool.tsx create mode 100644 packages/ui/src/components/ai-elements/web-search-tool.tsx diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f676a476b5d..83bc2cc4191 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx index 10ce1e78d1e..20986f07380 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx @@ -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, @@ -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 { @@ -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": @@ -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, @@ -52,11 +138,38 @@ export function ChatMessageItem({ (p) => p.type === "text" && p.content, ); + const grouped = groupParts(message.parts); + return ( - {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 ( + + ); + } + + const { part, index } = item; + const key = getPartKey(part, index); + switch (part.type) { case "thinking": return ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx index 335318b9e74..380c99737e6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx @@ -11,18 +11,15 @@ import { ConfirmationTitle, } from "@superset/ui/ai-elements/confirmation"; import { FileDiffTool } from "@superset/ui/ai-elements/file-diff-tool"; -import { - Tool, - ToolContent, - ToolHeader, - ToolInput, - ToolOutput, -} from "@superset/ui/ai-elements/tool"; +import { ToolCall } from "@superset/ui/ai-elements/tool-call"; +import { WebFetchTool } from "@superset/ui/ai-elements/web-fetch-tool"; +import { WebSearchTool } from "@superset/ui/ai-elements/web-search-tool"; import { mapApproval, mapToolCallState, safeParseJson, } from "../../utils/map-tool-state"; +import { getToolMeta, getToolStatus } from "../../utils/tool-registry"; interface ToolCallBlockProps { toolCallPart: ToolCallPart; @@ -39,7 +36,7 @@ type SpecializedToolState = /** * Map the broader ToolDisplayState to the 4-state subset - * used by BashTool and FileDiffTool. + * used by BashTool, FileDiffTool, WebSearchTool, WebFetchTool. */ function toSpecializedState( tc: ToolCallPart, @@ -71,7 +68,6 @@ function BashToolBlock({ const args = safeParseJson(toolCallPart.arguments); const command = typeof args.command === "string" ? args.command : undefined; - // Parse result content for stdout/stderr const resultContent = toolResultPart?.content ? safeParseJson(toolResultPart.content) : {}; @@ -114,7 +110,6 @@ function FileDiffToolBlock({ const filePath = typeof args.file_path === "string" ? args.file_path : undefined; - // Parse result for structured patch const resultContent = toolResultPart?.content ? safeParseJson(toolResultPart.content) : {}; @@ -151,11 +146,114 @@ function FileDiffToolBlock({ ); } -/** Tool names that get specialized rendering. */ -const SPECIALIZED_TOOLS: Record = { +function WebSearchToolBlock({ + toolCallPart, + toolResultPart, +}: { + toolCallPart: ToolCallPart; + toolResultPart?: ToolResultPart; +}) { + const state = toSpecializedState(toolCallPart, toolResultPart); + const args = safeParseJson(toolCallPart.arguments); + const query = typeof args.query === "string" ? args.query : undefined; + + const resultContent = toolResultPart?.content + ? safeParseJson(toolResultPart.content) + : {}; + const results = Array.isArray(resultContent.results) + ? (resultContent.results as Array<{ title?: string; url?: string }>).filter( + (r): r is { title: string; url: string } => + typeof r.title === "string" && typeof r.url === "string", + ) + : []; + + return ; +} + +function WebFetchToolBlock({ + toolCallPart, + toolResultPart, +}: { + toolCallPart: ToolCallPart; + toolResultPart?: ToolResultPart; +}) { + const state = toSpecializedState(toolCallPart, toolResultPart); + const args = safeParseJson(toolCallPart.arguments); + const url = typeof args.url === "string" ? args.url : undefined; + + const resultContent = toolResultPart?.content + ? safeParseJson(toolResultPart.content) + : {}; + const content = + typeof resultContent.content === "string" + ? resultContent.content + : typeof toolResultPart?.content === "string" + ? toolResultPart.content + : undefined; + const bytes = + typeof resultContent.bytes === "number" ? resultContent.bytes : undefined; + const statusCode = + typeof resultContent.status_code === "number" + ? resultContent.status_code + : undefined; + + return ( + + ); +} + +function DefaultToolBlock({ + toolCallPart, + toolResultPart, +}: { + toolCallPart: ToolCallPart; + toolResultPart?: ToolResultPart; +}) { + const meta = getToolMeta(toolCallPart.name); + const args = safeParseJson(toolCallPart.arguments); + const resultContent = toolResultPart?.content + ? safeParseJson(toolResultPart.content) + : {}; + const state = toolResultPart + ? toolResultPart.error + ? "output-error" + : "output-available" + : (toolCallPart.state ?? "input-available"); + const { isPending, isError } = getToolStatus( + state, + Boolean(toolResultPart), + Boolean(toolResultPart?.error), + ); + + const title = meta.title(args, resultContent, state); + const subtitle = meta.subtitle?.(args, resultContent, state); + + return ( + + ); +} + +/** Tool names that get specialized collapsible rendering. */ +const SPECIALIZED_DISPATCH: Record = { Bash: "bash", FileEdit: "file-edit", FileWrite: "file-write", + Edit: "file-edit", + Write: "file-write", + WebSearch: "web-search", + WebFetch: "web-fetch", }; export function ToolCallBlock({ @@ -166,10 +264,10 @@ export function ToolCallBlock({ }: ToolCallBlockProps) { const state = mapToolCallState(toolCallPart, toolResultPart); const approval = mapApproval(toolCallPart.approval); - const specialized = SPECIALIZED_TOOLS[toolCallPart.name]; + const dispatch = SPECIALIZED_DISPATCH[toolCallPart.name]; const toolContent = (() => { - switch (specialized) { + switch (dispatch) { case "bash": return ( ); - default: { - const output = toolResultPart?.content ?? toolCallPart.output; - const errorText = toolResultPart?.error; + case "web-search": + return ( + + ); + case "web-fetch": return ( - - - - - {(output || errorText) && ( - - )} - - + + ); + default: + return ( + ); - } } })(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/tool-registry.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/tool-registry.ts new file mode 100644 index 00000000000..129f4e48045 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/tool-registry.ts @@ -0,0 +1,289 @@ +import { + EyeIcon, + FileCode2Icon, + FilePlusIcon, + FolderSearchIcon, + GlobeIcon, + ListIcon, + PencilIcon, + PlusIcon, + RefreshCwIcon, + SearchIcon, + ServerIcon, + SparklesIcon, + TerminalIcon, + WrenchIcon, + XCircleIcon, +} from "lucide-react"; +import type { ComponentType } from "react"; + +type ToolArgs = Record; + +export type ToolMeta = { + icon: ComponentType<{ className?: string }>; + title: (args: ToolArgs, output: ToolArgs, state: string) => string; + subtitle?: ( + args: ToolArgs, + output: ToolArgs, + state: string, + ) => string | undefined; + variant: "simple" | "collapsible"; +}; + +/** Extract filename from a path, stripping common prefixes. */ +export function getDisplayPath(filePath: string): string { + const parts = filePath.split("/"); + return parts.at(-1) ?? filePath; +} + +/** Derive isPending / isError from tool state. */ +export function getToolStatus( + state: string, + hasResult: boolean, + hasError: boolean, +): { isPending: boolean; isError: boolean } { + const isPending = + !hasResult && + (state === "input-streaming" || + state === "input-available" || + state === "awaiting-input" || + state === "input-complete" || + state === "approval-requested" || + state === "approval-responded"); + return { isPending, isError: hasError }; +} + +function isPending(state: string): boolean { + return ( + state === "input-streaming" || + state === "input-available" || + state === "awaiting-input" + ); +} + +function taskSubject(args: ToolArgs, output: ToolArgs): string | undefined { + if (typeof output.subject === "string") return output.subject; + if (typeof args.subject === "string") return args.subject; + if (typeof args.taskId === "string") return `#${args.taskId}`; + return undefined; +} + +const registry: Record = { + Bash: { + icon: TerminalIcon, + title: (_a, _o, state) => + isPending(state) ? "Running command" : "Ran command", + subtitle: (args) => + typeof args.command === "string" + ? args.command.length > 60 + ? `${args.command.slice(0, 57)}...` + : args.command + : undefined, + variant: "collapsible", + }, + FileEdit: { + icon: PencilIcon, + title: (args, _o, state) => { + const path = typeof args.file_path === "string" ? args.file_path : ""; + return isPending(state) + ? `Editing ${getDisplayPath(path) || "file"}` + : `Edited ${getDisplayPath(path) || "file"}`; + }, + subtitle: (args) => { + const old = typeof args.old_string === "string" ? args.old_string : ""; + const next = typeof args.new_string === "string" ? args.new_string : ""; + const added = next.split("\n").length; + const removed = old.split("\n").length; + if (added === 0 && removed === 0) return undefined; + return `+${added} -${removed}`; + }, + variant: "collapsible", + }, + FileWrite: { + icon: FilePlusIcon, + title: (args, _o, state) => { + const path = typeof args.file_path === "string" ? args.file_path : ""; + return isPending(state) + ? `Creating ${getDisplayPath(path) || "file"}` + : `Created ${getDisplayPath(path) || "file"}`; + }, + variant: "collapsible", + }, + Read: { + icon: EyeIcon, + title: (_a, _o, state) => (isPending(state) ? "Reading" : "Read"), + subtitle: (args) => + typeof args.file_path === "string" + ? getDisplayPath(args.file_path) + : undefined, + variant: "simple", + }, + Grep: { + icon: SearchIcon, + title: (_args, output, state) => { + if (isPending(state)) return "Grepping"; + const count = typeof output.count === "number" ? output.count : undefined; + return count !== undefined ? `Found ${count} matches` : "Grepped"; + }, + subtitle: (args) => + typeof args.pattern === "string" ? args.pattern : undefined, + variant: "simple", + }, + Glob: { + icon: FolderSearchIcon, + title: (_args, output, state) => { + if (isPending(state)) return "Exploring files"; + const count = typeof output.count === "number" ? output.count : undefined; + return count !== undefined ? `Found ${count} files` : "Explored files"; + }, + subtitle: (args) => + typeof args.pattern === "string" ? args.pattern : undefined, + variant: "simple", + }, + WebSearch: { + icon: SearchIcon, + title: (_a, _o, state) => + isPending(state) ? "Searching web" : "Searched web", + subtitle: (args) => + typeof args.query === "string" ? args.query : undefined, + variant: "collapsible", + }, + WebFetch: { + icon: GlobeIcon, + title: (args, _o, state) => { + if (isPending(state)) return "Fetching"; + const url = typeof args.url === "string" ? args.url : ""; + try { + return `Fetched ${new URL(url).hostname}`; + } catch { + return "Fetched"; + } + }, + subtitle: (args) => { + if (typeof args.url !== "string") return undefined; + try { + return new URL(args.url).hostname; + } catch { + return args.url; + } + }, + variant: "collapsible", + }, + TaskCreate: { + icon: PlusIcon, + title: (_a, _o, state) => + isPending(state) ? "Creating task" : "Created task", + subtitle: (args) => + typeof args.subject === "string" ? args.subject : undefined, + variant: "simple", + }, + TaskUpdate: { + icon: RefreshCwIcon, + title: (args, _o, state) => { + if (isPending(state)) { + const status = args.status; + if (status === "in_progress") return "Starting task"; + if (status === "completed") return "Completing task"; + return "Updating task"; + } + const status = args.status; + if (status === "in_progress") return "Started task"; + if (status === "completed") return "Completed task"; + return "Updated task"; + }, + subtitle: (args, output) => taskSubject(args, output), + variant: "simple", + }, + TaskGet: { + icon: EyeIcon, + title: (_a, _o, state) => (isPending(state) ? "Getting task" : "Got task"), + subtitle: (args, output) => taskSubject(args, output), + variant: "simple", + }, + TaskList: { + icon: ListIcon, + title: (_a, output, state) => { + if (isPending(state)) return "Listing tasks"; + const count = typeof output.count === "number" ? output.count : undefined; + return count !== undefined ? `Listed ${count} tasks` : "Listed tasks"; + }, + variant: "simple", + }, + NotebookEdit: { + icon: FileCode2Icon, + title: (args, _o, state) => { + const path = + typeof args.notebook_path === "string" ? args.notebook_path : ""; + return isPending(state) + ? `Editing notebook${path ? ` ${getDisplayPath(path)}` : ""}` + : `Edited notebook${path ? ` ${getDisplayPath(path)}` : ""}`; + }, + subtitle: (args) => + typeof args.notebook_path === "string" + ? getDisplayPath(args.notebook_path) + : undefined, + variant: "simple", + }, + Agent: { + icon: SparklesIcon, + title: (_a, _o, state) => + isPending(state) ? "Running subagent" : "Completed subagent", + subtitle: (args) => + typeof args.description === "string" ? args.description : undefined, + variant: "simple", + }, + Task: { + icon: SparklesIcon, + title: (_a, _o, state) => + isPending(state) ? "Running subagent" : "Completed subagent", + subtitle: (args) => + typeof args.description === "string" ? args.description : undefined, + variant: "simple", + }, + TaskOutput: { + icon: TerminalIcon, + title: (_a, _o, state) => + isPending(state) ? "Getting output" : "Got output", + subtitle: (args) => + typeof args.task_id === "string" ? args.task_id : undefined, + variant: "simple", + }, + TaskStop: { + icon: XCircleIcon, + title: (_a, _o, state) => + isPending(state) ? "Stopping task" : "Stopped task", + subtitle: (args) => + typeof args.task_id === "string" ? args.task_id : undefined, + variant: "simple", + }, + ListMcpResources: { + icon: ServerIcon, + title: (_a, _o, state) => + isPending(state) ? "Listing resources" : "Listed resources", + variant: "simple", + }, + Mcp: { + icon: ServerIcon, + title: (_a, _o, state) => + isPending(state) ? "Running MCP tool" : "Ran MCP tool", + subtitle: (args) => + typeof args.tool_name === "string" ? args.tool_name : undefined, + variant: "simple", + }, +}; + +const fallbackMeta: ToolMeta = { + icon: WrenchIcon, + title: (_a, _o, state) => (isPending(state) ? "Running tool" : "Ran tool"), + variant: "simple", +}; + +export function getToolMeta(toolName: string): ToolMeta { + return ( + registry[toolName] ?? { + ...fallbackMeta, + title: (_a, _o, state) => + isPending(state) ? `Running ${toolName}` : `Ran ${toolName}`, + } + ); +} diff --git a/bun.lock b/bun.lock index 8c40f160ae7..f380bafb37d 100644 --- a/bun.lock +++ b/bun.lock @@ -232,6 +232,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", diff --git a/packages/ui/src/components/ai-elements/exploring-group.tsx b/packages/ui/src/components/ai-elements/exploring-group.tsx new file mode 100644 index 00000000000..e4a98443ac0 --- /dev/null +++ b/packages/ui/src/components/ai-elements/exploring-group.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { ChevronRightIcon } from "lucide-react"; +import type { ComponentType } from "react"; +import { useEffect, useRef, useState } from "react"; +import { cn } from "../../lib/utils"; +import { Shimmer } from "./shimmer"; +import { ToolCall } from "./tool-call"; + +export type ExploringGroupItem = { + icon: ComponentType<{ className?: string }>; + title: string; + subtitle?: string; + isPending: boolean; + isError: boolean; + onClick?: () => void; +}; + +export type ExploringGroupProps = { + items: ExploringGroupItem[]; + isStreaming: boolean; + className?: string; +}; + +function buildSummary(items: ExploringGroupItem[]): string { + let files = 0; + let searches = 0; + for (const item of items) { + // Heuristic: titles containing "Read", "Glob", "Explored", "Found" → files; others → searches + if (/read|glob|explor|found.*file/i.test(item.title)) { + files++; + } else { + searches++; + } + } + const parts: string[] = []; + if (files > 0) parts.push(`${files} file${files !== 1 ? "s" : ""}`); + if (searches > 0) + parts.push(`${searches} search${searches !== 1 ? "es" : ""}`); + return parts.join(", "); +} + +export const ExploringGroup = ({ + items, + isStreaming, + className, +}: ExploringGroupProps) => { + const [expanded, setExpanded] = useState(isStreaming); + const scrollRef = useRef(null); + const hasCollapsedRef = useRef(false); + + // Auto-collapse when streaming ends + useEffect(() => { + if (!isStreaming && !hasCollapsedRef.current) { + hasCollapsedRef.current = true; + setExpanded(false); + } + }, [isStreaming]); + + // Auto-scroll to bottom while streaming + useEffect(() => { + if (isStreaming && expanded && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [isStreaming, expanded]); + + const summary = buildSummary(items); + + return ( +
+ {/* Header */} + + + {/* Expandable items */} + {expanded && ( +
+ {items.map((item, i) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/packages/ui/src/components/ai-elements/tool-call.tsx b/packages/ui/src/components/ai-elements/tool-call.tsx new file mode 100644 index 00000000000..df4690e7bce --- /dev/null +++ b/packages/ui/src/components/ai-elements/tool-call.tsx @@ -0,0 +1,81 @@ +"use client"; + +import type { ComponentType } from "react"; +import { cn } from "../../lib/utils"; +import { Shimmer } from "./shimmer"; + +export type ToolCallProps = { + icon: ComponentType<{ className?: string }>; + title: string; + subtitle?: string; + isPending: boolean; + isError: boolean; + onClick?: () => void; + className?: string; +}; + +export const ToolCall = ({ + icon: Icon, + title, + subtitle, + isPending, + isError, + onClick, + className, +}: ToolCallProps) => { + const content = ( + <> + + {isPending ? ( + + {title} + + ) : ( + + {title} + + )} + {subtitle && ( + + {subtitle} + + )} + + ); + + if (onClick) { + return ( + + ); + } + + return ( +
+ {content} +
+ ); +}; diff --git a/packages/ui/src/components/ai-elements/web-fetch-tool.tsx b/packages/ui/src/components/ai-elements/web-fetch-tool.tsx new file mode 100644 index 00000000000..ab85fef764a --- /dev/null +++ b/packages/ui/src/components/ai-elements/web-fetch-tool.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { + CheckCircleIcon, + ChevronDownIcon, + GlobeIcon, + XCircleIcon, +} from "lucide-react"; +import { useState } from "react"; +import { cn } from "../../lib/utils"; +import { Loader } from "./loader"; +import { Shimmer } from "./shimmer"; + +type WebFetchToolState = + | "input-streaming" + | "input-available" + | "output-available" + | "output-error"; + +type WebFetchToolProps = { + url?: string; + content?: string; + bytes?: number; + statusCode?: number; + state: WebFetchToolState; + className?: string; +}; + +function extractHostname(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +const StatusIcon = ({ state }: { state: WebFetchToolState }) => { + if (state === "input-streaming" || state === "input-available") { + return ; + } + if (state === "output-error") { + return ; + } + return ; +}; + +export const WebFetchTool = ({ + url, + content, + bytes, + statusCode, + state, + className, +}: WebFetchToolProps) => { + const [expanded, setExpanded] = useState(false); + const isPending = state === "input-streaming" || state === "input-available"; + const hasContent = Boolean(content); + const hostname = url ? extractHostname(url) : undefined; + + return ( +
+ {/* Header */} + + + {/* Expandable content */} + {expanded && content && ( +
+
+						{content}
+					
+
+ )} +
+ ); +}; diff --git a/packages/ui/src/components/ai-elements/web-search-tool.tsx b/packages/ui/src/components/ai-elements/web-search-tool.tsx new file mode 100644 index 00000000000..1cf6ee7dbc7 --- /dev/null +++ b/packages/ui/src/components/ai-elements/web-search-tool.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { + CheckCircleIcon, + ChevronDownIcon, + ExternalLinkIcon, + SearchIcon, + XCircleIcon, +} from "lucide-react"; +import { useState } from "react"; +import { cn } from "../../lib/utils"; +import { Loader } from "./loader"; +import { Shimmer } from "./shimmer"; + +type WebSearchToolState = + | "input-streaming" + | "input-available" + | "output-available" + | "output-error"; + +type SearchResult = { title: string; url: string }; + +type WebSearchToolProps = { + query?: string; + results: SearchResult[]; + state: WebSearchToolState; + className?: string; +}; + +const StatusIcon = ({ state }: { state: WebSearchToolState }) => { + if (state === "input-streaming" || state === "input-available") { + return ; + } + if (state === "output-error") { + return ; + } + return ; +}; + +export const WebSearchTool = ({ + query, + results, + state, + className, +}: WebSearchToolProps) => { + const [expanded, setExpanded] = useState(false); + const isPending = state === "input-streaming" || state === "input-available"; + const hasResults = results.length > 0; + + return ( +
+ {/* Header */} + + + {/* Expandable results */} + {expanded && hasResults && ( + + )} +
+ ); +}; + +function extractHostname(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} From 1a04d18fe7524e4edb39d277bee7557c738c057c Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 01:19:32 -0800 Subject: [PATCH 03/11] More styling update --- .../ChatMessageItem/ChatMessageItem.tsx | 23 +- .../src/components/ai-elements/bash-tool.tsx | 293 ++++++++---------- .../ai-elements/exploring-group.tsx | 135 ++++---- .../components/ai-elements/file-diff-tool.tsx | 68 ++-- .../ui/src/components/ai-elements/message.tsx | 10 +- .../src/components/ai-elements/tool-call.tsx | 83 +++-- .../components/ai-elements/web-fetch-tool.tsx | 115 ++++--- .../ai-elements/web-search-tool.tsx | 136 ++++---- 8 files changed, 427 insertions(+), 436 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx index 20986f07380..5a8da8ad156 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx @@ -140,6 +140,27 @@ export function ChatMessageItem({ 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 ( + + + {textContent && ( +
+ {textContent} +
+ )} +
+
+ ); + } + + // Assistant messages: render with tool grouping + text return ( @@ -201,7 +222,7 @@ export function ChatMessageItem({ } })} - {message.role === "assistant" && hasTextContent && ( + {hasTextContent && ( diff --git a/packages/ui/src/components/ai-elements/bash-tool.tsx b/packages/ui/src/components/ai-elements/bash-tool.tsx index 670e8a43528..c09d48d4e13 100644 --- a/packages/ui/src/components/ai-elements/bash-tool.tsx +++ b/packages/ui/src/components/ai-elements/bash-tool.tsx @@ -1,12 +1,7 @@ "use client"; -import { - CheckCircleIcon, - ChevronDownIcon, - TerminalIcon, - XCircleIcon, -} from "lucide-react"; -import { useState } from "react"; +import { CheckIcon, XIcon } from "lucide-react"; +import { useMemo, useState } from "react"; import { cn } from "../../lib/utils"; import { Loader } from "./loader"; import { Shimmer } from "./shimmer"; @@ -26,16 +21,16 @@ type BashToolProps = { className?: string; }; -/** Extract a short summary of the command for display in the header. */ +/** Extract first word of each command in a pipeline, max 4. */ function extractCommandSummary(command: string): string { - const trimmed = command.trim(); - // For piped commands, show the first segment - const firstSegment = trimmed.split(/\s*\|\s*/)[0] ?? trimmed; - // Limit to first ~60 chars - if (firstSegment.length > 60) { - return `${firstSegment.slice(0, 57)}...`; + const normalized = command.replace(/\\\s*\n\s*/g, " "); + const parts = normalized.split(/\s*(?:&&|\|\||;|\|)\s*/); + const firstWords = parts.map((p) => p.trim().split(/\s+/)[0]).filter(Boolean); + const limited = firstWords.slice(0, 4); + if (firstWords.length > 4) { + return `${limited.join(", ")}...`; } - return firstSegment; + return limited.join(", "); } /** Limit text to N lines, returning whether it was truncated. */ @@ -43,6 +38,7 @@ function limitLines( text: string, maxLines: number, ): { text: string; truncated: boolean } { + if (!text) return { text: "", truncated: false }; const lines = text.split("\n"); if (lines.length <= maxLines) { return { text, truncated: false }; @@ -52,57 +48,6 @@ function limitLines( const MAX_COLLAPSED_LINES = 3; -const StatusIcon = ({ - state, - exitCode, -}: { - state: BashToolState; - exitCode?: number; -}) => { - if (state === "input-streaming" || state === "input-available") { - return ; - } - if (state === "output-error" || (exitCode !== undefined && exitCode !== 0)) { - return ; - } - return ; -}; - -const HeaderText = ({ - state, - command, -}: { - state: BashToolState; - command?: string; -}) => { - if (state === "input-streaming") { - return ( - - Running command... - - ); - } - if (state === "input-available") { - return ( - Running command... - ); - } - if (!command) { - return ( - - {state === "output-error" ? "Command failed" : "Ran command"} - - ); - } - const summary = extractCommandSummary(command); - return ( - - {state === "output-error" ? "Failed:" : "Ran:"}{" "} - {summary} - - ); -}; - export const BashTool = ({ command, stdout, @@ -111,113 +56,147 @@ export const BashTool = ({ state, className, }: BashToolProps) => { - const [expanded, setExpanded] = useState(false); + const [isOutputExpanded, setIsOutputExpanded] = useState(false); + + const isPending = state === "input-streaming" || state === "input-available"; + const isSuccess = exitCode === 0; + const isError = exitCode !== undefined && exitCode !== 0; + const _hasOutput = Boolean(stdout || stderr); - const hasOutput = Boolean(stdout || stderr); - const isStreaming = state === "input-streaming"; - const isPending = state === "input-available"; + const stdoutLimited = useMemo( + () => limitLines(stdout ?? "", MAX_COLLAPSED_LINES), + [stdout], + ); + const stderrLimited = useMemo( + () => limitLines(stderr ?? "", MAX_COLLAPSED_LINES), + [stderr], + ); + const hasMoreOutput = stdoutLimited.truncated || stderrLimited.truncated; + + const commandSummary = useMemo( + () => (command ? extractCommandSummary(command) : ""), + [command], + ); + + // Input still streaming + if (state === "input-streaming") { + return ( +
+
+
+ + + Generating command + + +
+
+
+ ); + } return (
- {/* Header */} - - )} - {isStreaming && ( - - ... - - )} + {/* Command */} + {command && ( +
+ $ + + {command} + +
+ )} + + {/* Stdout */} + {stdout && ( +
+ {isOutputExpanded ? stdout : stdoutLimited.text} +
+ )} + + {/* Stderr */} + {stderr && ( +
+ {isOutputExpanded ? stderr : stderrLimited.text} +
+ )} +
); }; diff --git a/packages/ui/src/components/ai-elements/exploring-group.tsx b/packages/ui/src/components/ai-elements/exploring-group.tsx index e4a98443ac0..e982a1a734e 100644 --- a/packages/ui/src/components/ai-elements/exploring-group.tsx +++ b/packages/ui/src/components/ai-elements/exploring-group.tsx @@ -4,7 +4,6 @@ import { ChevronRightIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useEffect, useRef, useState } from "react"; import { cn } from "../../lib/utils"; -import { Shimmer } from "./shimmer"; import { ToolCall } from "./tool-call"; export type ExploringGroupItem = { @@ -22,11 +21,13 @@ export type ExploringGroupProps = { className?: string; }; +const MAX_VISIBLE_TOOLS = 5; +const TOOL_HEIGHT_PX = 24; + function buildSummary(items: ExploringGroupItem[]): string { let files = 0; let searches = 0; for (const item of items) { - // Heuristic: titles containing "Read", "Glob", "Explored", "Found" → files; others → searches if (/read|glob|explor|found.*file/i.test(item.title)) { files++; } else { @@ -34,10 +35,10 @@ function buildSummary(items: ExploringGroupItem[]): string { } } const parts: string[] = []; - if (files > 0) parts.push(`${files} file${files !== 1 ? "s" : ""}`); + if (files > 0) parts.push(`${files} ${files === 1 ? "file" : "files"}`); if (searches > 0) - parts.push(`${searches} search${searches !== 1 ? "es" : ""}`); - return parts.join(", "); + parts.push(`${searches} ${searches === 1 ? "search" : "searches"}`); + return parts.join(" "); } export const ExploringGroup = ({ @@ -45,75 +46,93 @@ export const ExploringGroup = ({ isStreaming, className, }: ExploringGroupProps) => { - const [expanded, setExpanded] = useState(isStreaming); + const [isExpanded, setIsExpanded] = useState(isStreaming); const scrollRef = useRef(null); - const hasCollapsedRef = useRef(false); + const wasStreamingRef = useRef(isStreaming); - // Auto-collapse when streaming ends + // Auto-collapse when streaming ends (transition from true -> false) useEffect(() => { - if (!isStreaming && !hasCollapsedRef.current) { - hasCollapsedRef.current = true; - setExpanded(false); + if (wasStreamingRef.current && !isStreaming) { + setIsExpanded(false); } + wasStreamingRef.current = isStreaming; }, [isStreaming]); // Auto-scroll to bottom while streaming useEffect(() => { - if (isStreaming && expanded && scrollRef.current) { + if (isStreaming && isExpanded && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - }, [isStreaming, expanded]); + }, [isStreaming, isExpanded]); const summary = buildSummary(items); return ( -
- {/* Header */} - - - {/* Expandable items */} - {expanded && ( -
- {items.map((item, i) => ( - +
+ + {isStreaming ? "Exploring" : "Explored"} + + + {summary} + + - ))} +
+
+
+ + {/* Tools list */} + {isExpanded && ( +
+ {/* Top gradient fade when streaming and many items */} +
MAX_VISIBLE_TOOLS + ? "opacity-100" + : "opacity-0", + )} + /> + + {/* Scrollable container */} +
MAX_VISIBLE_TOOLS && + "overflow-y-auto scrollbar-hide", + )} + ref={scrollRef} + style={ + items.length > MAX_VISIBLE_TOOLS + ? { maxHeight: `${MAX_VISIBLE_TOOLS * TOOL_HEIGHT_PX}px` } + : undefined + } + > + {items.map((item, i) => ( + + ))} +
)}
diff --git a/packages/ui/src/components/ai-elements/file-diff-tool.tsx b/packages/ui/src/components/ai-elements/file-diff-tool.tsx index 771f61c6c7a..0c97fc73de4 100644 --- a/packages/ui/src/components/ai-elements/file-diff-tool.tsx +++ b/packages/ui/src/components/ai-elements/file-diff-tool.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronDownIcon, FileCode2Icon } from "lucide-react"; +import { FileCode2Icon } from "lucide-react"; import { useMemo, useState } from "react"; import { cn } from "../../lib/utils"; import { Shimmer } from "./shimmer"; @@ -126,34 +126,43 @@ export const FileDiffTool = ({ return (
- {/* Header */} - +
{/* Diff body */} {hasDiff && (
+
... diff --git a/packages/ui/src/components/ai-elements/message.tsx b/packages/ui/src/components/ai-elements/message.tsx index e08df92c9d1..0252ba09394 100644 --- a/packages/ui/src/components/ai-elements/message.tsx +++ b/packages/ui/src/components/ai-elements/message.tsx @@ -27,8 +27,8 @@ export type MessageProps = HTMLAttributes & { export const Message = ({ className, from, ...props }: MessageProps) => (
(
( *:first-child]:mt-0 [&>*:last-child]:mb-0", + "px-2 text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", className, )} {...props} diff --git a/packages/ui/src/components/ai-elements/tool-call.tsx b/packages/ui/src/components/ai-elements/tool-call.tsx index df4690e7bce..79b70f2448a 100644 --- a/packages/ui/src/components/ai-elements/tool-call.tsx +++ b/packages/ui/src/components/ai-elements/tool-call.tsx @@ -15,67 +15,54 @@ export type ToolCallProps = { }; export const ToolCall = ({ - icon: Icon, + icon: _Icon, title, subtitle, isPending, - isError, + isError: _isError, onClick, className, }: ToolCallProps) => { - const content = ( - <> - - {isPending ? ( - - {title} - - ) : ( - - {title} - - )} - {subtitle && ( - - {subtitle} - - )} - - ); - - if (onClick) { - return ( - - ); - } + const clickableClass = onClick + ? "cursor-pointer hover:text-muted-foreground transition-colors" + : ""; return (
- {content} +
+
+ + {isPending ? ( + + {title} + + ) : ( + title + )} + + {subtitle && ( + + {subtitle} + + )} +
+
); }; diff --git a/packages/ui/src/components/ai-elements/web-fetch-tool.tsx b/packages/ui/src/components/ai-elements/web-fetch-tool.tsx index ab85fef764a..fec8050494d 100644 --- a/packages/ui/src/components/ai-elements/web-fetch-tool.tsx +++ b/packages/ui/src/components/ai-elements/web-fetch-tool.tsx @@ -1,11 +1,6 @@ "use client"; -import { - CheckCircleIcon, - ChevronDownIcon, - GlobeIcon, - XCircleIcon, -} from "lucide-react"; +import { GlobeIcon } from "lucide-react"; import { useState } from "react"; import { cn } from "../../lib/utils"; import { Loader } from "./loader"; @@ -28,9 +23,9 @@ type WebFetchToolProps = { function extractHostname(url: string): string { try { - return new URL(url).hostname; + return new URL(url).hostname.replace("www.", ""); } catch { - return url; + return url.slice(0, 30); } } @@ -40,16 +35,6 @@ function formatBytes(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -const StatusIcon = ({ state }: { state: WebFetchToolState }) => { - if (state === "input-streaming" || state === "input-available") { - return ; - } - if (state === "output-error") { - return ; - } - return ; -}; - export const WebFetchTool = ({ url, content, @@ -58,59 +43,71 @@ export const WebFetchTool = ({ state, className, }: WebFetchToolProps) => { - const [expanded, setExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); const isPending = state === "input-streaming" || state === "input-available"; + const isError = state === "output-error"; + const isSuccess = statusCode === 200; const hasContent = Boolean(content); - const hostname = url ? extractHostname(url) : undefined; + const hostname = url ? extractHostname(url) : ""; return (
{/* Header */} - + onClick={() => hasContent && !isPending && setIsExpanded(!isExpanded)} + onKeyDown={undefined} + > +
+ + {isPending ? ( + + Fetching + + ) : ( + Fetched + )} + {hostname && ( + {hostname} + )} +
+ + {/* Status */} +
+
+ {isPending ? ( + + ) : isError || !isSuccess ? ( + + {statusCode ? `Error ${statusCode}` : "Failed"} + + ) : bytes !== undefined ? ( + + {formatBytes(bytes)} + + ) : null} +
+
+
- {/* Expandable content */} - {expanded && content && ( -
-
+			{/* Content */}
+			{hasContent && isExpanded && (
+				
+
 						{content}
 					
diff --git a/packages/ui/src/components/ai-elements/web-search-tool.tsx b/packages/ui/src/components/ai-elements/web-search-tool.tsx index 1cf6ee7dbc7..e892ad022e9 100644 --- a/packages/ui/src/components/ai-elements/web-search-tool.tsx +++ b/packages/ui/src/components/ai-elements/web-search-tool.tsx @@ -1,12 +1,6 @@ "use client"; -import { - CheckCircleIcon, - ChevronDownIcon, - ExternalLinkIcon, - SearchIcon, - XCircleIcon, -} from "lucide-react"; +import { ExternalLinkIcon, SearchIcon } from "lucide-react"; import { useState } from "react"; import { cn } from "../../lib/utils"; import { Loader } from "./loader"; @@ -27,89 +21,91 @@ type WebSearchToolProps = { className?: string; }; -const StatusIcon = ({ state }: { state: WebSearchToolState }) => { - if (state === "input-streaming" || state === "input-available") { - return ; - } - if (state === "output-error") { - return ; - } - return ; -}; - export const WebSearchTool = ({ query, results, state, className, }: WebSearchToolProps) => { - const [expanded, setExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); const isPending = state === "input-streaming" || state === "input-available"; + const isError = state === "output-error"; const hasResults = results.length > 0; + const truncatedQuery = + query && query.length > 40 ? `${query.slice(0, 37)}...` : query; return (
{/* Header */} - +
+
+
- {/* Expandable results */} - {expanded && hasResults && ( -
- {results.map((result) => ( + {/* Results list */} + {hasResults && isExpanded && ( + @@ -117,11 +113,3 @@ export const WebSearchTool = ({
); }; - -function extractHostname(url: string): string { - try { - return new URL(url).hostname; - } catch { - return url; - } -} From 5b19328a7b66c724cf1a61bde989f3c360bff4a0 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 01:29:28 -0800 Subject: [PATCH 04/11] Text select --- packages/ui/src/components/ai-elements/message.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/ai-elements/message.tsx b/packages/ui/src/components/ai-elements/message.tsx index 0252ba09394..5318e2fabd2 100644 --- a/packages/ui/src/components/ai-elements/message.tsx +++ b/packages/ui/src/components/ai-elements/message.tsx @@ -44,7 +44,7 @@ export const MessageContent = ({ }: MessageContentProps) => (
Date: Sun, 8 Feb 2026 01:32:29 -0800 Subject: [PATCH 05/11] Tool styling --- packages/ui/src/components/ai-elements/exploring-group.tsx | 2 +- packages/ui/src/components/ai-elements/file-diff-tool.tsx | 4 ++-- packages/ui/src/components/ai-elements/message.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/ai-elements/exploring-group.tsx b/packages/ui/src/components/ai-elements/exploring-group.tsx index e982a1a734e..1b825cd0d62 100644 --- a/packages/ui/src/components/ai-elements/exploring-group.tsx +++ b/packages/ui/src/components/ai-elements/exploring-group.tsx @@ -63,7 +63,7 @@ export const ExploringGroup = ({ if (isStreaming && isExpanded && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - }, [isStreaming, isExpanded]); + }, [isStreaming, isExpanded, items.length]); const summary = buildSummary(items); diff --git a/packages/ui/src/components/ai-elements/file-diff-tool.tsx b/packages/ui/src/components/ai-elements/file-diff-tool.tsx index 0c97fc73de4..0c876d39755 100644 --- a/packages/ui/src/components/ai-elements/file-diff-tool.tsx +++ b/packages/ui/src/components/ai-elements/file-diff-tool.tsx @@ -184,11 +184,11 @@ export const FileDiffTool = ({ maxHeight: expanded ? EXPANDED_MAX_HEIGHT : COLLAPSED_MAX_HEIGHT, }} > -
+
{diffLines.map((line, i) => (
(
Date: Sun, 8 Feb 2026 01:37:11 -0800 Subject: [PATCH 06/11] More styles --- packages/ui/src/components/ai-elements/bash-tool.tsx | 4 ++-- packages/ui/src/components/ai-elements/exploring-group.tsx | 5 +++-- packages/ui/src/components/ai-elements/file-diff-tool.tsx | 2 +- packages/ui/src/components/ai-elements/message.tsx | 2 +- packages/ui/src/components/ai-elements/tool-call.tsx | 2 +- packages/ui/src/components/ai-elements/web-fetch-tool.tsx | 2 +- packages/ui/src/components/ai-elements/web-search-tool.tsx | 2 +- 7 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/ai-elements/bash-tool.tsx b/packages/ui/src/components/ai-elements/bash-tool.tsx index c09d48d4e13..96ae596f7d7 100644 --- a/packages/ui/src/components/ai-elements/bash-tool.tsx +++ b/packages/ui/src/components/ai-elements/bash-tool.tsx @@ -112,6 +112,7 @@ export const BashTool = ({ )} > {/* Header - fixed height to prevent layout shift */} + {/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: interactive tool header */}
hasMoreOutput && !isPending && setIsOutputExpanded(!isOutputExpanded) } - onKeyDown={undefined} > {isPending ? "Running command: " : "Ran command: "} @@ -154,6 +154,7 @@ export const BashTool = ({
{/* Content - always visible */} + {/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: clickable to expand */}
hasMoreOutput && !isOutputExpanded && setIsOutputExpanded(true) } - onKeyDown={undefined} > {/* Command */} {command && ( diff --git a/packages/ui/src/components/ai-elements/exploring-group.tsx b/packages/ui/src/components/ai-elements/exploring-group.tsx index 1b825cd0d62..2acb3ba18ce 100644 --- a/packages/ui/src/components/ai-elements/exploring-group.tsx +++ b/packages/ui/src/components/ai-elements/exploring-group.tsx @@ -58,7 +58,8 @@ export const ExploringGroup = ({ wasStreamingRef.current = isStreaming; }, [isStreaming]); - // Auto-scroll to bottom while streaming + // Auto-scroll to bottom while streaming and when new items arrive + // biome-ignore lint/correctness/useExhaustiveDependencies: items.length triggers scroll on new items useEffect(() => { if (isStreaming && isExpanded && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; @@ -70,10 +71,10 @@ export const ExploringGroup = ({ return (
{/* Header - clickable to toggle */} + {/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: interactive group header */}
setIsExpanded(!isExpanded)} - onKeyDown={undefined} >
diff --git a/packages/ui/src/components/ai-elements/file-diff-tool.tsx b/packages/ui/src/components/ai-elements/file-diff-tool.tsx index 0c876d39755..06999a37150 100644 --- a/packages/ui/src/components/ai-elements/file-diff-tool.tsx +++ b/packages/ui/src/components/ai-elements/file-diff-tool.tsx @@ -131,6 +131,7 @@ export const FileDiffTool = ({ )} > {/* Header - fixed height */} + {/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: interactive tool header */}
hasDiff && setExpanded((prev) => !prev)} - onKeyDown={undefined} >
diff --git a/packages/ui/src/components/ai-elements/message.tsx b/packages/ui/src/components/ai-elements/message.tsx index 394d7c28cc4..5318e2fabd2 100644 --- a/packages/ui/src/components/ai-elements/message.tsx +++ b/packages/ui/src/components/ai-elements/message.tsx @@ -44,7 +44,7 @@ export const MessageContent = ({ }: MessageContentProps) => (
{subtitle && ( + // biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: clickable subtitle {subtitle} diff --git a/packages/ui/src/components/ai-elements/web-fetch-tool.tsx b/packages/ui/src/components/ai-elements/web-fetch-tool.tsx index fec8050494d..4f081c6815a 100644 --- a/packages/ui/src/components/ai-elements/web-fetch-tool.tsx +++ b/packages/ui/src/components/ai-elements/web-fetch-tool.tsx @@ -58,6 +58,7 @@ export const WebFetchTool = ({ )} > {/* Header */} + {/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: interactive tool header */}
hasContent && !isPending && setIsExpanded(!isExpanded)} - onKeyDown={undefined} >
diff --git a/packages/ui/src/components/ai-elements/web-search-tool.tsx b/packages/ui/src/components/ai-elements/web-search-tool.tsx index e892ad022e9..5bbeee5609d 100644 --- a/packages/ui/src/components/ai-elements/web-search-tool.tsx +++ b/packages/ui/src/components/ai-elements/web-search-tool.tsx @@ -42,6 +42,7 @@ export const WebSearchTool = ({ )} > {/* Header */} + {/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: interactive tool header */}
hasResults && !isPending && setIsExpanded(!isExpanded)} - onKeyDown={undefined} >
From c8a4065b8d0d38f5071becf5f437f1082fae36bd Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 01:45:26 -0800 Subject: [PATCH 07/11] More improvements --- .../ChatMessageItem/ChatMessageItem.tsx | 7 +++-- .../ToolCallBlock/ToolCallBlock.tsx | 12 ++++--- .../ChatInterface/utils/map-tool-state.ts | 5 ++- .../src/components/ai-elements/code-block.tsx | 9 ++---- .../ui/src/components/ai-elements/context.tsx | 6 ++-- .../ui/src/components/ai-elements/image.tsx | 2 +- .../ai-elements/inline-citation.tsx | 17 +++++++--- .../ai-elements/tool-interrupted.tsx | 31 +++++++++++++++++++ 8 files changed, 67 insertions(+), 22 deletions(-) create mode 100644 packages/ui/src/components/ai-elements/tool-interrupted.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx index 5a8da8ad156..07d5eff1adc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx @@ -106,10 +106,12 @@ function buildGroupItem({ ? toolResult.error ? "output-error" : "output-available" - : (tc.state ?? "input-available"); + : tc.output != null + ? "output-available" + : (tc.state ?? "input-available"); const { isPending, isError } = getToolStatus( state, - Boolean(toolResult), + Boolean(toolResult) || tc.output != null, Boolean(toolResult?.error), ); @@ -170,6 +172,7 @@ export function ChatMessageItem({ const isStreaming = item.items.some( (i) => !toolResults.has(i.part.id) && + i.part.output == null && (i.part.state === "input-streaming" || i.part.state === "awaiting-input"), ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx index 380c99737e6..1c4897f9631 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx @@ -45,13 +45,13 @@ function toSpecializedState( if (result) { return result.error ? "output-error" : "output-available"; } + if (tc.output != null) { + return "output-available"; + } switch (tc.state) { case "input-streaming": case "awaiting-input": return "input-streaming"; - case "approval-requested": - case "approval-responded": - return tc.output != null ? "output-available" : "input-available"; default: return "input-available"; } @@ -224,10 +224,12 @@ function DefaultToolBlock({ ? toolResultPart.error ? "output-error" : "output-available" - : (toolCallPart.state ?? "input-available"); + : toolCallPart.output != null + ? "output-available" + : (toolCallPart.state ?? "input-available"); const { isPending, isError } = getToolStatus( state, - Boolean(toolResultPart), + Boolean(toolResultPart) || toolCallPart.output != null, Boolean(toolResultPart?.error), ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/map-tool-state.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/map-tool-state.ts index abd189e2de7..14755827966 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/map-tool-state.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/map-tool-state.ts @@ -11,6 +11,9 @@ export function mapToolCallState( if (result) { return result.error ? "output-error" : "output-available"; } + if (tc.output != null) { + return "output-available"; + } switch (tc.state) { case "awaiting-input": case "input-streaming": @@ -20,7 +23,7 @@ export function mapToolCallState( case "approval-requested": return "approval-requested"; case "approval-responded": - return tc.output != null ? "output-available" : "approval-responded"; + return "approval-responded"; default: return "input-available"; } diff --git a/packages/ui/src/components/ai-elements/code-block.tsx b/packages/ui/src/components/ai-elements/code-block.tsx index 4be3620004e..bf0f22a949e 100644 --- a/packages/ui/src/components/ai-elements/code-block.tsx +++ b/packages/ui/src/components/ai-elements/code-block.tsx @@ -7,7 +7,6 @@ import { type HTMLAttributes, useContext, useEffect, - useRef, useState, } from "react"; import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki"; @@ -82,19 +81,17 @@ export const CodeBlock = ({ }: CodeBlockProps) => { const [html, setHtml] = useState(""); const [darkHtml, setDarkHtml] = useState(""); - const mounted = useRef(false); useEffect(() => { + let cancelled = false; highlightCode(code, language, showLineNumbers).then(([light, dark]) => { - if (!mounted.current) { + if (!cancelled) { setHtml(light); setDarkHtml(dark); - mounted.current = true; } }); - return () => { - mounted.current = false; + cancelled = true; }; }, [code, language, showLineNumbers]); diff --git a/packages/ui/src/components/ai-elements/context.tsx b/packages/ui/src/components/ai-elements/context.tsx index 9974895a3b9..674f1e3a10e 100644 --- a/packages/ui/src/components/ai-elements/context.tsx +++ b/packages/ui/src/components/ai-elements/context.tsx @@ -63,7 +63,7 @@ export const Context = ({ const ContextIcon = () => { const { usedTokens, maxTokens } = useContextValue(); const circumference = 2 * Math.PI * ICON_RADIUS; - const usedPercent = usedTokens / maxTokens; + const usedPercent = maxTokens > 0 ? usedTokens / maxTokens : 0; const dashOffset = circumference * (1 - usedPercent); return ( @@ -105,7 +105,7 @@ export type ContextTriggerProps = ComponentProps; export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => { const { usedTokens, maxTokens } = useContextValue(); - const usedPercent = usedTokens / maxTokens; + const usedPercent = maxTokens > 0 ? usedTokens / maxTokens : 0; const renderedPercent = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1, @@ -145,7 +145,7 @@ export const ContextContentHeader = ({ ...props }: ContextContentHeaderProps) => { const { usedTokens, maxTokens } = useContextValue(); - const usedPercent = usedTokens / maxTokens; + const usedPercent = maxTokens > 0 ? usedTokens / maxTokens : 0; const displayPct = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1, diff --git a/packages/ui/src/components/ai-elements/image.tsx b/packages/ui/src/components/ai-elements/image.tsx index 690534ec09c..5477650f18f 100644 --- a/packages/ui/src/components/ai-elements/image.tsx +++ b/packages/ui/src/components/ai-elements/image.tsx @@ -14,7 +14,7 @@ export const Image = ({ }: ImageProps) => ( {props.alt} {sources[0] ? ( <> - {new URL(sources[0]).hostname}{" "} - {sources.length > 1 && `+${sources.length - 1}`} + {(() => { + try { + return new URL(sources[0]).hostname; + } catch { + return sources[0].slice(0, 30); + } + })()} {sources.length > 1 && `+${sources.length - 1}`} ) : ( "unknown" @@ -166,9 +171,13 @@ export const InlineCitationCarouselIndex = ({ setCount(api.scrollSnapList().length); setCurrent(api.selectedScrollSnap() + 1); - api.on("select", () => { + const onSelect = () => { setCurrent(api.selectedScrollSnap() + 1); - }); + }; + api.on("select", onSelect); + return () => { + api.off("select", onSelect); + }; }, [api]); return ( diff --git a/packages/ui/src/components/ai-elements/tool-interrupted.tsx b/packages/ui/src/components/ai-elements/tool-interrupted.tsx new file mode 100644 index 00000000000..64c64a7ba84 --- /dev/null +++ b/packages/ui/src/components/ai-elements/tool-interrupted.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { cn } from "../../lib/utils"; + +type ToolInterruptedProps = { + toolName: string; + subtitle?: string; + className?: string; +}; + +export const ToolInterrupted = ({ + toolName, + subtitle, + className, +}: ToolInterruptedProps) => ( +
+ + {toolName} interrupted + + {subtitle && ( + + {subtitle} + + )} +
+); From 6b805f4993b68cf0bcc27b8e18836494599c8d1e Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 01:48:02 -0800 Subject: [PATCH 08/11] Clean up --- .../ChatMessageItem/ChatMessageItem.tsx | 7 +- .../ToolCallBlock/ToolCallBlock.tsx | 12 +- .../ChatInterface/utils/map-tool-state.ts | 5 +- .../src/components/ai-elements/code-block.tsx | 19 +- .../ai-elements/text-selection-popover.tsx | 154 ++++++++ .../ai-elements/user-question-tool.tsx | 357 ++++++++++++++++++ 6 files changed, 535 insertions(+), 19 deletions(-) create mode 100644 packages/ui/src/components/ai-elements/text-selection-popover.tsx create mode 100644 packages/ui/src/components/ai-elements/user-question-tool.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx index 07d5eff1adc..5a8da8ad156 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx @@ -106,12 +106,10 @@ function buildGroupItem({ ? toolResult.error ? "output-error" : "output-available" - : tc.output != null - ? "output-available" - : (tc.state ?? "input-available"); + : (tc.state ?? "input-available"); const { isPending, isError } = getToolStatus( state, - Boolean(toolResult) || tc.output != null, + Boolean(toolResult), Boolean(toolResult?.error), ); @@ -172,7 +170,6 @@ export function ChatMessageItem({ const isStreaming = item.items.some( (i) => !toolResults.has(i.part.id) && - i.part.output == null && (i.part.state === "input-streaming" || i.part.state === "awaiting-input"), ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx index 1c4897f9631..380c99737e6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx @@ -45,13 +45,13 @@ function toSpecializedState( if (result) { return result.error ? "output-error" : "output-available"; } - if (tc.output != null) { - return "output-available"; - } switch (tc.state) { case "input-streaming": case "awaiting-input": return "input-streaming"; + case "approval-requested": + case "approval-responded": + return tc.output != null ? "output-available" : "input-available"; default: return "input-available"; } @@ -224,12 +224,10 @@ function DefaultToolBlock({ ? toolResultPart.error ? "output-error" : "output-available" - : toolCallPart.output != null - ? "output-available" - : (toolCallPart.state ?? "input-available"); + : (toolCallPart.state ?? "input-available"); const { isPending, isError } = getToolStatus( state, - Boolean(toolResultPart) || toolCallPart.output != null, + Boolean(toolResultPart), Boolean(toolResultPart?.error), ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/map-tool-state.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/map-tool-state.ts index 14755827966..abd189e2de7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/map-tool-state.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/map-tool-state.ts @@ -11,9 +11,6 @@ export function mapToolCallState( if (result) { return result.error ? "output-error" : "output-available"; } - if (tc.output != null) { - return "output-available"; - } switch (tc.state) { case "awaiting-input": case "input-streaming": @@ -23,7 +20,7 @@ export function mapToolCallState( case "approval-requested": return "approval-requested"; case "approval-responded": - return "approval-responded"; + return tc.output != null ? "output-available" : "approval-responded"; default: return "input-available"; } diff --git a/packages/ui/src/components/ai-elements/code-block.tsx b/packages/ui/src/components/ai-elements/code-block.tsx index bf0f22a949e..31d9127398d 100644 --- a/packages/ui/src/components/ai-elements/code-block.tsx +++ b/packages/ui/src/components/ai-elements/code-block.tsx @@ -159,8 +159,6 @@ export const CodeBlockCopyButton = ({ } }; - const Icon = isCopied ? CheckIcon : CopyIcon; - return ( ); }; diff --git a/packages/ui/src/components/ai-elements/text-selection-popover.tsx b/packages/ui/src/components/ai-elements/text-selection-popover.tsx new file mode 100644 index 00000000000..47b96b7adc3 --- /dev/null +++ b/packages/ui/src/components/ai-elements/text-selection-popover.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "../../lib/utils"; + +type TextSelectionPopoverAction = { + label: string; + onClick: (text: string) => void; +}; + +type TextSelectionPopoverProps = { + /** The container element to monitor for text selections. */ + containerRef: React.RefObject; + /** Primary action (always shown). */ + primaryAction: TextSelectionPopoverAction; + /** Optional secondary action (shown with a separator). */ + secondaryAction?: TextSelectionPopoverAction; + /** Called after any action fires, to refocus the input. */ + onAfterAction?: () => void; +}; + +const POPOVER_HEIGHT = 28; + +export const TextSelectionPopover = ({ + containerRef, + primaryAction, + secondaryAction, + onAfterAction, +}: TextSelectionPopoverProps) => { + const [selectedText, setSelectedText] = useState(""); + const [rect, setRect] = useState(null); + const [isVisible, setIsVisible] = useState(false); + const [isMouseDown, setIsMouseDown] = useState(false); + const popoverRef = useRef(null); + + // Track mouse state to show popover only after selection completes + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (popoverRef.current?.contains(e.target as Node)) return; + setIsMouseDown(true); + setIsVisible(false); + }; + const onUp = (e: MouseEvent) => { + if (popoverRef.current?.contains(e.target as Node)) return; + setIsMouseDown(false); + }; + + document.addEventListener("mousedown", onDown); + document.addEventListener("mouseup", onUp); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("mouseup", onUp); + }; + }, []); + + // Monitor selection changes + useEffect(() => { + const onSelectionChange = () => { + const sel = document.getSelection(); + const text = sel?.toString().trim() ?? ""; + + if ( + !text || + !sel?.rangeCount || + !containerRef.current?.contains(sel.anchorNode) + ) { + setSelectedText(""); + setRect(null); + setIsVisible(false); + return; + } + + const range = sel.getRangeAt(0); + setSelectedText(text); + setRect(range.getBoundingClientRect()); + }; + + document.addEventListener("selectionchange", onSelectionChange); + return () => + document.removeEventListener("selectionchange", onSelectionChange); + }, [containerRef]); + + // Show when mouse is up and we have a selection + useEffect(() => { + if (!isMouseDown && selectedText && rect) { + setIsVisible(true); + } else if (!selectedText || !rect) { + setIsVisible(false); + } + }, [isMouseDown, selectedText, rect]); + + const handleAction = useCallback( + (action: TextSelectionPopoverAction) => { + if (!selectedText) return; + action.onClick(selectedText); + setIsVisible(false); + document.getSelection()?.removeAllRanges(); + requestAnimationFrame(() => onAfterAction?.()); + }, + [selectedText, onAfterAction], + ); + + if (!isVisible || !rect) return null; + + // Position: centered above selection, below if not enough space + const viewportWidth = window.innerWidth; + const estimatedWidth = secondaryAction ? 160 : 100; + const left = Math.max( + 8, + Math.min( + rect.left + rect.width / 2 - estimatedWidth / 2, + viewportWidth - estimatedWidth - 8, + ), + ); + const showAbove = rect.top > POPOVER_HEIGHT + 8; + const top = showAbove ? rect.top - POPOVER_HEIGHT - 4 : rect.bottom + 4; + + const content = ( +
+
+ + {secondaryAction && ( + <> +
+ + + )} +
+
+ ); + + return createPortal(content, document.body); +}; diff --git a/packages/ui/src/components/ai-elements/user-question-tool.tsx b/packages/ui/src/components/ai-elements/user-question-tool.tsx new file mode 100644 index 00000000000..42aa02b1ded --- /dev/null +++ b/packages/ui/src/components/ai-elements/user-question-tool.tsx @@ -0,0 +1,357 @@ +"use client"; + +import { + ChevronDownIcon, + ChevronUpIcon, + CornerDownLeftIcon, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; + +type QuestionOption = { + label: string; + description?: string; +}; + +type Question = { + question: string; + header?: string; + options: QuestionOption[]; + multiSelect?: boolean; +}; + +type UserQuestionToolProps = { + questions: Question[]; + onAnswer: (answers: Record) => void; + onSkip: () => void; + className?: string; +}; + +export const UserQuestionTool = ({ + questions, + onAnswer, + onSkip, + className, +}: UserQuestionToolProps) => { + const [currentIndex, setCurrentIndex] = useState(0); + const [answers, setAnswers] = useState>({}); + const [focusedOption, setFocusedOption] = useState(0); + const [isVisible, setIsVisible] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const prevIndexRef = useRef(currentIndex); + + // Animate on question change + useEffect(() => { + if (prevIndexRef.current !== currentIndex) { + setIsVisible(false); + const timer = setTimeout(() => setIsVisible(true), 50); + prevIndexRef.current = currentIndex; + return () => clearTimeout(timer); + } + }, [currentIndex]); + + if (questions.length === 0) return null; + + const current = questions[currentIndex]; + const options = current?.options ?? []; + const isMulti = current?.multiSelect ?? false; + const isLast = currentIndex === questions.length - 1; + + const isSelected = (questionText: string, optionLabel: string) => + answers[questionText]?.includes(optionLabel) ?? false; + + const currentHasAnswer = (answers[current?.question] ?? []).length > 0; + const allAnswered = questions.every( + (q) => (answers[q.question] ?? []).length > 0, + ); + + const handleOptionClick = useCallback( + ({ + questionText, + optionLabel, + questionIndex, + }: { + questionText: string; + optionLabel: string; + questionIndex: number; + }) => { + const question = questions[questionIndex]; + const allowMultiple = question?.multiSelect ?? false; + const isLastQ = questionIndex === questions.length - 1; + + setAnswers((prev) => { + const current = prev[questionText] ?? []; + if (allowMultiple) { + return { + ...prev, + [questionText]: current.includes(optionLabel) + ? current.filter((l) => l !== optionLabel) + : [...current, optionLabel], + }; + } + return { ...prev, [questionText]: [optionLabel] }; + }); + + // Auto-advance for single-select + if (!allowMultiple && !isLastQ) { + setTimeout(() => { + setCurrentIndex(questionIndex + 1); + setFocusedOption(0); + }, 150); + } + }, + [questions], + ); + + const handleContinue = useCallback(() => { + if (isSubmitting) return; + if (!currentHasAnswer) return; + + if (!isLast) { + setCurrentIndex((i) => i + 1); + setFocusedOption(0); + return; + } + + if (!allAnswered) return; + setIsSubmitting(true); + + const formatted: Record = {}; + for (const q of questions) { + const selected = answers[q.question] ?? []; + formatted[q.question] = selected.join(", "); + } + onAnswer(formatted); + }, [ + isSubmitting, + currentHasAnswer, + isLast, + allAnswered, + questions, + answers, + onAnswer, + ]); + + const handleSkip = useCallback(() => { + if (isSubmitting) return; + setIsSubmitting(true); + onSkip(); + }, [isSubmitting, onSkip]); + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (isSubmitting) return; + + const active = document.activeElement; + if ( + active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement || + active?.getAttribute("contenteditable") === "true" + ) { + return; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + if (focusedOption < options.length - 1) { + setFocusedOption(focusedOption + 1); + } else if (currentIndex < questions.length - 1) { + setCurrentIndex(currentIndex + 1); + setFocusedOption(0); + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (focusedOption > 0) { + setFocusedOption(focusedOption - 1); + } else if (currentIndex > 0) { + const prevOptions = questions[currentIndex - 1]?.options ?? []; + setCurrentIndex(currentIndex - 1); + setFocusedOption(prevOptions.length - 1); + } + } else if (e.key === "Enter") { + e.preventDefault(); + if (currentHasAnswer) { + handleContinue(); + } else if (options[focusedOption] && current) { + handleOptionClick({ + questionText: current.question, + optionLabel: options[focusedOption].label, + questionIndex: currentIndex, + }); + } + } else if (e.key >= "1" && e.key <= "9") { + const idx = Number.parseInt(e.key, 10) - 1; + if (idx >= 0 && idx < options.length && current) { + e.preventDefault(); + handleOptionClick({ + questionText: current.question, + optionLabel: options[idx].label, + questionIndex: currentIndex, + }); + setFocusedOption(idx); + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [ + options, + current, + currentIndex, + focusedOption, + handleOptionClick, + currentHasAnswer, + handleContinue, + questions, + isSubmitting, + ]); + + return ( +
+ {/* Header */} +
+
+ + {current?.header ?? "Question"} + + · + + {isMulti ? "Multi-select" : "Single-select"} + +
+ + {questions.length > 1 && ( +
+ + + {currentIndex + 1} / {questions.length} + + +
+ )} +
+ + {/* Current question */} +
+
+ {currentIndex + 1}.{" "} + {current?.question} +
+ + {/* Options */} +
+ {options.map((option, optIndex) => { + const selected = current + ? isSelected(current.question, option.label) + : false; + const focused = focusedOption === optIndex; + + return ( + + ); + })} +
+
+ + {/* Footer */} +
+ + +
+
+ ); +}; From 2f10c53a9149538c253c6d64754ccee86074745d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 01:50:31 -0800 Subject: [PATCH 09/11] MOre --- apps/streams/src/sdk-to-ai-chunks.ts | 10 ++- .../ai-elements/text-selection-popover.tsx | 3 +- .../ai-elements/user-question-tool.tsx | 65 +++++++++---------- 3 files changed, 42 insertions(+), 36 deletions(-) diff --git a/apps/streams/src/sdk-to-ai-chunks.ts b/apps/streams/src/sdk-to-ai-chunks.ts index d83d0dac463..d5bc2930bb0 100644 --- a/apps/streams/src/sdk-to-ai-chunks.ts +++ b/apps/streams/src/sdk-to-ai-chunks.ts @@ -109,6 +109,7 @@ interface ActiveBlock { toolCallId?: string; toolName?: string; argsAccumulator?: string; + textAccumulator?: string; } export interface ConversionState { @@ -201,7 +202,7 @@ function handleContentBlockStart( switch (block.type) { case "text": { - state.activeBlocks.set(index, { type: "text" }); + state.activeBlocks.set(index, { type: "text", textAccumulator: "" }); return []; } @@ -258,11 +259,18 @@ function handleContentBlockDelta( switch (delta.type) { case "text_delta": { + // Track per-block accumulated text so the StreamProcessor + // can detect new text segments across agent turns. + if (block?.type === "text") { + block.textAccumulator = + (block.textAccumulator ?? "") + delta.text; + } return [ { type: "TEXT_MESSAGE_CONTENT", messageId: state.messageId, delta: delta.text, + content: block?.textAccumulator ?? delta.text, timestamp: now, } satisfies StreamChunk, ]; diff --git a/packages/ui/src/components/ai-elements/text-selection-popover.tsx b/packages/ui/src/components/ai-elements/text-selection-popover.tsx index 47b96b7adc3..025abbb0208 100644 --- a/packages/ui/src/components/ai-elements/text-selection-popover.tsx +++ b/packages/ui/src/components/ai-elements/text-selection-popover.tsx @@ -1,7 +1,6 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; import { cn } from "../../lib/utils"; type TextSelectionPopoverAction = { @@ -150,5 +149,5 @@ export const TextSelectionPopover = ({
); - return createPortal(content, document.body); + return content; }; diff --git a/packages/ui/src/components/ai-elements/user-question-tool.tsx b/packages/ui/src/components/ai-elements/user-question-tool.tsx index 42aa02b1ded..a716a30f17a 100644 --- a/packages/ui/src/components/ai-elements/user-question-tool.tsx +++ b/packages/ui/src/components/ai-elements/user-question-tool.tsx @@ -41,6 +41,17 @@ export const UserQuestionTool = ({ const [isSubmitting, setIsSubmitting] = useState(false); const prevIndexRef = useRef(currentIndex); + const current = questions[currentIndex] as Question | undefined; + const options = current?.options ?? []; + const isMulti = current?.multiSelect ?? false; + const isLast = currentIndex === questions.length - 1; + const currentQuestion = current?.question ?? ""; + + const currentHasAnswer = (answers[currentQuestion] ?? []).length > 0; + const allAnswered = questions.every( + (q) => (answers[q.question] ?? []).length > 0, + ); + // Animate on question change useEffect(() => { if (prevIndexRef.current !== currentIndex) { @@ -51,21 +62,6 @@ export const UserQuestionTool = ({ } }, [currentIndex]); - if (questions.length === 0) return null; - - const current = questions[currentIndex]; - const options = current?.options ?? []; - const isMulti = current?.multiSelect ?? false; - const isLast = currentIndex === questions.length - 1; - - const isSelected = (questionText: string, optionLabel: string) => - answers[questionText]?.includes(optionLabel) ?? false; - - const currentHasAnswer = (answers[current?.question] ?? []).length > 0; - const allAnswered = questions.every( - (q) => (answers[q.question] ?? []).length > 0, - ); - const handleOptionClick = useCallback( ({ questionText, @@ -81,13 +77,13 @@ export const UserQuestionTool = ({ const isLastQ = questionIndex === questions.length - 1; setAnswers((prev) => { - const current = prev[questionText] ?? []; + const selected = prev[questionText] ?? []; if (allowMultiple) { return { ...prev, - [questionText]: current.includes(optionLabel) - ? current.filter((l) => l !== optionLabel) - : [...current, optionLabel], + [questionText]: selected.includes(optionLabel) + ? selected.filter((l) => l !== optionLabel) + : [...selected, optionLabel], }; } return { ...prev, [questionText]: [optionLabel] }; @@ -105,8 +101,7 @@ export const UserQuestionTool = ({ ); const handleContinue = useCallback(() => { - if (isSubmitting) return; - if (!currentHasAnswer) return; + if (isSubmitting || !currentHasAnswer) return; if (!isLast) { setCurrentIndex((i) => i + 1); @@ -141,6 +136,8 @@ export const UserQuestionTool = ({ // Keyboard navigation useEffect(() => { + if (questions.length === 0) return; + const handleKeyDown = (e: KeyboardEvent) => { if (isSubmitting) return; @@ -174,20 +171,21 @@ export const UserQuestionTool = ({ e.preventDefault(); if (currentHasAnswer) { handleContinue(); - } else if (options[focusedOption] && current) { + } else if (options[focusedOption] && currentQuestion) { handleOptionClick({ - questionText: current.question, + questionText: currentQuestion, optionLabel: options[focusedOption].label, questionIndex: currentIndex, }); } } else if (e.key >= "1" && e.key <= "9") { const idx = Number.parseInt(e.key, 10) - 1; - if (idx >= 0 && idx < options.length && current) { + const opt = options[idx]; + if (opt && currentQuestion) { e.preventDefault(); handleOptionClick({ - questionText: current.question, - optionLabel: options[idx].label, + questionText: currentQuestion, + optionLabel: opt.label, questionIndex: currentIndex, }); setFocusedOption(idx); @@ -199,7 +197,7 @@ export const UserQuestionTool = ({ return () => document.removeEventListener("keydown", handleKeyDown); }, [ options, - current, + currentQuestion, currentIndex, focusedOption, handleOptionClick, @@ -209,6 +207,8 @@ export const UserQuestionTool = ({ isSubmitting, ]); + if (questions.length === 0 || !current) return null; + return (
- {current?.header ?? "Question"} + {current.header ?? "Question"} · @@ -268,15 +268,14 @@ export const UserQuestionTool = ({ >
{currentIndex + 1}.{" "} - {current?.question} + {current.question}
{/* Options */}
{options.map((option, optIndex) => { - const selected = current - ? isSelected(current.question, option.label) - : false; + const selected = + answers[current.question]?.includes(option.label) ?? false; const focused = focusedOption === optIndex; return ( @@ -289,7 +288,7 @@ export const UserQuestionTool = ({ disabled={isSubmitting} key={option.label} onClick={() => { - if (isSubmitting || !current) return; + if (isSubmitting) return; handleOptionClick({ questionText: current.question, optionLabel: option.label, From 6f46d1e230baa517f7d565d662a36b2417ff360c Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 02:02:32 -0800 Subject: [PATCH 10/11] Fix chunks --- .../ChatMessageItem/ChatMessageItem.tsx | 50 +++++++++++++++++++ packages/durable-session/src/materialize.ts | 26 ++++++++++ 2 files changed, 76 insertions(+) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx index 5a8da8ad156..c064a306356 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx @@ -18,11 +18,14 @@ import { ReasoningContent, ReasoningTrigger, } from "@superset/ui/ai-elements/reasoning"; +import { useState } from "react"; 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"; +const DEBUG_PARTS = true; + interface ChatMessageItemProps { message: UIMessage; onApprove?: (approvalId: string) => void; @@ -161,9 +164,56 @@ export function ChatMessageItem({ } // Assistant messages: render with tool grouping + text + const [showDebug, setShowDebug] = useState(false); + return ( + {DEBUG_PARTS && ( + + )} + {showDebug && ( +
+						{JSON.stringify(
+							message.parts.map((p) => {
+								if (p.type === "text")
+									return {
+										type: "text",
+										content:
+											p.content.length > 200
+												? `${p.content.slice(0, 200)}... (${p.content.length} chars)`
+												: p.content,
+									};
+								if (p.type === "tool-call")
+									return {
+										type: "tool-call",
+										id: p.id,
+										name: p.name,
+										state: p.state,
+										hasOutput: p.output != null,
+										argsLen: p.arguments?.length,
+									};
+								if (p.type === "tool-result")
+									return {
+										type: "tool-result",
+										toolCallId: p.toolCallId,
+										state: p.state,
+										error: p.error,
+										contentLen: p.content?.length,
+									};
+								return { type: p.type };
+							}),
+							null,
+							2,
+						)}
+					
+ )} {grouped.map((item) => { if (item.kind === "exploring-group") { const groupKey = item.items.map((i) => i.part.id).join("-"); diff --git a/packages/durable-session/src/materialize.ts b/packages/durable-session/src/materialize.ts index d6472072797..2e82ed13ca7 100644 --- a/packages/durable-session/src/materialize.ts +++ b/packages/durable-session/src/materialize.ts @@ -96,6 +96,12 @@ function materializeAssistantMessage(rows: ChunkRow[]): MessageRow { let isComplete = false; + // Track per-segment text so we can inject `content` for segment detection. + // Without `content`, StreamProcessor's isNewTextSegment() always returns false + // and text accumulates across agent turns instead of creating separate parts. + let segmentText = ""; + let hadToolSinceText = false; + for (const row of sorted) { const chunk = parseChunk(row.chunk); if (!chunk) continue; @@ -114,6 +120,26 @@ function materializeAssistantMessage(rows: ChunkRow[]): MessageRow { // Skip whole-message chunks (shouldn't be in assistant messages, but guard) if (isWholeMessageChunk(chunk)) continue; + // Enrich TEXT_MESSAGE_CONTENT with `content` for segment boundary detection. + // When text follows tool calls, reset the accumulator so `content` is shorter + // than the previous segment — this triggers StreamProcessor.isNewTextSegment(). + if (type === "TEXT_MESSAGE_CONTENT") { + const textChunk = chunk as { delta?: string; content?: string }; + if (hadToolSinceText) { + segmentText = ""; + hadToolSinceText = false; + } + segmentText += textChunk.delta ?? ""; + if (textChunk.content === undefined) { + textChunk.content = segmentText; + } + } else if ( + type === "TOOL_CALL_START" || + type === "TOOL_CALL_END" + ) { + hadToolSinceText = true; + } + // Process TanStack AI StreamChunk try { processor.processChunk(chunk as StreamChunk); From 14c1d4f3e66f7515a8f7bf26f5066eff56bfb461 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 02:05:03 -0800 Subject: [PATCH 11/11] Fix stream and refactor --- .../ChatMessageItem/ChatMessageItem.tsx | 50 ------------------- apps/streams/src/sdk-to-ai-chunks.ts | 17 +++---- .../src/enrich-text-segments.ts | 49 ++++++++++++++++++ packages/durable-session/src/index.ts | 6 +++ packages/durable-session/src/materialize.ts | 32 ++---------- 5 files changed, 67 insertions(+), 87 deletions(-) create mode 100644 packages/durable-session/src/enrich-text-segments.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx index c064a306356..5a8da8ad156 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx @@ -18,14 +18,11 @@ import { ReasoningContent, ReasoningTrigger, } from "@superset/ui/ai-elements/reasoning"; -import { useState } from "react"; 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"; -const DEBUG_PARTS = true; - interface ChatMessageItemProps { message: UIMessage; onApprove?: (approvalId: string) => void; @@ -164,56 +161,9 @@ export function ChatMessageItem({ } // Assistant messages: render with tool grouping + text - const [showDebug, setShowDebug] = useState(false); - return ( - {DEBUG_PARTS && ( - - )} - {showDebug && ( -
-						{JSON.stringify(
-							message.parts.map((p) => {
-								if (p.type === "text")
-									return {
-										type: "text",
-										content:
-											p.content.length > 200
-												? `${p.content.slice(0, 200)}... (${p.content.length} chars)`
-												: p.content,
-									};
-								if (p.type === "tool-call")
-									return {
-										type: "tool-call",
-										id: p.id,
-										name: p.name,
-										state: p.state,
-										hasOutput: p.output != null,
-										argsLen: p.arguments?.length,
-									};
-								if (p.type === "tool-result")
-									return {
-										type: "tool-result",
-										toolCallId: p.toolCallId,
-										state: p.state,
-										error: p.error,
-										contentLen: p.content?.length,
-									};
-								return { type: p.type };
-							}),
-							null,
-							2,
-						)}
-					
- )} {grouped.map((item) => { if (item.kind === "exploring-group") { const groupKey = item.items.map((i) => i.part.id).join("-"); diff --git a/apps/streams/src/sdk-to-ai-chunks.ts b/apps/streams/src/sdk-to-ai-chunks.ts index d5bc2930bb0..a2300987c5a 100644 --- a/apps/streams/src/sdk-to-ai-chunks.ts +++ b/apps/streams/src/sdk-to-ai-chunks.ts @@ -16,6 +16,7 @@ */ import type { StreamChunk } from "@tanstack/ai"; +import { createTextSegmentEnricher } from "@superset/durable-session"; // ============================================================================ // Claude SDK Types (subset used for conversion) @@ -109,7 +110,6 @@ interface ActiveBlock { toolCallId?: string; toolName?: string; argsAccumulator?: string; - textAccumulator?: string; } export interface ConversionState { @@ -132,10 +132,14 @@ export function createConverter(): { runId: crypto.randomUUID(), }; + const enrichChunk = createTextSegmentEnricher(); + return { state, convert(message: SDKMessage): StreamChunk[] { - return convertMessage(state, message); + return convertMessage(state, message).map((chunk) => + enrichChunk(chunk as StreamChunk & { [key: string]: unknown }), + ); }, }; } @@ -202,7 +206,7 @@ function handleContentBlockStart( switch (block.type) { case "text": { - state.activeBlocks.set(index, { type: "text", textAccumulator: "" }); + state.activeBlocks.set(index, { type: "text" }); return []; } @@ -259,18 +263,11 @@ function handleContentBlockDelta( switch (delta.type) { case "text_delta": { - // Track per-block accumulated text so the StreamProcessor - // can detect new text segments across agent turns. - if (block?.type === "text") { - block.textAccumulator = - (block.textAccumulator ?? "") + delta.text; - } return [ { type: "TEXT_MESSAGE_CONTENT", messageId: state.messageId, delta: delta.text, - content: block?.textAccumulator ?? delta.text, timestamp: now, } satisfies StreamChunk, ]; diff --git a/packages/durable-session/src/enrich-text-segments.ts b/packages/durable-session/src/enrich-text-segments.ts new file mode 100644 index 00000000000..7b7fd212f5f --- /dev/null +++ b/packages/durable-session/src/enrich-text-segments.ts @@ -0,0 +1,49 @@ +/** + * Enriches TEXT_MESSAGE_CONTENT chunks with a `content` field so + * TanStack AI's StreamProcessor can detect text segment boundaries. + * + * Problem: StreamProcessor.isNewTextSegment() only fires when `content` + * is present. Without it, text accumulates across agent turns — each + * successive text part contains ALL previous text instead of just the + * current segment. + * + * Solution: Track per-segment accumulated text and inject `content`. + * When text follows tool calls, the accumulator resets so `content` + * is shorter than the previous segment, triggering a new segment. + * + * @example + * ```ts + * const enrich = createTextSegmentEnricher(); + * for (const chunk of chunks) { + * processor.processChunk(enrich(chunk)); + * } + * ``` + */ +export function createTextSegmentEnricher(): < + T extends { type: string; [key: string]: unknown }, +>( + chunk: T, +) => T { + let segmentText = ""; + let hadToolSinceText = false; + + return (chunk) => { + if (chunk.type === "TEXT_MESSAGE_CONTENT") { + if (hadToolSinceText) { + segmentText = ""; + hadToolSinceText = false; + } + const delta = (chunk as { delta?: string }).delta ?? ""; + segmentText += delta; + if ((chunk as { content?: string }).content === undefined) { + (chunk as { content?: string }).content = segmentText; + } + } else if ( + chunk.type === "TOOL_CALL_START" || + chunk.type === "TOOL_CALL_END" + ) { + hadToolSinceText = true; + } + return chunk; + }; +} diff --git a/packages/durable-session/src/index.ts b/packages/durable-session/src/index.ts index c196a9e72c0..291e6965a23 100644 --- a/packages/durable-session/src/index.ts +++ b/packages/durable-session/src/index.ts @@ -180,3 +180,9 @@ export { messageRowToUIMessage, parseChunk, } from "./materialize"; + +// ============================================================================ +// Stream Utilities +// ============================================================================ + +export { createTextSegmentEnricher } from "./enrich-text-segments"; diff --git a/packages/durable-session/src/materialize.ts b/packages/durable-session/src/materialize.ts index 2e82ed13ca7..78747b2ae41 100644 --- a/packages/durable-session/src/materialize.ts +++ b/packages/durable-session/src/materialize.ts @@ -17,6 +17,7 @@ import type { StreamChunk, UIMessage } from "@tanstack/ai"; import { StreamProcessor } from "@tanstack/ai"; +import { createTextSegmentEnricher } from "./enrich-text-segments"; import type { ChunkRow } from "./schema"; import type { DurableStreamChunk, @@ -95,12 +96,7 @@ function materializeAssistantMessage(rows: ChunkRow[]): MessageRow { processor.startAssistantMessage(); let isComplete = false; - - // Track per-segment text so we can inject `content` for segment detection. - // Without `content`, StreamProcessor's isNewTextSegment() always returns false - // and text accumulates across agent turns instead of creating separate parts. - let segmentText = ""; - let hadToolSinceText = false; + const enrichChunk = createTextSegmentEnricher(); for (const row of sorted) { const chunk = parseChunk(row.chunk); @@ -120,29 +116,11 @@ function materializeAssistantMessage(rows: ChunkRow[]): MessageRow { // Skip whole-message chunks (shouldn't be in assistant messages, but guard) if (isWholeMessageChunk(chunk)) continue; - // Enrich TEXT_MESSAGE_CONTENT with `content` for segment boundary detection. - // When text follows tool calls, reset the accumulator so `content` is shorter - // than the previous segment — this triggers StreamProcessor.isNewTextSegment(). - if (type === "TEXT_MESSAGE_CONTENT") { - const textChunk = chunk as { delta?: string; content?: string }; - if (hadToolSinceText) { - segmentText = ""; - hadToolSinceText = false; - } - segmentText += textChunk.delta ?? ""; - if (textChunk.content === undefined) { - textChunk.content = segmentText; - } - } else if ( - type === "TOOL_CALL_START" || - type === "TOOL_CALL_END" - ) { - hadToolSinceText = true; - } - // Process TanStack AI StreamChunk try { - processor.processChunk(chunk as StreamChunk); + processor.processChunk( + enrichChunk(chunk as StreamChunk & { [key: string]: unknown }), + ); } catch { // Skip chunks that can't be processed }